Skills/agent-evals/scripts/run-trigger-evals.ps1

#requires -Version 7.0
<#
.SYNOPSIS
    Trigger-eval harness: measures whether a skill's description causes an
    agent to select it, using labelled positive and near-miss negative queries
    with a train/validation split.
 
.DESCRIPTION
    Output evals (run-evals.ps1) ask "given the skill fired, was the answer
    good?". This asks the prior question: "does the skill fire at all, and does
    it stay quiet when it shouldn't?". A skill that never triggers cannot
    produce a good output, so this gate comes first.
 
    The harness runs in three modes.
 
    -Mode Prepare (no credential needed)
        Validates the query set, loads every SKILL.md description from -SkillRoot,
        and emits one judge prompt per query x repetition into -WorkDir. Each
        prompt presents the full skill catalogue and one user query, and asks
        which skill would be selected. Feed these to a model that has NOT seen
        the authoring session, save each reply as the matching .out.txt, then
        run -Mode Grade.
 
    -Mode Execute (needs ShellPilot)
        Does the same as Prepare, then answers each prompt itself via
        Invoke-Shp and writes the replies, so Grade can run immediately.
        Requires the ShellPilot module and a working backend.
 
        The judge runs with tools, browsing, file and terminal access all
        disabled. It sees only the catalogue and the query, so its answer
        cannot be contaminated by the repository it is judging. The session
        conversation is reset before every call, so each judge call is a genuinely
        fresh context rather than one carrying every previous verdict.
 
    -Mode Grade (no credential needed)
        Reads the replies, extracts the selected skill name, and reports the
        trigger rate per query and the pass rate per split.
 
    The train/validation split exists to catch overfitting. Iterate the
    description against train queries only; validation queries are scored but
    never used to decide an edit. If train improves while validation stalls,
    the description has been tuned to the test rather than to the concept.
 
    A note on who may judge. Do not grade a skill using the same session that
    wrote it: that measures recall of authoring intent, not discoverability.
    Execute mode satisfies this because each call is a fresh context with no
    history of the authoring conversation - which requires resetting the
    ShellPilot session conversation between calls, since Invoke-Shp continues it
    by default. See the comment in Execute mode.
 
.PARAMETER QueryFile
    Labelled query set. Schema: [ { id, query, should_trigger, split, note? } ].
 
.PARAMETER TargetSkill
    Directory name of the skill under test, e.g. 'skill-creator'.
 
.PARAMETER SkillRoot
    Root holding one directory per skill, each with a SKILL.md.
 
.PARAMETER WorkDir
    Where prompts and replies live.
 
.PARAMETER Repetitions
    Runs per query. Selection is stochastic; a single run cannot distinguish a
    reliable trigger from a lucky one. Default 3.
 
.PARAMETER TriggerThreshold
    Fraction of repetitions that must agree for a query to count as triggering.
    Default 0.5.
 
.PARAMETER Model
    Execute mode only. The judge model. A cheap model is appropriate: the task
    is a single forced-choice selection, not reasoning.
 
.PARAMETER MaxBudgetUSD
    Execute mode only. Hard ceiling passed to each call.
 
.PARAMETER Force
    Execute mode only. Re-answer prompts whose reply file already exists.
 
.EXAMPLE
    ./run-trigger-evals.ps1 -Mode Prepare -QueryFile ../assets/trigger-queries.skill-creator.json -TargetSkill skill-creator -SkillRoot ../../ -WorkDir ./work
 
.EXAMPLE
    ./run-trigger-evals.ps1 -Mode Execute -QueryFile ../assets/trigger-queries.skill-creator.json -TargetSkill skill-creator -SkillRoot ../../ -WorkDir ./work
 
.EXAMPLE
    ./run-trigger-evals.ps1 -Mode Grade -QueryFile ../assets/trigger-queries.skill-creator.json -TargetSkill skill-creator -WorkDir ./work
#>

