Skip to main content

In this article

Evals in CI

This guide describes how the vally eval pipeline authenticates in CI, how forked pull requests are handled, and how to add a new eval spec for an AI artifact (agent, prompt, instructions file, or skill).

Required Secret​

The eval-execute job in .github/workflows/pr-validation.yml runs the vally eval command for each changed AI artifact. The vally CLI delegates to the @github/copilot CLI, which requires a GitHub credential exported as COPILOT_GITHUB_TOKEN.

Configure the secret at the repository (or organization) level:

  • Settings -> Secrets and variables -> Actions -> New repository secret
  • Name: COPILOT_GITHUB_TOKEN
  • Value: a token from one of the accepted token types listed below.

Token-Type Guidance​

The @github/copilot CLI accepts the following token prefixes. Classic personal access tokens (ghp_) are rejected at runtime.

PrefixToken TypeUse in CI
ghs_GitHub App installation tokenPreferred. Short-lived, scoped, auditable
github_pat_Fine-grained personal access tokenAcceptable when a GitHub App is not feasible
gho_, ghu_OAuth / user-to-server tokenAvoid. Tied to a user identity
ghp_Classic personal access tokenRejected at runtime. The probe fails fast
GITHUB_TOKENActions-issued tokenScope-limited. Not sufficient for vally eval

A GitHub App that mints an installation token in CI is the preferred target state, because a leaked installation token expires in about an hour rather than remaining valid until someone notices.

hve-core has not adopted that pattern for this credential. COPILOT_GITHUB_TOKEN is currently a fine-grained personal access token held as a repository secret, which the table above rates as acceptable where a GitHub App is not feasible. Adopting a GitHub App here requires first confirming that an App can carry the Copilot SDK scopes the CLI needs.

Probe Behavior​

scripts/evals/Test-CopilotToken.ps1 runs before any vally eval invocation and exits non-zero with a ::error:: annotation when:

  • COPILOT_GITHUB_TOKEN is missing or empty and gh auth token cannot supply a token (fallback for local runs with the GitHub CLI logged in)
  • the token begins with ghp_ (classic PAT)
  • the optional -SmokeTest switch invokes vally --version and the CLI exits non-zero

The pass-path Reason includes (source: COPILOT_GITHUB_TOKEN) or (source: gh auth token) so contributors can confirm which credential path was used. The smoke test reports a clean skip when vally is not installed locally, so contributors can run the probe outside CI without installing the CLI.

Per-Job COPILOT_HOME Isolation​

The @github/copilot CLI persists state (logged-in users, caches) under the directory named by COPILOT_HOME, defaulting to ~/.copilot. CI jobs share runner home directories across steps and can pollute each other when this state leaks.

Export COPILOT_HOME to a job-local path in every workflow job that invokes vally:

env:
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
COPILOT_HOME: ${{ runner.temp }}/copilot-home

This pattern keeps each eval job hermetic, prevents credential bleed-through between matrix legs, and avoids the deprecated --config-dir CLI flag.

Bounded Eval Execution​

Eval Validation creates logs/agent-eval-plan.json once before any model-backed work. The digest-covered plan records both changed-artifact manifest hashes, conditional baseline applicability, planned shards, artifact and run-key ownership, expected trial weights, and the complete producer set.

Agent, instruction, and skill artifacts stay cohesive. Artifacts connected through one deduplicated run key remain in the same shard, and deterministic longest-first assignment balances each kind by selected stimuli multiplied by declared runs. Agent work uses at most four ordinary-## shards. Instruction and skill each use at most two instruction-## and skill-## shards. Prompt work retains one producer.

The execution matrix therefore contains one prompt row plus the planned agent, instruction, and skill rows. max-parallel: 6 bounds that matrix. Baseline equivalence runs only when the canonical plan marks baseline.required: true; its fixed GPT and Claude producers use separate runner filesystems and a configurable maximum parallelism of two. The plan requires it when the changed-artifact manifest lists rpi-agent in affectedAgents. A change reaches that list through the agent dependency map, or by touching any file inside a skill directory that the equivalence harness materializes for rpi-agent, including the skills it names in backticks such as rpi-research.

At default shard counts the matrix holds more rows than the cap allows to start at once, so some rows wait for a free slot. To keep the longest producers from waiting behind short ones, the planned rows are emitted heaviest first, ordered by the expectedTrialWeight the canonical plan already computes, with an ascending shard-id tie-break that keeps the emitted matrix reproducible. The static prompt row stays first. This ordering is workload-adaptive: whichever kind carries the most trial weight for a given change is dispatched first, without hardcoding a kind order.

Treat that ordering as a scheduling heuristic rather than a guarantee. Matrix order determines the order in which jobs are created, but GitHub does not guarantee the order in which matrix jobs run, and expected trial weight predicts runtime only loosely because retries and per-trial variance are not modeled. Judge the benefit from the queue phase described under Hosted Measurement rather than assuming a fixed speedup.

Every producer validates the plan identity it consumes. Ordinary shards verify exact kind, plan and manifest digests, artifact ownership, and run-key ownership before invoking Vally. Baseline producers verify their model is present in the signed plan and stamp its digest into their evidence envelope.

Authoritative Fan-In​

The baseline fan-in validates one current-run envelope for each required fixed model and computes the combined verdict through the shared baseline aggregation function. The global fan-in then requires every planned ordinary producer, the prompt producer, and the combined baseline summary when applicable. Missing, duplicate, unexpected, unparseable, wrong-kind, digest-mismatched, artifact-incomplete, or run-key-incomplete evidence fails closed.

eval-report is presentation-only. It downloads the single eval-authoritative artifact and renders its eval-summary.json; it does not concatenate partial summaries or decide whether evidence is complete.

The per-artifact table includes Input tokens / trial and Cache-read tokens / trial columns. Each value is the artifact's summed token count divided by the trials that reported usage, taken from Vally's native trajectory.metrics.tokenUsage for the selected attempt of each spec. The columns are advisory and never gate a pull request. Model-backed trials vary from run to run, so compare a value against several runs rather than one. A dash means no trial reported usable token counts, or the summary predates token reporting.

The token columns never fail a pull request. Cold-start growth is enforced statically instead: npm run lint:cold-start and its Pester suite sum each planning-chain agent's file, recursive #file: imports, and always-on instructions against the budgets in scripts/linting/agent-cold-start-budgets.json, and fail when a set exceeds its ceiling. This replaces the retired activation harness, which gated only the ADR Creator.

Advisory Model Lanes​

The equivalence-advisory job reports how additional models behave on the baseline-equivalence suite without gating the pull request. It currently runs Microsoft mai-code-1.1-flash.

  • When it runs: under the same conditions as the gating baseline model lanes, and in parallel with them.
  • What it runs: the equivalence driver in the advisory devloop tier with -Model mai-code-1.1-flash, -NoBaselineCache, and the shared compare-shard-count. The model runs at its default reasoning and context settings because GitHub does not list it among models with configurable reasoning or the extended context window.
  • Why it never gates: the job is continue-on-error, no fan-in lists it in needs, and its artifact name matches no fan-in download pattern. MAI models also receive new checkpoints over time, so their behavior can change independently of this repository.
  • Where results appear: the job's step summary lists the verdict, gate, and key counts, and the advisory-equivalence-mai artifact holds only the summary JSON.

To add a model, add a matrix entry to equivalence-advisory and confirm the model is listed in scripts/linting/model-catalog.json; the workflow contract tests reject an uncatalogued model. If a parallel advisory lane causes judge errors in the gating lanes, add equivalence-execute to the advisory job's needs so it runs after them. To stop the advisory lanes without a workflow edit, set the reusable workflow input advisory-equivalence to false; see Rollback Controls.

Trusted Progress​

Vally stdout and stderr can contain prompts, responses, trajectories, arbitrary errors, or environment content. The process wrapper drains both streams asynchronously to withheld runner-local files and never replays raw lines to public workflow output.

While a phase is active, logs expose only these bounded fields:

  • Event: phase-start, heartbeat, or phase-complete
  • Phase: one declared eval phase such as ordinary-eval, baseline-eval, or compare
  • Sanitized worker identifier. A baseline compare shard reports <model>:compare-NN.
  • Attempt number
  • Elapsed seconds
  • Fixed exit category

The default heartbeat interval is 60 seconds. Aggregate summaries carry diagnostic phaseTimings, with one compare entry per shard attempt; timing does not affect evaluation verdicts. A compare shard worker that fails before a process result exists records the fixed category worker-error.

Rollback Controls​

The reusable workflow inputs change scheduling without changing evidence semantics:

InputNormal valueRollback valueEffect
ordinary-shard-count41Uses the same planner and runner with one agent shard
instruction-shard-count21Uses the same planner and runner with one instruction shard
skill-shard-count21Uses the same planner and runner with one skill shard
baseline-max-parallel21Serializes the same isolated baseline model producers
compare-shard-count71Runs one serial vally compare per baseline model producer
advisory-equivalencetruefalseSkips the non-gating advisory model lanes