[CmdletBinding()]
param(
    [Parameter(Mandatory)]
    [ValidateSet('Prepare', 'Execute', 'Grade')]
    [string] $Mode,

    [Parameter(Mandatory)]
    [ValidateScript({ Test-Path -LiteralPath $_ -PathType Leaf })]
    [string] $QueryFile,

    [Parameter(Mandatory)]
    [string] $TargetSkill,

    [string] $SkillRoot,

    [Parameter(Mandatory)]
    [string] $WorkDir,

    [ValidateRange(1, 20)]
    [int] $Repetitions = 3,

    [ValidateRange(0.0, 1.0)]
    [double] $TriggerThreshold = 0.5,

    [string] $Model = 'claude-haiku-4.5',

    [double] $MaxBudgetUSD = 2.0,

    [switch] $Force
)

$ErrorActionPreference = 'Stop'

function Get-SkillCatalogue {
    [CmdletBinding()]
    param([Parameter(Mandatory)][string] $Root)

    if (-not (Get-Module -ListAvailable -Name powershell-yaml)) {
        throw 'powershell-yaml is required. Install-Module powershell-yaml -Scope CurrentUser'
    }
    Import-Module powershell-yaml -ErrorAction Stop

    foreach ($file in Get-ChildItem -LiteralPath $Root -Filter 'SKILL.md' -Recurse -File) {
        $raw = [System.IO.File]::ReadAllText($file.FullName)
        $m = [regex]::Match($raw, '(?s)\A---\r?\n(.*?)\r?\n---\r?\n')
        if (-not $m.Success) {
            Write-Warning "No frontmatter: $($file.FullName)"
            continue
        }
        try {
            $fm = ConvertFrom-Yaml $m.Groups[1].Value
        }
        catch {
            # A skill whose frontmatter will not load cannot be selected at all.
            Write-Warning "Frontmatter does not parse, skill is unloadable: $($file.Directory.Name) - $($_.Exception.Message)"
            continue
        }
        [pscustomobject]@{
            Name        = [string]$fm.name
            Description = [string]$fm.description
        }
    }
}

function Test-QuerySet {
    [CmdletBinding()]
    param([Parameter(Mandatory)][object[]] $Queries)

    $problems = [System.Collections.Generic.List[string]]::new()

    $dupes = $Queries | Group-Object id | Where-Object Count -gt 1
    foreach ($d in $dupes) { $problems.Add("duplicate id: $($d.Name)") }

    foreach ($q in $Queries) {
        if ([string]::IsNullOrWhiteSpace($q.id))    { $problems.Add('a query has no id') }
        if ([string]::IsNullOrWhiteSpace($q.query)) { $problems.Add("query '$($q.id)' has empty text") }
        if ($q.split -notin 'train', 'validation')  { $problems.Add("query '$($q.id)' has invalid split '$($q.split)'") }
        if ($q.should_trigger -isnot [bool])        { $problems.Add("query '$($q.id)' should_trigger is not a boolean") }
    }

    # Upstream guidance: 8-10 positives and 8-10 near-miss negatives. Fewer
    # negatives than positives is the common failure - it hides over-triggering,
    # because a description that matches everything scores perfectly on
    # positives alone.
    $pos = @($Queries | Where-Object { $_.should_trigger }).Count
    $neg = @($Queries | Where-Object { -not $_.should_trigger }).Count
    if ($pos -lt 8) { $problems.Add("only $pos positive queries; guidance says 8-10") }
    if ($neg -lt 8) { $problems.Add("only $neg negative queries; guidance says 8-10") }

    foreach ($split in 'train', 'validation') {
        $inSplit = @($Queries | Where-Object { $_.split -eq $split })
        if (@($inSplit | Where-Object { $_.should_trigger }).Count -lt 1) {
            $problems.Add("split '$split' has no positive queries")
        }
        if (@($inSplit | Where-Object { -not $_.should_trigger }).Count -lt 1) {
            $problems.Add("split '$split' has no negative queries")
        }
    }

    $problems
}