Each baseline model producer splits its comparison into compare-shard-count stimulus-disjoint vally compare shards that run concurrently and are merged into one comparison file before tallying. A shard that exits nonzero without any comparison record is retried once. The rollback value keeps that retry and runs a single compare over the run directories.

The single-process fixed-pair baseline driver remains a deterministic aggregation oracle for local tests. It is not a second production rollback path.

Run the deterministic and policy checks before changing the scheduling defaults:

npm run test:ps -- -TestPath scripts/tests/evals/
npm run lint:ps
npm run lint:yaml
npm run lint:permissions
npm run lint:workflow-runner
npm run lint:dangerous-workflow
npm run lint:dependency-pinning
npm run lint:pr-gate

Model-backed canaries additionally require COPILOT_GITHUB_TOKEN and the CI-owned moderation environment. Record unavailable credential-backed checks as pending CI.

Hosted Measurement​

Compare one-shard control and two-shard treatment runs only when their plan manifest digests, target artifact and run-key sets, model, declared trials, and policy version match. Separate these intervals rather than inferring improvement from job duration:

  • Queue: job creation to runner start
  • Setup: runner start to Execute evals
  • Execution: Execute evals start to completion
  • Post-execution: execution completion to producer job completion
  • Fan-in: authoritative fan-in queue and execution
  • Reporting: report queue and execution

Use aggregate phaseTimings to attribute worker attempts and moderation, not as a substitute for GitHub job timestamps. Sum-of-trial durationMs can exceed elapsed job time when Vally runs trials concurrently.

Across the first ten qualifying treatment runs and a comparable one-shard control cohort, the target is at least a 25 percent reduction in median queue-excluded target producer critical interval and at least a 10 percent reduction in median run-creation- to-report time. Fan-in plus reporting should remain within 15 seconds of the control median, and per-shard setup should remain within 15 seconds of its control-kind median.

Roll back instruction and skill shard counts to 1 when fan-in reports a contract failure, merge behavior changes advisory or authoritative outcomes, aggregate-only output boundaries are violated, queue or unknown-attempt rates repeatedly regress, or the treatment misses either performance target after ten qualifying runs. Keep the generalized planner and fan-in active during rollback so evidence semantics do not change with scheduling.

Fork PR Policy​

GitHub Actions does not expose repository secrets to workflows triggered by pull requests from forks. Without COPILOT_GITHUB_TOKEN, the eval-execute job cannot succeed.

The credential boundary uses an allowlist at two layers. The pr-validation.yml caller passes the token only for manual dispatch and same-repository pull requests, and passes an empty value for merge groups, fork pull requests, and every other event:

secrets:
copilot-github-token: ${{ (github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)) && secrets.COPILOT_GITHUB_TOKEN || '' }}

Each job in eval-validation.yml that uses the token, or needs a job that does, repeats the same-repository check in its condition, so the pipeline clean-skips eval execution for fork PRs rather than failing the check:

jobs:
eval-execute:
if: >-
needs.eval-validation.outputs.eval-relevant == 'true' &&
needs.agent-plan.outputs.execution-enabled == 'true' &&
((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
github.event_name == 'workflow_dispatch')

Compare the head repository's full_name with github.repository instead of using a fork == false check. GitHub expressions coerce both null and false to 0 before comparing, so a fork == false check passes when the head repository is missing, for example after a fork is deleted. The full_name comparison fails closed in that case.

The Eval credential boundary tests in scripts/tests/security/Test-MergeQueueWorkflowContract.Tests.ps1 evaluate both layers for trusted and untrusted events and fail when a token job lacks the check.

The eval-execute job is also skipped for non-eval-relevant PRs (those that change only documentation or other non-AI-artifact paths) through the eval-relevant output gate, independent of the fork policy.

The eval-presence and eval-lint jobs do run on fork PRs because they require no secrets. Structural problems with eval specs (missing coverage, schema violations, profanity in stimulus text) surface immediately. Eval execution itself runs only after a maintainer merges the fork branch into a trusted topic branch on the upstream repository.

Merge Groups and Full-Mode Validation​

The eval lane receives one change-range decision from pr-validation.yml. In range mode, it validates the exact resolved commits. Full mode occurs when the resolver cannot prove a range, so the lane validates everything it can without the token, and fails instead of skipping the steps that need a proven range.

RunContent moderationAgent-eval selection and execution
Pull request or manual dispatchChanged artifacts plus every eval specRuns for eligible changes; dispatch resolves against main and needs commits ahead of it
Merge groupChanged artifacts plus every eval specSkipped; merge groups never receive COPILOT_GITHUB_TOKEN
Any run in full modeEvery tracked AI artifact plus every eval specFails for eligible pull requests and manual dispatch; merge groups and forks still skip

Merge groups run relevance, lint, and content moderation unprivileged. A full-mode failure in agent-eval selection means the change-range job could not verify a non-empty range. A manual dispatch from the main tip, or from a branch with no commits ahead of main, has no range to verify and always takes this path; dispatch from a branch with changes instead. Otherwise, investigate the change-range job before rerunning.

Published Artifacts and the Transcript Boundary​

Workflow artifacts on a public repository are readable by anyone, and an agent transcript records whatever the agent printed. An agent asked to inspect the workspace can print the job environment, and the eval jobs export COPILOT_GITHUB_TOKEN into that environment. Nothing in the pipeline redacts transcripts.

Eval artifacts therefore publish aggregate results only. These paths are deliberately withheld:

Withheld pathReason
evals/results/**/results.jsonlPer-trial trajectories, including raw model output
logs/vally-compare-*.logRaw judge-run output

What is still published covers the summaries that drive the gate and the per-artifact debugging payloads: logs/eval-summary.json, logs/eval-results-*.json, logs/baseline-equivalence-*.json, and the changed-artifact and stimulus-presence manifests. None of these embed model output.

To narrow a failed program grader without a transcript, read its bounded failureCodes under perSpec[].diagnostics.attempts[].trials[].graders[] in eval-summary.json. Only codes from a fixed per-grader vocabulary are published; the agent-behavior suite guide lists the outcome grader's field codes.

The cost is that inspecting what an agent actually said during a failed run means re-running it rather than downloading the artifact. Do not re-add the withheld paths to make debugging easier.

Adding a New Eval Spec​

When you add or modify an AI artifact under .github/agents/, .github/prompts/, .github/instructions/, or .github/skills/, the eval-presence job fails the PR until a matching eval spec exists.

Steps to add coverage:

  1. Create an eval spec under evals/ that follows the structure documented in evals/README.md.

  2. Add a stimuli[].tags.<kind> backlink whose value is the artifact slug, where <kind> is one of agent, prompt, instruction, or skill, and the slug is the artifact basename minus its .agent.md, .prompt.md, .instructions.md, or SKILL.md suffix (for example, tags: {agent: code-review-functional} for .github/agents/coding-standards/subagents/code-review-functional.agent.md).

  3. Ensure the spec declares an executor compatible with the vally CLI (typically the CopilotSdkExecutor with a model: hint).

  4. Run the presence check locally to confirm the artifact is covered:

    pwsh scripts/evals/Get-EvalChangeSet.ps1 -BaseRef origin/main -HeadRef HEAD -OutFile logs/eval-change-set.json
    pwsh scripts/evals/Get-ChangedAIArtifact.ps1 -ChangeSetPath logs/eval-change-set.json -OutFile logs/changed-ai-artifacts.json
    pwsh scripts/evals/Test-StimulusPresence.ps1 -ManifestPath logs/changed-ai-artifacts.json
  5. Run the eval locally (requires COPILOT_GITHUB_TOKEN in your shell environment):

    pwsh scripts/evals/Get-ChangedSpecStimulus.ps1 `
    -ChangeSetPath logs/eval-change-set.json `
    -OutFile logs/changed-spec-stimuli.json
    pwsh scripts/evals/Test-CopilotToken.ps1 -SmokeTest
    pwsh scripts/evals/Invoke-VallyEvals.ps1 `
    -ManifestPath logs/changed-ai-artifacts.json `
    -ChangedSpecManifestPath logs/changed-spec-stimuli.json

Commit the new spec alongside the artifact change. The PR comment summary in eval-execute reports per-artifact pass/fail with links to the captured logs/eval-results-<artifact-id>.json payloads.

Get-ChangedSpecStimulus.ps1 emits a synthetic artifact for every added or modified stimulus in a changed eval spec. Invoke-VallyEvals.ps1 unions those entries with the changed AI artifact manifest and deduplicates them by kind:artifactId. This ensures a changed stimulus runs even when its referenced agent, prompt, instruction, or skill did not change in the same diff.

A single spec can be shared by multiple artifacts: add one stimuli[].tags.<kind> backlink per artifact that the spec covers. When more than one artifact backlinks the same spec, scripts/evals/Invoke-VallyEvals.ps1 runs the spec once per artifact using vally eval --tag kind=slug, so each artifact is scored only on its own stimuli. A spec backlinked by exactly one artifact runs untagged. The per-spec run key reflects this: untagged runs use specRel, and tag-scoped runs use specRel|kind=slug. scripts/evals/Modules/VallyRunner.psm1 computes the backlink count (Get-VallySpecBacklinkCount) and run plan (Get-VallySpecRunPlan).

Stimulus presence linter​

scripts/evals/Test-StimulusPresence.ps1 is the gate that fails an eval-presence job when a changed AI artifact lacks an eval spec backlink. It reads the manifest produced by scripts/evals/Get-ChangedAIArtifact.ps1 and builds a coverage index from every evals/**/*.yaml spec.

Each changed artifact is matched against the stimuli[].tags.<kind> = <slug> backlinks in that index. Deleted artifacts (manifest status D) are skipped because coverage cannot be required for removed files.

The script writes a structured report to logs/stimulus-presence.json (covered, missing, errors, skipped) and emits a single ::error file=...:: annotation per missing artifact, so a PR comment names the file that needs coverage.

Exit codes:

ExitMeaning
0Every changed artifact is covered, or the manifest is empty or contains only deletions.
1At least one changed artifact is missing an eval-spec backlink.
2Invalid input: missing manifest, missing evals/ root, or unrecoverable YAML parse fail.

The -FailOnSpecError switch promotes recoverable YAML parse failures to a hard exit 2 so a malformed spec cannot mask a missing-backlink failure during local hardening sweeps.

Run the linter locally before pushing artifact changes:

pwsh scripts/evals/Get-EvalChangeSet.ps1 -BaseRef origin/main -HeadRef HEAD -OutFile logs/eval-change-set.json
pwsh scripts/evals/Get-ChangedAIArtifact.ps1 -ChangeSetPath logs/eval-change-set.json -OutFile logs/changed-ai-artifacts.json
pwsh scripts/evals/Test-StimulusPresence.ps1 -ManifestPath logs/changed-ai-artifacts.json -FailOnSpecError

To add coverage for a missing artifact, create or extend an eval spec under evals/ and set stimuli[].tags.<kind> to the artifact slug (the basename minus the .agent.md, .prompt.md, .instructions.md, or SKILL.md suffix); the next run reports it covered.

Per-Spec Moderation Threshold​

The moderation.threshold schema field on an eval spec sets the per-spec Detoxify cutoff (any label score exceeding the value hard-fails the spec):

moderation:
threshold: 0.7

The validator accepts numeric values in [0.0, 1.0]; out-of-range or non-numeric values emit ModerationThresholdOutOfRange / ModerationThresholdType diagnostics during ci:eval:lint:schema. The default is 0.5 when the field is omitted.

Invoke-VallyEvals.ps1 -ModerationThreshold <value> overrides every spec's threshold for a run. CLI override wins over the per-spec value, which wins over the default.

Content moderation coverage​

Content moderation runs in two complementary CI lanes, each scoped to a different surface.

LaneJob in eval-validation.ymlScriptToolchainSurface
Markdown corpuseval-validationscripts/evals/Test-EvalSpecText.ps1Node (retext-equality, retext-profanities).github/{agents,prompts,instructions,skills}/**/*.md and docs/**/*.md
Eval specs + artifactscontent-moderationscripts/evals/Invoke-ArtifactModeration.ps1Python + Detoxify (unbiased by default)Every eval spec under evals/ plus the AI artifacts changed in the PR

The two lanes use different checkers: the markdown-corpus lane keeps the AI artifacts that ship to contributors free of insensitive or foul language; the moderation lane scores every eval spec, plus the AI artifacts changed in the PR, against a Detoxify cutoff so a spec that probes a model with toxic content cannot itself ship unredacted.

The content-moderation job is the only path that exercises the real Detoxify model in CI. The job installs the Python dependencies via uv sync --locked in scripts/evals/moderation (declared in its pyproject.toml and uv.lock), generates the changed-artifact manifest, then runs npm run ci:eval:moderate:artifacts.

Invoke-ArtifactModeration.ps1 enumerates every eval spec under evals/, adds the changed AI artifacts from the changed-artifact manifest, and passes the de-duplicated file list to scripts/evals/Invoke-ContentModeration.ps1 for whole-file moderation.

This lane applies one Detoxify threshold to every file (default 0.5, set with -Threshold); the per-spec moderation.threshold field documented above is read by Invoke-VallyEvals.ps1 for its pre-eval input moderation.

Local opt-in for the Detoxify lane:

uv sync --locked --project scripts/evals/moderation
npm run ci:eval:moderate:artifacts

Without the Python dependencies installed, Invoke-ContentModeration.ps1 exits 2 with a setup error rather than silently passing. The markdown-corpus lane (Test-EvalSpecText.ps1) requires only Node and runs as the CI-owned ci:eval:lint:text lane without any moderation-environment opt-in.

Eval Lint Scripts​

The CI-owned eval-validation workflow runs the static eval-lint lanes. They are not part of validate:local; see Validation Commands and CI-Owned Lanes for local reproduction prerequisites and output handling.

ScriptToolPurpose
ci:eval:lint:vallyBuild-AgentBehaviorSpec.ps1 -WhatIf && npm run lint:eval-grader-lineage && vally lint --eval-spec evals/Spec validation via upstream CLI, chained with spec drift and grader lineage
lint:eval-grader-lineageBuild-GraderLineageMap.ps1 -CheckGrader-name lineage across Vally migrations; chained into ci:eval:lint:vally
ci:eval:lint:schemaTest-EvalSpec.ps1Schema lint, agent-behavior coverage, and orphaned-tag reachability
ci:eval:lint:textTest-EvalSpecText.ps1retext-profanities + retext-equality gate on the AI-artifact corpus
ci:eval:lint:safetyTest-VallyTestSafety.ps1Safety validation for eval stimuli

lint:eval-grader-lineage is not a standalone lane. ci:eval:lint:vally runs it between Build-AgentBehaviorSpec.ps1 -WhatIf (the agent-behavior spec drift check) and the vally CLI, so a drift or lineage failure fails that lane. With -Check it verifies the committed lineage JSON without changing it; given -SourceRevision and -TargetRevision it rebuilds the map between two reachable revisions. It pairs graders by source, stimulus, and a name-free behavior digest, and fails closed on unreachable history, provenance drift, ambiguous pairs, semantic changes, count drift, kind mismatches, duplicate result keys, or output drift.

ci:eval:lint:text scans .github/{agents,prompts,instructions,skills}/**/*.md and docs/**/*.md using separate retext-equality and retext-profanities processors. The alex package is no longer a dependency. Equality findings retain source: alex in the JSON report and emit ::warning annotations by default; the source alias and -FailOnAlex name are retained for existing consumers.

The profanity processor uses sureness: 1, a confidence threshold rather than a severity rating. Rating-0 profanity matches are excluded both with and without -FailOnAlex. Non-allowlisted rating-1 and rating-2 findings are emitted once with source: retext-profanities and cause exit code 1.

Report fields and CLI names remain compatible, but the finding set is intentionally different from the former alex.text() pipeline: profanity findings are no longer duplicated under source: alex, and rating-0 profanity matches are no longer reported. Full historical output equivalence is not claimed.

Pass -FailOnAlex to promote only emitted equality findings to errors. It does not change the profanity threshold or restore excluded matches:

pwsh scripts/evals/Test-EvalSpecText.ps1 -FailOnAlex

Matches admitted by either processor are filtered by the phrase-aware allowlist in scripts/evals/Modules/retext-runner.mts (PHRASE_ALLOWLIST keyed by retext rule id; ±60-character context window). For example, the allowlist suppresses the equality match in HTTP host and the profanity match in penetration test.

Test-EvalSpecText.ps1 exit codes:

ExitMeaning
0No error-level findings; equality findings (source: alex) may still be reported as warnings.
1At least one emitted profanity finding (source: retext-profanities), or an equality finding with -FailOnAlex.
2Setup failure (corpus expansion failed, Node shim missing, or node not on PATH).

Schema lint, coverage, and reachability​

ci:eval:lint:schema runs three checks in a single pass, and any one of them can fail the lane:

  • Schema validation of every spec under evals/, covering required keys, the executor whitelist, moderation.threshold, and stimulus backlink tags.
  • Agent-behavior coverage. Every parent (user-invocable) agent under .github/agents/ must have a stimulus partial at evals/agent-behavior/stimuli/<slug>.yml.
  • Orphaned-tag reachability. Every agent= and scenario= tag in evals/agent-behavior/eval.yaml must resolve to a slug present in the agent inventory. Unresolvable tags emit ::error annotations and hard-fail the lane.

Test-EvalSpec.ps1 exit codes:

ExitMeaning
0Every spec is valid, every parent agent is covered, and no tag is orphaned.
1A spec failed schema validation, a parent agent lacked a stimulus partial, the inventory was unreadable, or a tag was orphaned.
2Setup failure (the powershell-yaml module is not installed).

Use -SkipAgentCoverage for fixture-only runs, or -NewAgentsOnly with -BaseRef to enforce coverage incrementally on newly added agents.

Agent inventory​

evals/agent-behavior/AGENTS.yml is the source of truth for the orphaned-tag gate. It is generated rather than hand-edited:

pwsh scripts/evals/Build-AgentInventory.ps1 -Force

Enrollment follows two rules:

  • Parent agents are always inventoried. An agent file with no user-invocable key is treated as a parent.
  • Subagents (user-invocable: false) are inventoried only when a matching stimulus partial exists at evals/agent-behavior/stimuli/<slug>.yml.

When the lane reports an orphaned tag, either the tag is misspelled or the agent it names is not enrolled. Add the agent's stimulus partial when the agent is a subagent, regenerate the inventory, and commit the regenerated AGENTS.yml alongside the change.

Baseline-equivalence specs​

ci:eval:lint:vally runs a three-step composition: it checks agent-behavior spec drift (Build-AgentBehaviorSpec.ps1 -WhatIf), verifies grader lineage mapping across migrations (npm run lint:eval-grader-lineage via Build-GraderLineageMap.ps1 -Check), and then runs vally lint --eval-spec evals/, which scans recursively to a maximum depth of ten directory levels. The baseline-equivalence suite under evals/baseline-equivalence/ ships its paired specs one level down (baseline/eval.yaml and customized/eval.yaml), so both are discovered by that sweep. Lint either one on its own when iterating on a single spec:

vally lint --eval-spec evals/baseline-equivalence/baseline/eval.yaml
vally lint --eval-spec evals/baseline-equivalence/customized/eval.yaml

scripts/evals/Invoke-BaselineEquivalence.ps1 runs during npm run ci:eval:equivalence and owns environment materialization, seeding, baseline caching, the pinned comparison invocation, and summary generation. It is the only path that materializes the customization surface, so the customized spec must be run through it rather than invoked directly. See evals/baseline-equivalence/README.md for the suite operator guide and driver-output contract.

Matrix, Moderation, and Dashboard Scripts​

Beyond the lint lanes, scripts/evals/ holds the scripts that scope runs, moderate artifacts, and render results.

ScriptInvoked byPurpose
Invoke-ArtifactModeration.ps1ci:eval:moderate:artifactsModerates all eval specs plus changed AI artifacts from the changed-artifact manifest
New-AgentMatrixDashboard.ps1ci:eval:agent:dashboard, ci:eval:agent:reportRenders a self-contained HTML matrix dashboard, one row per inventory agent
New-EquivalenceDashboard.ps1ci:eval:dashboardRenders a self-contained HTML dashboard for a baseline-equivalence run
Get-AgentDependencyMap.ps1Run directlyBuilds a JSON map of agent dependencies for the baseline-equivalence dispatcher
Update-AgentMatrixSummariesFromLogs.ps1Run directlyRebuilds per-agent matrix summaries from existing vally logs without re-running npx vally

Invoke-ArtifactModeration.ps1 and Invoke-CorpusModeration.ps1 are distinct lanes over the same changed-artifact manifest, each writing to its own output file. Artifact moderation covers every eval spec plus the changed AI artifacts and is the lane the content-moderation job runs; corpus moderation (ci:eval:moderate:corpus) strips frontmatter from the changed .github/{agents,prompts,instructions,skills}/**/*.md files and scores only their bodies.

ci:eval:agent:report is the end-to-end convenience command: it runs ci:eval:agent:matrix and then ci:eval:agent:dashboard.

Running Pester Tests Locally​

npm run test:ps wraps scripts/tests/Invoke-PesterTests.ps1. The default invocation applies ExcludeTag=@('Integration','Slow'):

npm run test:ps # default green-bar (excludes Integration + Slow)
npm run test:ps -- -ExcludeTag Slow # include Integration, exclude Slow
npm run test:ps -- -Tag Integration # run only Integration-tagged tests
npm run test:ps -- -TestPath scripts/tests/evals/ # scope to one directory

-Tag (with -IncludeTag as an alias) and -ExcludeTag flow through to the inner Pester configuration only when explicitly bound, so omitting them preserves the default exclusion. CI matches the default invocation; opt-in tag overrides are intended for targeted local runs.

Results land in logs/pester-summary.json (overall counts) and logs/pester-failures.json (per-failure detail).

Testing PowerShell Wrappers Around Python Subprocesses​

Invoke-ContentModeration.ps1 invokes python through Start-Process in a child pwsh -NoProfile -File boundary. The parent test scope's Mock / function: injections do not cross that boundary, so the test suite at scripts/tests/evals/Invoke-ContentModeration.Tests.ps1 uses a PATH-shimmed stub:

  1. Create a temp directory and write python.cmd containing a CMD wrapper that re-launches pwsh against a canned python.ps1.
  2. Prepend the temp directory to $env:PATH for the duration of the test.
  3. The child process resolves python to the shim, executes python.ps1, and observes real argv (--input, --output, --threshold).

This is the only viable mock boundary for cross-process invocation. Apply the same pattern when adding tests for any PowerShell script that shells out to a Python subprocess.

Test authoring patterns​

When authoring new Pester suites for the evals scripts, four patterns recur often enough to call out:

  • Define helper functions inside BeforeAll { function ... } so Pester promotes them to the containing Describe scope for all It blocks. Functions defined directly inside Describe (outside BeforeAll) do not survive the fresh runspaces Pester uses for each It.
  • When a production script exposes an Invoke-*Core function behind an $MyInvocation.InvocationName -ne '.' guard, dot-source the script in BeforeAll and call the core function directly. This avoids a fresh pwsh startup for every case and lets Pester mocks intercept calls made inside the function.
  • When the command under test is invoked through pwsh -File or Start-Process (so the parent runspace cannot install a Mock), declare a bare function at file scope in the test (or in a fixture script the child loads). The PATH-shim pattern above is one instance of this; the scripts/tests/evals/fixtures/stub-vally.ps1 fixture is another.
  • When a stub or script under test needs to signal a non-zero exit while $ErrorActionPreference = 'Stop' is in effect, write the diagnostic with [Console]::Error.WriteLine(...) and then call exit <code> explicitly. throw short-circuits the runspace before the intended exit code is set, which causes the parent process to observe exit 1 instead of the contract code.
BeforeAll {
$script:ScriptPath = Join-Path $PSScriptRoot '../../evals/Build-AgentBehaviorSpec.ps1'
. $script:ScriptPath
}

It 'reports drift without starting a child pwsh process' {
$result = Invoke-AgentBehaviorSpecCore -Check
$result.Outcome | Should -Be 'Drift'
}

The core function returns a PSCustomObject with an Outcome field. Test that result directly; leave exit-code mapping to the guarded main block. Use a child process only when the process boundary itself is part of the behavior under test.

The stub-vally fixture demonstrates the third pattern in practice. scripts/tests/evals/Invoke-VallyEvals.Tests.ps1 drives scripts/evals/Invoke-VallyEvals.ps1 against the fixture by passing -VallyCommand $script:StubPath and setting $env:STUB_VALLY_MODE per scenario. The fixture supports these modes:

  • pass - two passing trials, exit 0.
  • fail - two failing trials, exit 1.
  • fail-noname - two failing trials with no trajectory.stimulus.name, reproducing an empty per-stimulus map, exit 1.
  • mixed - one passing and one failing trial, exit 0; the failed trial drives the outer status.
  • empty - no trials, exit 0.
  • errored - two trials with no gradeResult, exit 1.
  • crash - prints an error and exits 99 without writing results.
  • per-stim - one trial per STUB_VALLY_STIM_RESULTS_JSON entry.

Per-spec overrides flow through $env:STUB_VALLY_MODES_JSON.

These modes exercise the driver's advisory demotion logic. When vally exits 0, the spec met its aggregate runs/threshold contract, so any per-trial assertion dips counted in assertionsFailed are sub-threshold noise rather than merge blockers; the driver demotes them to advisory, sets status to advisory-fail, and emits a ::warning annotation instead of gating the build. Baseline-equivalence specs resolved via agent tags are likewise advisory (DD-01). A spec gates the build (status fail, promoted to a CI failure) only when it has authoritative grader failures or flagged output moderation.

This lets the stub-mode aggregation tests exercise the real driver code paths (the manifest loop, threshold override, and summary writer) without invoking the vally CLI or paying Copilot SDK costs.

🤖 Crafted with precision by ✨Copilot following brilliant human instruction, then carefully refined by our team of discerning human reviewers.