function New-JudgePromptSet {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]   $Root,
        [Parameter(Mandatory)][string]   $Target,
        [Parameter(Mandatory)][object[]] $Queries,
        [Parameter(Mandatory)][int]      $Reps
    )

    $catalogue = @(Get-SkillCatalogue -Root $Root | Sort-Object Name)
    if (-not $catalogue) { throw "No loadable skills found under '$Root'." }
    if ($catalogue.Name -notcontains $Target) {
        throw "Target skill '$Target' is not in the catalogue - it may be unloadable. See warnings above."
    }

    $sb = [System.Text.StringBuilder]::new()
    foreach ($s in $catalogue) { $null = $sb.AppendLine("- $($s.Name): $($s.Description)") }
    $catalogueText = $sb.ToString()

    foreach ($q in $Queries) {
        foreach ($rep in 1..$Reps) {
            $prompt = @"
You are an agent with the skills listed below. Read the user message and decide
which single skill, if any, you would load before answering.
 
Answer with exactly one line:
SELECTED: <skill-name>
or
SELECTED: none
 
Do not explain.
 
AVAILABLE SKILLS
$catalogueText
USER MESSAGE
$($q.query)
"@

            [pscustomobject]@{
                Id     = $q.id
                Rep    = $rep
                Count  = $catalogue.Count
                Prompt = $prompt
            }
        }
    }
}

$queries = @(Get-Content -LiteralPath $QueryFile -Raw -Encoding utf8 | ConvertFrom-Json)
if (-not $queries) { throw "Query file '$QueryFile' is empty." }

$issues = @(Test-QuerySet -Queries $queries)
if ($issues.Count -gt 0) {
    Write-Host 'Query set problems:' -ForegroundColor Yellow
    $issues | ForEach-Object { Write-Host " - $_" -ForegroundColor Yellow }
    if ($issues | Where-Object { $_ -match 'duplicate|empty|invalid|not a boolean' }) {
        throw 'Query set is structurally invalid; fix the errors above.'
    }
}

$null = New-Item -ItemType Directory -Path $WorkDir -Force

switch ($Mode) {
    'Prepare' {
        if (-not $SkillRoot) { throw '-SkillRoot is required in Prepare mode.' }

        $set = @(New-JudgePromptSet -Root $SkillRoot -Target $TargetSkill -Queries $queries -Reps $Repetitions)
        $enc = New-Object System.Text.UTF8Encoding $false
        foreach ($item in $set) {
            $path = Join-Path $WorkDir "$($item.Id).rep$($item.Rep).prompt.txt"
            [System.IO.File]::WriteAllText($path, $item.Prompt, $enc)
        }

        Write-Host "Prepared $($set.Count) prompts in '$WorkDir' across $($set[0].Count) skills." -ForegroundColor Cyan
        Write-Host 'Run each against a model with no prior context of this session,' -ForegroundColor Cyan
        Write-Host 'save the reply next to it as <id>.rep<n>.out.txt, then use -Mode Grade.' -ForegroundColor Cyan
    }

    'Execute' {
        if (-not $SkillRoot) { throw '-SkillRoot is required in Execute mode.' }
        if (-not (Get-Command Invoke-Shp -ErrorAction SilentlyContinue)) {
            throw 'Execute mode needs the ShellPilot module (Invoke-Shp). Use -Mode Prepare instead.'
        }

        $set = @(New-JudgePromptSet -Root $SkillRoot -Target $TargetSkill -Queries $queries -Reps $Repetitions)
        $enc = New-Object System.Text.UTF8Encoding $false
        $done = 0
        $failed = 0
        $spent = 0.0

        foreach ($item in $set) {
            $stem = Join-Path $WorkDir "$($item.Id).rep$($item.Rep)"
            [System.IO.File]::WriteAllText("$stem.prompt.txt", $item.Prompt, $enc)
            $outPath = "$stem.out.txt"

            if ((Test-Path -LiteralPath $outPath -PathType Leaf) -and -not $Force) {
                $done++
                continue
            }

            try {
                # Every Invoke-Shp -Prompt call seeds from AND writes back to the
                # module-scoped session conversation, so a loop like this one
                # accumulates every prompt and reply. Measured 2026-08-11 on this
                # query set: calls 1-18 succeeded and calls 19-54 all failed with
                # HTTP 400 model_max_prompt_tokens_exceeded once the accumulated
                # conversation passed claude-haiku-4.5's 136k window - and never
                # recovered, because a failed call does not write back. Re-running
                # the script "fixed" it only because a fresh process starts with
                # an empty conversation, which is what made the failure look
                # transient and get misread as rate limiting.
                #
                # Resetting here also restores the isolation this harness claims:
                # a judge that carries 18 previous verdicts is not a fresh
                # context, so the scores were contaminated well before the first
                # 400. Do not remove without re-measuring.
                Clear-ShpChat

                $r = Invoke-Shp -Prompt $item.Prompt -Model $Model `
                    -DisableUserTools -DisableBrowsing -DisableFileAccess `
                    -DisableTerminal -DisableUserPrompts -DisableTodoList `
                    -MaxBudgetUSD $MaxBudgetUSD -TimeoutSec 120 -ErrorAction Stop
                [System.IO.File]::WriteAllText($outPath, [string]$r.Content, $enc)
                if ($null -ne $r.CostUSD) { $spent += [double]$r.CostUSD }
                $done++
            }
            catch {
                # Record the failure rather than aborting: one bad call should not
                # discard the rest of the run. Grade counts missing replies.
                Write-Warning "$($item.Id) rep$($item.Rep) failed: $($_.Exception.Message)"
                $failed++
            }

            Write-Progress -Activity 'Trigger eval' -Status "$done/$($set.Count)" `
                -PercentComplete (100.0 * $done / $set.Count)
        }
        Write-Progress -Activity 'Trigger eval' -Completed

        Write-Host "Executed $done/$($set.Count) prompts against $Model failures=$failed cost=$([math]::Round($spent, 4)) USD" -ForegroundColor Cyan
        Write-Host "Now: -Mode Grade -WorkDir '$WorkDir'" -ForegroundColor Cyan
    }

    'Grade' {
        $missing = 0
        $rows = foreach ($q in $queries) {
            $hits = 0
            $seen = 0
            foreach ($rep in 1..$Repetitions) {
                $out = Join-Path $WorkDir "$($q.id).rep$rep.out.txt"
                if (-not (Test-Path -LiteralPath $out -PathType Leaf)) { $missing++; continue }
                $seen++
                $text = [string](Get-Content -LiteralPath $out -Raw -Encoding utf8)
                $m = [regex]::Match($text, '(?im)^\s*SELECTED:\s*(?<sel>[a-z0-9._-]+)\s*$')
                if ($m.Success -and $m.Groups['sel'].Value -eq $TargetSkill) { $hits++ }
            }

            $rate = if ($seen -gt 0) { $hits / $seen } else { [double]::NaN }
            $triggered = ($seen -gt 0 -and $rate -ge $TriggerThreshold)

            [pscustomobject]@{
                Id       = $q.id
                Split    = $q.split
                Expected = [bool]$q.should_trigger
                Runs     = $seen
                Hits     = $hits
                Rate     = if ($seen -gt 0) { [math]::Round($rate, 2) } else { $null }
                Correct  = ($seen -gt 0 -and $triggered -eq [bool]$q.should_trigger)
            }
        }

        if ($missing -gt 0) {
            Write-Warning "$missing reply file(s) missing; those repetitions were not scored."
        }

        $scored = @($rows | Where-Object { $_.Runs -gt 0 })
        if (-not $scored) {
            Write-Host 'No replies found. Run -Mode Prepare or -Mode Execute, then grade.' -ForegroundColor Yellow
            exit 2
        }

        $rows | Sort-Object Split, Id | Format-Table -AutoSize

        foreach ($split in 'train', 'validation') {
            $s = @($scored | Where-Object { $_.Split -eq $split })
            if (-not $s) { continue }
            $ok = @($s | Where-Object Correct).Count
            $fp = @($s | Where-Object { -not $_.Correct -and -not $_.Expected }).Count
            $fn = @($s | Where-Object { -not $_.Correct -and $_.Expected }).Count
            '{0,-11} pass {1,2}/{2,-2} ({3,5:P0}) false-positive {4} false-negative {5}' -f `
                $split, $ok, $s.Count, ($ok / $s.Count), $fp, $fn
        }

        Write-Host ''
        Write-Host 'Iterate on train only. If train climbs while validation does not,' -ForegroundColor Cyan
        Write-Host 'the description is overfitted - generalise to the concept instead of' -ForegroundColor Cyan
        Write-Host 'adding keywords from failed queries.' -ForegroundColor Cyan
    }
}