G37 — Agent Skill Quality Evaluation: measuring whether the skills actually work¶
ADR: ADR-058
Type: Initiative plan (multi-phase); no usecase-registry.yaml entries —
this is evaluation infrastructure for a distribution surface, not a detector
capability, tracked here the same way G36
is.
Relationship to G36: G37 is the measurement half of G36. G36 P0.8 built
the deterministic trigger tests and G36 P1.1/P1.5 named a behavioral evaluation
and a cross-agent validation log but did not design either. G37 designs both,
adds the two layers neither item covers (comparative lift, and grading the
grader), and decides where each layer runs. G36 P1.1, P1.4 and P1.5 are
superseded in their implementation detail by this plan — see
Relationship to G36's own items for exactly
what changes and why.
Scope note (2026-08-11, flagship-first narrowing). Per ADR-058's 2026-08-11 amendment, the skill portfolio is frozen and
native-binary-compatibility-reviewis the sole flagship subject for every phase below — Phase 1's L4 golden transcripts, Phase 2's live runner, and Phase 3's scenario corpus target that one skill only, not the four-skill sweep the rest of this document was originally scoped for.native-api-evolution,native-release-compatibility, andnative-consumer-compatibilityare prototype status (seeskills-src/CLAUDE.md's portfolio-status table): no scenario, fixture, or evidence bundle should be built against them until the flagship experiment demonstrates measurable lift. The remaining text below is unchanged design — every mechanism (D1–D8, the bundle schema, the rubric, the freshness gate) is skill-agnostic by construction and applies unmodified to a one-skill corpus; only the breadth narrows, not the design. Phase 3's "~6 scenarios per skill, ~24 total" and Phase 4's cross-agent sweep are reduced accordingly — see the updated phase notes. A prototype skill re-enters scope as a Phase 3-style corpus buildout only after the flagship clears the acceptance criteria below, at which point it is added back one skill at a time rather than as a second four-way sweep.
Problem¶
Four public skills ship today (native-binary-compatibility-review,
native-api-evolution, native-release-compatibility,
native-consumer-compatibility). Everything currently gating them measures the
artifact, not the behavior:
| Existing gate | What it proves | What it cannot prove |
|---|---|---|
tests/test_gen_agent_skills.py |
the three published trees match skills-src/ |
nothing about content quality |
tests/test_agent_skills_structural.py |
frontmatter, layering, version range, self-containment | nothing about whether the workflow works |
tests/test_agent_skills_drift.py |
every CLI flag and report-JSON path a skill names still exists | that naming a real flag means using it correctly |
tests/test_agent_skills_triggers.py |
the descriptions lexically cover and discriminate ADR-058's seven intents | that a real agent activates the right skill |
agent-evals/ (M1-5) |
an agent can make a small abicheck code change | nothing — different axis entirely (agent as contributor to abicheck, not agent equipped with these skills) |
So the repository can currently ship a skill that is perfectly well-formed,
references only live CLI surface, and gives systematically wrong compatibility
answers — including the one failure ADR-058 calls non-negotiable, a
manufactured green verdict. ADR-058's Safety invariants exist as prose in
skills-src/shared/safety-invariants.md with no executable check anywhere
that a skill's actual behavior honors them.
Three distinct questions are unanswered, and they are unanswered in different ways:
- Does the skill trigger? — G36 P0.8 answered the static half; the live half (does Claude Code / Codex / Gemini CLI actually activate it) was deliberately deferred to an opt-in lane that was never built.
- Does the skill reach the right answer? — G36 P1.1, not started. This is a correctness gate: absolute, ground-truth-anchored, and for the two safety dimensions, zero-tolerance.
- Does the skill help? — asked nowhere. A skill that reaches the right answer that the bare agent also reaches, at three times the token cost, is not worth publishing. This is a comparative measurement: relative, statistical, needs arms and variance, and is the only layer that answers "результативность" — whether shipping the artifact is justified at all.
Questions 2 and 3 are different kinds of measurement and must not be collapsed into one harness or one number. That distinction drives every decision below.
Goal & acceptance criteria¶
Goal. Every claim ADR-058 makes about these skills is backed by a re-runnable measurement, and the claims that are safety-critical are backed by a check that blocks a merge.
Accepted when all of:
- A skill-content PR that introduces a false-green behavior fails a check in this repository, not a reviewer's judgement.
- Every published skill carries a scorecard: activation precision/recall,
verdict accuracy against ground truth, safety-dimension pass rate across
kruns, cost per resolved question, and measured lift over the no-skill baseline. Sequenced, not simultaneous (2026-08-11 scope note): this criterion is satisfied for the flagship skill first; a prototype-status skill's scorecard is deferred until it re-enters scope, not owed by this plan's first pass. - That scorecard is provably fresh — a results artifact whose recorded content hash does not match the current generated skill trees is rejected as evidence, mechanically (this replaces G36's repeatedly-patched prose requirement that "the run must postdate every content-changing commit").
- The grading harness itself is tested against curated bad transcripts and demonstrably catches each of them.
- Nothing in (1)–(4) requires a model, a credential, or a network call to run
the ordinary
prprofile.
Assessment: the five layers, and where each one stands¶
"Quality of a skill" is not one quantity. It decomposes into five layers that differ in determinism, cost, and what a failure means.
| Layer | Question | Determinism | Today |
|---|---|---|---|
| L0 — Structural | Is the artifact well-formed and non-drifted? | fully deterministic | done, in pr |
| L1s — Trigger, static | Do the descriptions cover and discriminate the target intents lexically? | fully deterministic | done, in pr |
| L1l — Trigger, live | Does a real agent activate the right skill on real phrasing? | stochastic, model-dependent | not started (G36 P0.8's deferred half) |
| L2 — Behavioral correctness | Does a skill-equipped agent reach the right answer, safely, on known-truth fixtures? | mixed — mostly deterministic if graded from artifacts (see D3) | not started (G36 P1.1) |
| L3 — Comparative lift | Does the skill beat no-skill / docs-only, and at what cost? | statistical, needs k runs × n arms |
not started, no home |
Plus one layer that measures the measurement:
| L4 — Meta | Does the grader catch a bad answer? | fully deterministic (replay) | not started |
Two observations shape the design.
L2 is far more deterministic than it looks. Every P0 skill's workflow
terminates in an abicheck invocation that produces a JSON report. If the run
happens in a sandbox where abicheck is a recording shim, the harness gets
the exact argv, exit code, and produced report of every call — and the ground
truth for the fixture is already in catalog/ground_truth.json, whose per-case
records carry expected, expected_kinds, min_evidence and platforms
(the number of cases is owned by that file, not restated here). Most of the
rubric is then a deterministic assertion, not a judge call. Only the residual —
was the root-cause explanation right, was the remediation appropriate — needs a
model in the loop. Maximizing the deterministic fraction is what makes this
affordable and reproducible.
The single most valuable check needs no judge at all. ADR-058's
non-negotiable invariant is "never manufacture a false green." That is
mechanically detectable as a claim-vs-artifact mismatch: take the verdict
the agent claimed, compare it against the verdict the recorded abicheck run
actually produced, and against the fixture's ground truth. A green claim over
a non-green artifact, or over no artifact at all, is a hard failure with no
model involved. The same shape catches "reported a non-comparable pair as a
pass" and "suppressed findings to quiet the output" (the shim sees the
--suppress/policy flags).
That check must not rest on a regex over prose (D3's claim.json). A
correct answer in this domain routinely names more than one outcome in one
paragraph — "ABI-compatible but source-breaking" is not hedging, it is exactly
API_BREAK — so a text parser searching final.md for a verdict word can both
miss a real false green and reject a correct answer. The scenario prompt
therefore requires the final answer to end with a small machine-readable
envelope, and the runner extracts it into claim.json:
verdict, drawn fromcompare's own ordinal vocabulary —NO_CHANGE,COMPATIBLE,COMPATIBLE_WITH_RISK,API_BREAK,BREAKING— ornullfor a pair the agent judges not comparable, mirroring howcompareitself expresses that (shared/compatibility-contracts.md). Not a boolean: a green/not-green field would erase the ABI-vs-source distinction the whole skill exists to make.matrix, for a release-scope answer: the targets enumerated and the state of each, so an unrun cell is a recorded fact rather than an omission. Absent for a single-pair question, which has no matrix.evidence, the call IDs fromcalls.jsonlthe claim rests on.confident, and when it is false,uncertainty— a closed-vocabulary reason, one value per D4 uncertainty kind (not_comparable,evidence_too_shallow,matrix_target_unrun,contract_coverage_ incomplete), plus the specific unresolved thing it refers to. A bare boolean is not enough and would have quietly broken the zero-tolerance gate: dimension 2 has to tell a correctly caveated shallow-evidence answer from an arbitraryconfident: false, and with only a flag the sole way to do that is to readfinal.md— a prose parse, in the one place this design least tolerates one. The typed reason is graded against the artifact: the reason a claim gives must be one the recorded run actually exhibits, so "unconfident for the wrong reason" fails rather than passing as caution. Same lesson asclaim.verdict— a field the grader must interpret has to carry the distinction in its type, not in its prose.
Absent or ambiguous envelope fails dimension 6, closed. Two envelopes, a
verdict outside the vocabulary, or no envelope at all is "no verifiable claim,"
not "benefit of the doubt." The cost is honest and worth naming: requiring the
envelope makes the evaluated interaction slightly less natural than an
unprompted one, and it is accepted because a zero-tolerance safety gate resting
on a regex over free text is the worse of the two. It also stays close to what
the skills already do — native-binary-compatibility-review's own termination
criteria already require that "a verdict exists from a real comparison (not a
refused one)," so the envelope formalizes an outcome shape Layer A defines
rather than inventing a new obligation.
Design¶
D1 — Two homes, one artifact contract¶
abicheck owns L0, L1s, L2, L4 and the fixture/ground-truth corpus. agent-benchmark owns L3.
Rationale, stated as the two rejected alternatives:
Everything in abicheck fails on L3. Comparative lift needs N-way treatment
arms, an LLM-as-judge panel, multi-provider credentials, token/cost accounting,
variance across repeated runs, and a cross-artifact dashboard. That is not a
feature to add here — it is what agent-benchmark already is
(agent_benchmarks/treatments/ with skill:/skill-agent:/docs/baseline
arms, agent_benchmarks/eval/arm_runner.py, the judge panel, subjects/ with
SUBJECT_KINDS already containing "skill" and a scorecard.py, and
harnesses/docker_solver.py whose skill_path is literally "the with-skill
treatment arm"). Rebuilding it would also drag LLM provider keys into
abicheck's CI for a job that is not a merge gate.
Everything in agent-benchmark fails on L2. The correctness gate must block a
skill-content PR in the repository where the skill source lives, and it
must be anchored to catalog/ground_truth.json and the examples/case*
fixtures, which live here. A cross-repo required check whose ground truth is in
a third repo is exactly the drift ADR-058 and docs/AGENTS.md forbid.
The contract between them is an artifact, not an import. abicheck publishes a versioned skill eval pack; agent-benchmark consumes it as a subject. Neither repository imports the other's Python. agent-benchmark drives abicheck only through its published CLI, the same way it drives any other product.
abicheck agent-benchmark
──────── ───────────────
skills-src/ ──gen──► .agents/skills/ ┐
catalog/ground_truth.json ├─► skill-eval-pack.json ──► subjects/ (kind: skill)
agent-evals/skills/scenarios.yaml │ + fixtures manifest arms run: baseline | docs | skill | skill-agent
transcript bundles + rubric schema ┘ + content hash tasks run: harness × model matrix
│ │
│ off-CI: run_skill_eval.py ──► committed evidence bundles │
│ CI (pr): L0/L1s/L4 + replay-grade that evidence + freshness │
└──► no model ever runs in this repo's CI (D2) └──► L3 scorecard (off-CI)
The pack is a single JSON file plus a fixture manifest: skill identities and
their content hashes, the scenario list with prompts and expected outcomes, the
rubric schema version, and the resolved fixture locations. It is generated,
committed, and --check-able the same way .agents/skills/ already is.
D2 — CI runs only deterministic checks; live evaluation runs off-CI¶
No model ever runs in this repository's CI. Every live, model-driven evaluation is an off-CI operation a maintainer runs on demand; CI's entire role is to verify, deterministically, the evidence that operation produced. This is what D3's replay-first design buys — because grading is a pure function of a recorded bundle for the dimensions that gate, CI can enforce the safety contract without ever calling a model.
| Where | Trigger | Needs | Contains | Blocking |
|---|---|---|---|---|
pr profile (CI) |
every PR | these steps need nothing beyond [dev] (the profile as a whole still needs [dev,docs,dist] for its pre-existing docs-build/distribution-build steps — see AGENTS.md) |
L0, L1s, pack build --check, scenario-manifest validity, fixture resolution, shim unit tests, L4 replay grading of golden transcripts, replay grading of the committed evidence bundles, D6 freshness |
yes, required |
run_skill_eval.py (off-CI) |
a maintainer, on demand | agent binary + model credentials + network | L1l, L2 live runs at k=3, producing committed bundles |
not a check — it produces what CI checks |
| agent-benchmark (off-CI, other repo) | on demand | full LLM provider matrix | L3 arms, cost, cross-model variance, scorecard | no — a publication precondition, checked as an artifact |
How a skill-content PR is still gated. The merge gate is not "did a live run happen in CI" but "does fresh, passing evidence exist for what this PR changes":
- The author (or a maintainer) runs
run_skill_eval.pylocally for the risk-selected scenario set (Cost model) and commits the resulting bundles. - CI re-grades those committed bundles deterministically — the four
deterministic dimensions, including both zero-tolerance ones — runs the D6
freshness check, and verifies the evidence set is complete:
check_skill_eval_evidence.pyderives the expected set for both bundle kinds —(scenario, repetition)pairs from the same risk-selection rule the runner used, and(prompt, repetition)pairs covering every entry intrigger_corpus.yaml, negatives included — and rejects any missing id or index. The trigger half matters for the same reason as the behavioral half and is easier to overlook: an absent false-triggering negative prompt inflates activation precision while every committed bundle stays fresh. Without that step the gate reads only what was committed, so omitting one failing scenario — or one failing run out ofk— would turnpass^kinto pass-on-whatever-was-uploaded, and the two zero-tolerance dimensions would be satisfiable by selective upload. Deriving the expected set rather than trusting the uploaded one is what makes the repetition count mean anything. - A PR that changes skill content without refreshed bundles fails on freshness: the committed evidence records a per-skill hash (D6) that no longer matches that skill's generated tree. Missing evidence and stale evidence fail identically, which is the property that makes step 1 non-optional.
The cost is real and worth stating plainly: a skill-content PR now carries a manual step, and an external contributor without model credentials cannot complete it — a maintainer re-runs the evaluation and pushes the bundles for them. That is a deliberate trade of contributor convenience for keeping models, credentials, and nondeterminism out of CI.
The threat model this gate does and does not cover — stated, not implied.
CI re-grades a bundle the PR author committed, and nothing in a JSON file
proves a model ever produced it. A hand-authored bundle carrying the right
hashes and a passing transcript passes every pr check. So the honest scope
is:
- Covered: accident. A skill edit that regresses behaviour, evidence that went stale, a scenario never exercised, a grader that stopped detecting. These are the failures that actually happen, and they now fail a check instead of needing a reviewer to notice.
- Not covered: fabrication. An author who forges a bundle defeats the gate. Acceptance criterion 1 should be read with that bound: it removes reviewer judgement about whether behaviour regressed, not reviewer trust that the evidence is real.
This is the residual cost of running evaluation off-CI (Decision 4) — a CI-produced artifact would carry provenance by construction. Three mitigations are cheap and worth taking; none turns fabrication into a machine-checkable property, and the plan does not pretend otherwise:
- Bundles are reviewable artifacts. They are committed, diffable, and small enough to read. A forged transcript is a deliberate act visible in the diff, not an omission.
- Bundle provenance is recorded — runner version, agent binary version, model id, wall-clock timestamps, token counts. Not proof, but a forged bundle must also forge internally consistent metadata.
- Publication re-runs (Phase 6). The pre-publication full pass is maintainer-run by definition, so evidence that gates the public artifact never rests on a contributor-supplied bundle.
If fabrication ever becomes a real concern rather than a theoretical one, the fix is a trusted two-stage CI handoff (maintainer-triggered run, artifact signed by the runner), which is a separate design — recorded here so the gap is a known, bounded one rather than an unexamined assumption.
Two structural problems with the rejected alternative are worth recording,
because both are why "just add a skill-eval workflow" is not the cheaper
option it looks like. A conditional job that runs only when a label is present
does not block merge when it is skipped, so an ordinary PR touching
skills-src/ would merge with no evaluation at all unless a separate
always-running sentinel failed until evidence appeared — which is exactly the
freshness check above, arrived at from the other direction. And a fork-
originated PR cannot receive repository secrets on pull_request, while
pull_request_target would execute PR-controlled skill content, shim, and
prompts with credentials — so the live-in-CI design needs a trusted
two-stage handoff before it can accept external contributions at all. The
evidence-artifact model sidesteps both.
If the team later wants live runs in CI, the trigger shape is recorded here
so the analysis is not re-derived: eval-suite.yml's
pull_request: types: [opened, reopened, synchronize, labeled] with no paths
filter (a paths filter gates the whole trigger including labeled), a job if
of github.event_name != 'pull_request' || contains(github.event.
pull_request.labels.*.name, 'skill-eval') (label presence alone skips cron and
dispatch, which carry no pull_request context), synchronize included so a
later push cannot ship unexercised, and ABICHECK_MIN_EXECUTED so a missing
binary or expired credential cannot go green with zero scenarios run. Those
constraints are properties of the problem, not of this plan's phasing.
D3 — Replay-first: grade artifacts, not prose¶
Every live run persists a transcript bundle, of one of two kinds —
behavioral (an L2 scenario) or trigger (an L1l corpus prompt). The
distinction is load-bearing, not bookkeeping: a correct trigger run for a
negative prompt activates no skill, runs no abicheck, and states no verdict.
Graded by the behavioral rules it would fail three ways at once — no claim
envelope (dimension 6), an empty calls.jsonl (dimension 3), and no verdict to
check uncertainty against (dimension 2) — so the harness would reject exactly
the evidence proving the skills correctly stayed out of a REST or Java
question.
So the rubric is scoped by kind, and this is stated as a hard split rather than left implied:
behavioralbundles are graded on all six dimensions, and carryclaim.json,calls.jsonl, andcaptured/.triggerbundles are graded on activation alone — which skill activated, against the corpus label. Their graded artifacts areevents.jsonlandfinal.md, but they carry the samemeta.jsonprovenance every bundle does — hashes and observed inputs included. Freshness is not a property of the grading contract: without it a trigger-corpus, skill-tree or harness change would leave stale activation evidence indistinguishable from current, which is the whole failure D6 exists to prevent. None of dimensions 1–6 apply, the claim envelope is not required, and an empty call log is the expected result for a negative prompt rather than a failure. A trigger run stops once activation is observed; making it complete the workflow would duplicate L2 at seven times the cost while measuring nothing L2 doesn't already cover.
The schema carries kind, and grade_bundle.py dispatches on it, so a bundle
cannot be graded by the wrong rule set. The layout below is the behavioral
shape:
agent-evals/skills/runs/<run-id>/<scenario>/<k>/
meta.json agent, model, seed/temperature; **every** hash D6's freshness
check reads — this skill's tree hash, one entry per scenario
exercised (scenario record + fixture closure), the live-trigger
corpus hash, the abicheck build-surface hash, and the harness
hash (runner instructions, launch configuration, agent/model
identifiers, recording shim); plus the **observed** input set —
what the centralized accessor recorded this run reading (D6),
not a self-report — which the completeness and mapping checks
run against
prompt.txt the verbatim user request
events.jsonl normalized agent events: which skill activated, which skill files
were read, tool calls in order — the L1l evidence (see below)
calls.jsonl one record per recorded abicheck invocation: argv, cwd, exit code,
stdout/stderr digests, and the path of every artifact the call
produced — both a `-o`/`--output` file and the captured stdout
captured/<n>.out the verbatim stdout of call <n>, always persisted (see below)
captured/<n>.out.d/ a per-call immutable copy of every file call <n> produced —
`-o`/`--output` and the whole `--output-dir` tree alike —
snapshotted when the child exits (see below)
final.md the agent's final answer text
claim.json the machine-readable verdict envelope parsed out of final.md
usage.json turns, tool calls, tokens in/out, wall clock, retries
judgments.json persisted judge verdicts for dimensions 4 and 5: judge model
+ version, rubric version, prompt hash, score, rationale
Grading over dimensions 1, 2, 3 and 6 is a pure function of the bundle —
those four graders read calls.jsonl, the per-call artifacts under
captured/, claim.json, and final.md, and call no model. Dimensions 4 and 5 are judged, so their first evaluation is not
reproducible from the bundle alone; what the bundle carries is their recorded
verdict, stamped with the judge model and rubric version that produced it.
The distinction matters, so it is stated as two separate operations rather than one word:
- Replay re-derives dimensions 1/2/3/6 from the bundle and reads 4/5 out of
judgments.json. Deterministic, offline, no credentials — this is whatprruns and what an auditor re-runs. - Re-judge re-invokes the panel for 4/5 and appends a new, separately
stamped
judgments.jsonentry. Explicitly not deterministic, never run inpr, and never silently substituted for a replay: a rubric change that affects 4/5 requires a re-judge pass and is visible as such, because the old and new entries carry different rubric versions.
Consequences, all of which matter:
- A rubric change to the deterministic dimensions is re-gradable against stored transcripts with no model calls at all; a change to 4/5 needs an explicit, budgeted re-judge pass, and the plan does not pretend otherwise.
- The deterministic graders — which include both zero-tolerance safety
dimensions — are unit-testable in
prwith zero model calls (that is L4). - Publication (G36 P1.4) gets an auditable evidence trail rather than a claim, including which judge model signed off on 4/5.
- Cross-agent comparison is apples-to-apples: same bundle schema for Claude Code, Codex, and Gemini CLI, so only the runner is vendor-specific, not the grading.
The recording shim is a small executable placed first on PATH inside the
scenario sandbox — which makes "spawn the real abicheck" ambiguous in a way that
recurses: a name-based spawn re-resolves abicheck through the same PATH and
finds the shim again, forever. The shim is therefore given the real
interpreter/entry-point path explicitly (an env var the runner sets when it
builds the sandbox, e.g. ABICHECK_REAL=/usr/bin/abicheck) and spawns that
absolute path, never a bare name; it refuses to start if the variable is unset
rather than falling back to a PATH lookup. It spawns the real abicheck as a child, waits for it, and
finalizes the record after the child exits — it does not exec, which would
replace the shim process and make the exit code and output digest calls.jsonl
requires unobservable, leaving every deterministic grader with incomplete
evidence. Concretely: write a provisional record with argv/cwd, spawn, tee
stdout and stderr through to the caller's own streams while digesting them,
then rewrite the record with exit code and digests and propagate the child's
exit status verbatim. Teeing rather than capturing is the invariant — the agent
must see output that is byte-identical modulo the recorded path mapping
(D3's output-isolation section: a run using the filesystem-view mechanism has
an empty mapping and is byte-identical outright; one using path substitution
differs only by that substitution, which the call record states so the
difference is auditable rather than invisible), the shim must exit with the
child's own status, because a shim that can change a result invalidates the
measurement it exists to produce. A shim crash after spawn must leave the
provisional record in place rather than nothing: a call that happened and was
lost would read to a grader as a call that never happened, which is the false
direction to fail in for dimension 3.
Skill activation is its own recorded fact, not an inference from argv
(events.jsonl). An abicheck command line cannot tell a skill-driven run
from a bare model that happened to reach the same command, and it cannot say
which skill ran when several teach a similar compare invocation — so
neither L1l's activation precision/recall nor dimension 1's "right skill,
right branch" is derivable from calls.jsonl. The runner therefore emits a
normalized event stream, and because vendors expose very different amounts of
this, the contract is explicitly two-tier:
L1l drives the existing trigger corpus, not the behavioral scenarios.
tests/agent_skills/trigger_corpus.yaml is already the canonical labelled
set L1s grades statically, and it is the only input here that contains
negatives — out-of-scope requests (REST/OpenAPI compatibility, a database
migration, Java binary compatibility) that no native-* skill should claim,
plus two positives deliberately labelled expected_skill: null for ADR-058's
P1 candidates. Running only the Category A/B scenarios would measure recall
against in-scope prompts and nothing else, and activation precision is not
computable without negatives — a skill that triggers on everything would
score perfectly. So the live runner replays that same corpus and grades which
skill actually activated per prompt, reusing the file rather than defining a
second labelled set that could disagree with the one L1s already gates on.
- Tier 1 — the vendor reports activation. The runner maps its native
events onto one vocabulary (
skill_activated,skill_file_read,tool_call) and L1l is graded deterministically from the bundle, same as every other replayed dimension. - Tier 2 — the vendor reports nothing usable. Reads of files inside the installed skill tree are still observable from the sandbox, which distinguishes a progressive-disclosure run from a bare one but cannot attribute an eagerly-injected skill. Where even that is unavailable, L1l degrades to the manual cross-agent log for that vendor rather than being graded from argv — the same line G36 P0.8 already drew between scriptable and non-scriptable agents. Dimension 1 falls back to its invocation-class check alone, and the bundle records which tier produced it so a scorecard never presents a tier-2 activation number as if it were measured.
The shim persists the teed stdout itself, not only its digest.
--output defaults to stdout (cli_options.py: "Write output to this path
(default: stdout)"), and the skills genuinely use that form — shared/
root-cause-grouping.md and shared/compiler-and-build-profiles.md both
document abicheck compare OLD NEW --format json with no -o. A bundle
holding only a digest for those calls would have no parseable artifact, and
every grader that reads the produced verdict would silently degrade to
"no evidence" on the most idiomatic invocation the skills teach. Each call's
stdout therefore lands in captured/<n>.out and the record points at it.
Every file a call produced is snapshotted per call, not merely referenced by
path — --output-dir included. An
agent iterating on a comparison naturally reuses one output path — -o
report.json, look, adjust flags, run again — so a record holding only that
path describes a file a later call has already overwritten. A claim citing
call 3 would then be replay-graded against call 5's report, and a digest
recorded at write time detects the overwrite without being able to
reconstruct what call 3 actually produced. The shim therefore copies every
file the call wrote into captured/<n>.out.d/ when the child exits and
digests the copy, so each call's evidence is immutable regardless of what
later calls do to the working tree.
--output-dir matters as much as -o here and is easy to overlook: the
release workflow native-release-compatibility/SKILL.md teaches drives
compare old_release_dir/ new_dir/ --output-dir release-reports/, which
writes a per-library report per library plus a summary. Those files are
overwritten or replaced by the next invocation exactly like a reused -o
path, so a claim resting on one library's report would be unauditable from
the bundle. The shim snapshots that directory, but only what this call actually
produced — not its full contents. A reused output directory legitimately
retains reports from earlier calls: cli_compare_release.py creates it with
mkdir(parents=True, exist_ok=True) and overwrites only the libraries in the
current comparison, clearing nothing. Capturing everything present would
attribute an earlier call's per-library report to this one, and replay would
accept it as this call's evidence — the same misattribution the per-call
snapshot exists to prevent, reintroduced one level up. So the shim redirects every output path the call names — -o/--output,
--secondary-output (a real second artifact: --secondary-format exists
precisely to emit a machine-readable report alongside a human one, and a
claim can rest on it), and --output-dir — to a per-call private location, and copies the
result back to the requested path when the child exits, keeping the private
copy as the snapshot. Content comparison alone cannot do this job: it misses a
rewrite that produces byte-identical output (an agent repeating the same
comparison writes the same report, so no digest moves and the call's own
evidence would be dropped), and mtime comparison misses a rewrite inside the
filesystem's timestamp granularity. A failed call is worse than either — it
writes nothing, so a post-hoc snapshot of the requested path captures the
previous call's file and attributes it to the failure. None of these are
fixable by comparing the directory before and after, because "which file did
this call write" is not information the directory's state contains.
Redirection supplies it directly.
Redirection is visibly imperfect, and the plan states the cost rather than
claiming the copy-back erases it. An earlier draft asserted the agent sees
the requested path exactly as a real run leaves it. That is false in one
observable way: abicheck prints paths. cli_compare_release.py emits
Per-library reports written to {output_dir}/ on stderr, so a naive redirect
puts the harness's private path in front of the agent, which may then read or
report it. Two mechanisms close that, and Phase 1 picks between them against a
real runner rather than this plan guessing:
- Path substitution on the teed streams — replace the private prefix with the requested one on the way out. Cheap and portable, but it makes the shim edit what the agent sees, so the substitution count is recorded in the call record; a grader can then tell a clean run from one where a path was emitted in a form the substitution did not match.
- A filesystem view (bind mount / overlay) that makes the requested path itself resolve to per-call private storage. Nothing is rewritten and the child's own argument is untouched, which is strictly better where the platform and privileges allow it — and not available everywhere, which is why it is not the only option.
Either way the record keeps both the requested and the effective path, so dimension 1's invocation-shape check reads the agent's own argument rather than the harness's substitution.
D4 — The rubric, and which dimensions gate how¶
Six dimensions, from ADR-058, each with a stated grader kind. The split between deterministic and judged is the design's cost control; the split between zero-tolerance and baseline is its safety model.
| # | Dimension | Grader | Gating |
|---|---|---|---|
| 1 | Correct workflow chosen (right skill, right branch within it) | deterministic — events.jsonl's activation record for which skill, plus recorded argv shape vs. the scenario's expected invocation class for which branch; degrades to the argv half alone under D3's tier 2 |
baseline / non-regression |
| 2 | Uncertainty preserved | deterministic, and per uncertainty kind — see below; a not-comparable artifact must not be answered with a definite verdict, while a contract-coverage failure must be carried rather than dropped | zero tolerance, all k runs |
| 3 | Deterministic evidence obtained | deterministic — at least one real abicheck run over the right two sides; a claim with an empty calls.jsonl fails outright |
baseline / non-regression |
| 4 | Root-cause explanation correct | judged (LLM panel) against the fixture's expected_kinds |
baseline / non-regression, at publication only |
| 5 | Appropriate remediation proposed | judged | baseline / non-regression, at publication only |
| 6 | No compatibility claim without sufficient evidence | deterministic — claim-vs-artifact-vs-ground-truth consistency, plus suppression-flag inspection | zero tolerance, all k runs |
Dimension 2 grades four distinct uncertainties by four distinct rules — one rule per kind, enumerated below — because collapsing them would penalize a correct answer. An earlier draft required simply that no definite verdict follow any of them, which is wrong for two of the four and would have failed an agent for being right:
- Not comparable — the verdict genuinely does not exist.
claim.verdictmust benullwith the reason carried; any ordinal verdict fails. - Incomplete evidence for the depth the question needs — a verdict may be
stated, but
claim.confidentmust be false and name what is unresolved. - An unrun matrix target — the release skill's own outcome shape
(
native-release-compatibility/SKILL.md) records five per-cell states, and "not run" is unknown, and it blocks — explicitly not collapsible into pass. This is not expressible as an ordinalclaim.verdictat all: the executed cells can legitimately all beCOMPATIBLEwhile the release verdict is still unknown, andaggregate.pykeeps the gap in its ownmissing_required_targetsfield rather than in any verdict. So the claim envelope carries a separatematrixblock — enumerated targets and any unrun ones — and dimension 2 requires that an unrun required target be reported and that the executed cells' verdict not be presented as the release's. Without this, an agent reporting "compatible" across the cells it ran, silently dropping the one that never ran, passes all three rules below — a false green none of them was shaped to catch. - Contract-coverage failure — a definite verdict is correct here and must
be kept. ADR-049 Phase 7 makes coverage an axis orthogonal to compatibility:
it raises a clean
0to1, never lowers a2/4, and never rewrites a finding's compatibility decision. A report can legitimately carryBREAKINGand a coverage failure at once. What dimension 2 requires is that the claim carry the caveat — coverage incompleteness reflected inclaim.confident/evidence— not that it withhold the verdict. Dropping the caveat fails; downgrading a realAPI_BREAK/BREAKINGto "cannot say" because coverage was short is its own failure, of dimension 6.
Dimensions 2 and 6 are graded with pass^k (every run must pass), not
pass@k. A safety property that holds two times in three is not a safety
property. G36 P1.1 already reached this conclusion in prose — that these two
cannot use a SURVIVOR_BASELINE-style "establish from the first run" model,
because a first run containing a false green would enshrine it as the floor.
D4 makes it executable.
The other four use the established baseline/non-regression model (the same
shape as SURVIVOR_BASELINE and the FP-rate gate), with the baseline recorded
per (skill, agent, model) triple — a model change re-baselines dimensions 1,
3, 4, 5 and never relaxes 2 or 6.
Where each dimension gates differs, and dimensions 4 and 5 gate at
publication only. --no-judge is the intended everyday posture (Cost
model), so an ordinary refreshed bundle carries no current judgments — which
would leave CI either silently bypassing two gates or rejecting every bundle
produced the normal way. Neither is acceptable, so the split is explicit:
- Merge evidence is gated on dimensions 1, 2, 3 and 6 — the four deterministic ones, including both zero-tolerance ones. A bundle without judgments is complete evidence for that gate, not a deficient one.
- Publication (Phase 6) additionally requires dimensions 4 and 5 at or
above baseline, which is why the pre-publication full pass runs with
judges and records
judgments.json.
This is the same principle the cost model states from the other side — what blocks a merge costs nothing to check — made explicit in the rubric so a reader cannot infer a merge gate that no lane runs.
D5 — Scenario corpus: two categories, as G36 P1.1 correctly identified¶
Category A — resolvable from catalog/ground_truth.json. Cases keyed
under catalog["verdicts"][case_dir], each carrying expected,
expected_kinds, min_evidence, platforms. A scenario names a case and a
skill; expected outcome is derived from the catalog, never re-stated (one fact,
one place). Covers: removed export, changed signature, struct layout drift,
enum value change, vtable change, API-only break, public/private scope false
positive, compile-profile difference.
Category B — needs explicit invocation parameters the catalog cannot
express. Non-comparable snapshots, evidence too shallow for the question
asked, incomplete contract-provider evidence,
consumer-unaffected-despite-global-break,
consumer-actually-affected, plugin required-symbol loss, missing matrix target.
These need --used-by, --required-symbol, a multi-target matrix, a
deliberately broken comparability contract, an L0-only pair whose question
requires L2 evidence, or a --contract-evaluation run whose selected
--contract domain cannot be closed. They get explicit records in
agent-evals/skills/scenarios.yaml with their own fixtures.
Each of dimension 2's four uncertainty kinds (D4) needs its own scenario,
or a zero-tolerance rule gates on nothing. The first three entries above and
the last (missing matrix target) are exactly those four. The third was
missing until this review round: an
earlier draft claimed "missing matrix target" covered the contract-coverage
kind, which it does not — that scenario exercises release-matrix assurance
(an unrun target in a multi-platform release), a different mechanism entirely.
(The matrix case is the fourth kind, not the third — an unrun cell is
aggregate.py's missing_required_targets, release-matrix assurance;
contract coverage is a different mechanism, below.) Contract coverage is
contract_coverage_ledger.py's unsuppressible ledger:
it exists only under --contract-evaluation, is answered per selected
--contract domain, and surfaces as contract_coverage_failures plus the
orthogonal exit contribution. A scenario for it must therefore run
--contract-evaluation against a pair whose chosen domain has genuinely
incomplete provider evidence — e.g. an exports-domain run over a side whose
export table was never captured, which export_surface.py reports as
resolvable=False rather than as "exports nothing". Anything short of that
leaves the rule ungated no matter how many other Category B scenarios exist.
Category B is where the highest-value safety scenarios live — every one of them is a place a skill can plausibly manufacture a green result — so it is built first, not last, inverting the natural "easy cases first" ordering.
D6 — Freshness as a mechanism, not a rule¶
G36 states, in three separate items and with an amendment history showing it was patched each review round, that a publication-relied-on evaluation "must postdate every later commit that changes the generated skill trees' content." That is a prose requirement with no enforcement, which is why it kept needing restatement.
G37 makes it mechanical: the eval pack records content hashes, every evidence
bundle records the hashes it ran against, and check_skill_eval_freshness — a
pr-profile check requiring no model — fails when a bundle claims to be
evidence for content it did not exercise. Stale evidence becomes a failing
check instead of a review-round catch.
The hashes are per skill and per scenario, never one whole-pack hash. A
single global hash would change on any skill's edit and invalidate every other
skill's evidence with it — forcing a full re-evaluation on every single-skill
change and flatly contradicting the affected-skill selection rule the Cost
model depends on. The pack therefore records, per skill, the hash of that
skill's own generated tree (its SKILL.md, its references/, and the shared
fragments the generator actually resolved into it — so a shared-fragment edit
changes the hash of exactly the skills that cite it, which is the same
dependency graph the selection rule reads), plus a hash per scenario. An
evidence bundle is fresh when every hash it recorded still matches; unchanged
skills keep their evidence, and only what actually changed needs re-running.
A scenario's hash covers its whole input closure, not just its manifest
record. Hashing only the scenario's YAML entry leaves the digest unchanged
when a fixture is edited in place — same path, same record — so evidence
produced against the old fixture would still read as fresh. The scenario hash
therefore covers the manifest record, the fixture files it resolves to, and
the fixture's ground_truth.json entry, so any change to what the scenario
actually feeds the agent requires refreshed evidence.
A third hash covers abicheck itself, because the skills' answers come from
it. Skill and scenario hashes alone leave a whole class of staleness
invisible: a PR that changes compare's verdict logic, a report field the
skills read, or a CLI flag they drive changes what a live run would produce,
while every committed transcript — recorded against the previous build —
keeps re-grading green. That is the same evaluated-tree-vs-shipped-tree gap
D6 exists to close, one layer down. Each bundle therefore records an abicheck
build hash, and freshness requires it to match.
Scoping that hash is the one real design choice here, and it trades two
failure modes against each other. Hashing all of abicheck/ is safest and
invalidates every bundle on every source commit, which would make the
evaluation unrunnable in practice. Phase 0 instead hashes the surface the
skills actually consume — the CLI command/option tree and the report JSON
schema, both of which tests/test_agent_skills_drift.py already extracts for
its own drift check, plus the ChangeKind registry's verdict mapping. That
catches every change to what a skill can invoke or read, and deliberately does
not catch a pure detector-internals change that alters a verdict without
changing any surface. Phase 6's pre-publication full pass is what closes that
residual, since it re-runs everything against the build being published.
This is what makes the risk-selected suite and the freshness gate the same mechanism rather than two rules that can disagree: the set of skills whose hashes moved is the set whose evidence must be refreshed.
D7 — L3 in agent-benchmark: the arms that actually answer "does it help"¶
Once the pack exists, agent-benchmark runs the comparison it is already built
for. The arms:
| Arm | Spec | Answers |
|---|---|---|
baseline |
bare model, no skill, no docs | what the agent knows unaided |
docs |
abicheck documentation injected | is the skill better than just shipping docs? |
skill:<pack>/<name> |
skill body injected | eager-injection quality |
skill-agent:<pack>/<name> |
skill offered via progressive disclosure | does it get found and used, not just read |
The four arms answer two different questions, and only one of them gates.
An earlier draft treated skill: vs docs as the single verdict on whether a
skill deserves to exist. That is the wrong comparator to gate on, because the
docs arm injects documentation the user already decided to include — it
presupposes exactly the retrieval decision the skill exists to make. Split:
| Question | Comparison | Status |
|---|---|---|
| Content quality — is the distilled workflow better than the raw documentation, given both are in context? | skill: vs docs |
reported, never gating |
| Deployment value — offered but not injected, does the skill get found, activated, and used to a better answer than the unaided agent? | skill-agent: vs baseline |
gates publication |
skill-agent: vs baseline is the honest test of ADR-058's actual bet,
because progressive disclosure is how these skills are really deployed: nobody
pastes a SKILL.md into context by hand. A skill may legitimately lose the
content comparison and win the deployment one — a distilled workflow is not
obliged to carry more information than the full documentation, only to be
found and applied without the user knowing abicheck exists. Folding a skill
into a shared/ fragment is therefore the response to losing the second
comparison, not the first.
The first comparison stays reported because it is diagnostic: a skill that loses it badly is usually a skill whose Layer A has drifted into documentation, which is a content bug worth knowing about even when the deployment number is fine.
Reported per arm: judge score, verdict accuracy, safety-dimension pass rate, tokens, wall clock, and cost — so "lift" is always a quality-per-cost number, never quality alone.
Three gaps in agent-benchmark are Phase 5 prerequisites, and two of them currently invalidate the measurement rather than merely limiting it. These were verified against the checkout, not assumed:
- Truncation silently cuts a skill in half. Both arms render the skill
through
Skill.as_context(max_chars=12_000)(treatments/arms.py,treatments/tools.py).native-release-compatibility/SKILL.mdis ~17.5 KB today, so it is truncated mid-body in both the gating and the reported arm — the run would score a skill no user ever gets. The cap has to rise above the largest publishedSKILL.md, or the arms have to refuse to truncate rather than silently eliding, because a measurement of a truncated artifact is worse than no measurement: it looks like a result. skill-agent:has nothing to disclose.ViewSkillToolbuilds its file list fromskill.resourcesfiltered to.md, andloader.pypopulatesresourcesfrom top-level sibling files ofSKILL.mdonly. abicheck's skills keep everything inreferences/andreferences/shared/, so that list is empty — the progressive-disclosure arm would offer the body and no references at all. Since D7 makes this the gating arm, fixing the loader to walk subdirectories is a precondition for Phase 5 having any validity, not a nicety.- The executable-task track needs a pack adapter. The with-skill arm
exists in
harnesses/docker_solver.py; the pack format has to be fed to it.
Gaps 1 and 2 share a shape worth naming: each would produce a number rather than an error, and a number from a mis-measured artifact is exactly what this plan exists to stop. Phase 5 does not start until both are fixed and a round-trip check confirms the arm receives the whole skill and its references.
D8 — Where the harness lives in this repo¶
agent-evals/, extended with a second task kind — not a new tree, and not
validation/.
agent-evals/ is already "score an agent's behavior against hidden expectations
with a manifest, a scope contract, a gate contract, and a JSON result," and is
already a FIRST_PARTY_PY_ROOTS member with its own CLAUDE.md. Skill
evaluation is the same mechanism pointed at a different subject (an agent
equipped with a skill, rather than an agent modifying abicheck).
validation/ is a different thing entirely — running abicheck against
real-world package corpora — and putting an agent-behavioral harness there
would create a third overlapping home for "evaluation."
This deviates from G36 P1.1's stated file paths (validation/scripts/
run_skill_evals.py, validation/data/skill_eval_scenarios.yaml). The
deviation is deliberate and is recorded in Relationship to G36's own
items below.
Files & surfaces¶
agent-evals/
skills/
CLAUDE.md scoped agent context for this sub-tree
scenarios.yaml Category A refs + Category B explicit records
(L2); L1l reuses tests/agent_skills/trigger_corpus.yaml
rubric.yaml the six dimensions' grader kind and gating mode —
read by the graders and by the publication gate, so
the two cannot disagree about what is zero-tolerance
skill-eval-pack.json GENERATED by scripts/gen_skill_eval_pack.py: every
hash freshness reads, each with the skills it
affects and the paths that route to it
schema/
scenario.schema.json scenario manifest contract
transcript-bundle.schema.json the bundle shape every runner must emit
claim.schema.json the final-answer verdict envelope (D3)
rubric.schema.json six dimensions, grader kind, gating mode
shim/abicheck recording shim: argv/cwd, exit status, teed
stdout+stderr with digests, persisted stdout,
per-call immutable snapshots of `-o` and
`--output-dir` output, provisional record kept
on shim failure after spawn (all per D3)
runners/
claude_code.py headless Claude Code runner
codex.py (Phase 4)
gemini_cli.py (Phase 4)
graders/
deterministic.py dimensions 1, 2, 3, 6
judged.py dimensions 4, 5 (model in the loop)
run_skill_eval.py live runner entry point (off-CI, maintainer-run)
grade_bundle.py replay grader entry point (no model for 1,2,3,6)
baselines/<skill>-<agent>-<model>.json
per-triple baselines for dimensions 1,3,4,5 —
the skill is in the path because D4 defines the
baseline per (skill, agent, model); a file keyed
on two of the three lets one skill consume or
overwrite another's
evidence/<skill>/ committed transcript bundles — the merge evidence
golden/ curated transcript bundles, good and bad (L4)
scripts/
gen_skill_eval_pack.py builds skill-eval-pack.json (+ --check)
check_skill_eval_freshness.py D6's mechanical freshness gate
check_skill_eval_evidence.py re-grades committed evidence; the merge check
tests/
test_skill_eval_scenarios.py manifest/schema validity, fixture resolution
test_skill_eval_graders.py L4 — graders vs. golden good/bad bundles
test_skill_eval_pack.py pack generation + freshness check
No .github/workflows/ entry: per D2, no model runs in this repository's CI,
so the live runner has no workflow. Both new scripts/ checks are steps in
scripts/verify.py's pr profile, which is what makes them required.
In agent-benchmark (separate repository, separate PR):
data/skills/abicheck/ (the consumed pack), a subjects/ entry of kind
skill per P0 skill, the references/ sub-directory fix in
agent_benchmarks/skills/loader.py, and a pack→docker_solver adapter.
Phases¶
Each phase is one PR unless noted. Phases 0–2 are the load-bearing ones; 3–6 are buildout.
Phase 0 — Contracts, no model (S) — implemented¶
Scenario/bundle/claim/rubric JSON Schemas, the pack generator with --check,
the freshness checker, and scenarios.yaml with Category B scenarios declared
but not yet runnable. Wired into scripts/verify.py's step catalog as two
steps — skill-eval-pack and skill-eval-freshness — so pr, pixi,
pre-commit, and CI all route through it (tests/test_verify_profiles.py
enforces this). Two rather than one because they fail for different reasons
and want different fixes: the pack no longer describing the repository is a
regeneration, the evidence no longer describing the pack is a re-run.
Two things landed here that this section did not originally name, both because writing the checks made the gap visible:
rubric.yaml, not only its schema. D4's gating modes are read by the graders and by the publication gate, so leaving them as a table in this document would have meant two consumers each interpreting prose.- The consumed-surface digest is read off committed files, not live
objects — and this one was got wrong first. The obvious implementation
introspects the live Click tree and
ChangeKindregistry: narrow, and it moves for exactly the changes a skill can see. It also makes the pack a function of the running interpreter, and the pack is a committed artifact whose--checkruns on Linux, macOS and Windows. CI said so plainly — the macOS lane failed while every other passed, then Ubuntu 3.14 joined it — and the specific varying input was never isolated, which is the point: an artifact whose inputs cannot be enumerated is one that will drift again. The digest now coversdocs/reference/cli-reference.md,docs/reference/detector-spec.jsonand the compare-report schema — three committed files that are themselves drift-gated against the live objects in the sameprprofile. That keeps the asymmetry D6 wants (acli.pyedit that changes no user-facing surface moves nothing; a renamed flag reaches the reference in the PR that renames it) without any host dependence, and it covers more than the live walk did — defaults, choices and help text included. Two mechanisms keep the class closed rather than the instance: a test asserts every hashed input is a git-tracked file, and--checknow names the entry that moved instead of reporting only that the file differs. - Two digests are computed on demand rather than committed, for a reason
the review itself demonstrated twice.
cli-reference.mdmoves whenever any PR adds a CLI option —scan's severity flags anddump --compressionboth landed during this feature's review. A committed surface digest would make every such PR owe a pack regeneration, and, worse, one could merge cleanly into amainwhose pack is stale, because the two touch different files and git reports no conflict. The publication build digest had the same shape over all ofabicheck/. Both now live outside the pack —scripts/skill_eval_surface.pyfor the surface,publication_build_digest()for the build — and the freshness checker computes the surface value at check time. Invalidation is unchanged: a bundle still records the digest it ran against, and a CLI change still makes every bundle stale. What disappears is only a committed copy that nothing needed and that other people's PRs could silently invalidate. - A consumer-scoped scenario states both verdicts. (Historical: this
bullet describes the pre-D-S1 field names — see below.) In a scoped run
the top-level
verdictwas the scoped answer andfull_verdictwas the library-wide one —native-consumer-compatibility/SKILL.mdused exactly the divergent pair as its worked example and warned against reading it the other way round. A scenario stating only the global value would have made the grader reject the correct consumer answer and reward the inversion, soexpected.full_verdictexisted to give the grader the distractor. Superseded (workstream D-S1):verdictis now always the library-wide answer; the consumer's own answer isconsumer_verdict/consumer_scope.verdict. The grading invariant this bullet states is unchanged in spirit — a scenario still states both the global and the consumer's own expected answer, and the grader still rejects a claim that drops or inverts either — only the field names moved; seeagent-evals/skills/graders/dimensions.pyandtests/test_skill_eval_graders_consumer_scoping.pyfor the current shape.
Done when: pr fails on a hand-edited pack, an unresolvable fixture
reference, and a stale results artifact. All three are covered:
gen_skill_eval_pack.py --check, tests/test_skill_eval_scenarios.py, and
tests/test_skill_eval_pack.py's synthetic stale bundles — synthetic because
no evidence exists until Phase 2, and a staleness check with nothing to check
proves only that it does not crash on an empty tree.
Phase 1 — Deterministic grading core + L4 (M)¶
The shim, the four deterministic graders, grade_bundle.py, and the golden
corpus — including hand-authored bad bundles: a false green over a
not-comparable artifact, a definite verdict with an empty calls.jsonl, a run
that reached green by adding a suppression, a correct verdict reached with the
wrong evidence depth, one that silently drops an unrun matrix target while
reporting the executed cells' verdict, one that silently drops a
contract-coverage failure, and three that exercise the envelope's fail-closed
path
(no envelope, two envelopes, a verdict outside the vocabulary). One further
bundle claims COMPATIBLE where the artifact says API_BREAK — the case a
verdict-word regex over prose would have passed and a typed claim.verdict
catches, which is the whole reason the envelope exists. The two
uncertainty-branch bundles are there for the same reason and are easy to skip:
D5 gives each of dimension 2's four kinds a correct scenario, which proves
only that valid output passes — nothing there demonstrates the grader rejects
the matching false-green, so a regression in either branch would survive both
L4 and committed-evidence replay. Every zero-tolerance branch needs a negative
fixture, not just a positive one.
Each bundle asserts an expected failure set, not a single dimension. The
dimensions genuinely overlap, and that overlap is correct behaviour rather than
double-counting: a false green over a not-comparable artifact violates both
dimension 2 (uncertainty not preserved) and dimension 6 (claim inconsistent
with artifact); a definite verdict with an empty calls.jsonl violates both 3
and 6. Demanding exactly one failure per bundle would either make these fixtures
unwritable or push the grader toward suppressing real findings to keep the
count at one — a grader teaching itself to under-report is the last thing this
corpus should incentivize. Each bundle names the dimension it primarily
exercises and lists every dimension it is expected to trip.
Done when: every golden bad bundle fails at least its named dimension,
with its full expected failure set asserted, and every golden good bundle
passes all four deterministic dimensions — with no model call, inside pr. Dimensions 4 and 5 are replayed
out of each golden bundle's judgments.json (D3), which checks the replay path
and the schema but deliberately does not re-derive a judge verdict; the golden
corpus therefore also carries one bundle whose judgments.json records a
failing judge verdict, so the replay path is proven to propagate a judged
failure rather than only ever reading passes.
This phase is where most of the value lands. After it, the repository can detect ADR-058's non-negotiable failure mode from a recorded transcript, and everything after is about producing transcripts.
Phase 2 — Live runner (Claude Code), off-CI + the evidence gate (M)¶
Headless Claude Code runner at k=3, run by a maintainer rather than by a
workflow, driving two inputs — the L2 scenario manifest and, for L1l,
tests/agent_skills/trigger_corpus.yaml (see below); check_skill_eval_evidence.py and the freshness check wired into
verify.py's pr profile; the first committed evidence set; and the first
real baseline for dimensions 1, 3, 4, 5. Dimensions 2 and 6 gate at zero from
the first run — including if that first run fails, which blocks rather than
baselines.
Done when: a maintainer's local run produces committed bundles — for the
flagship skill while the 2026-08-11 scope note holds, per skill again once a
prototype skill re-enters scope — that CI re-grades and accepts; a
deliberately regressed SKILL.md fails the re-grade; and a skill edit
committed without refreshed bundles fails the freshness check. The third is
the one that matters — it is what makes running the evaluation non-optional
rather than merely available.
Phase 3 — Scenario corpus buildout (M)¶
Scoped to the flagship skill only (see the 2026-08-11 scope note above).
Category B first (every scenario ground_truth.json structurally cannot
index), then Category A across the eight named categories. Target ~12–16
scenarios for native-binary-compatibility-review, not the ~24-across-four-
skills figure this phase originally carried — the corpus classes in D5 were
themselves generic across all four skills, so this is a scope cut (one
skill's worth), not a redesign. A prototype skill's corpus is out of scope
for this phase; see the scope note for when it re-enters.
Status: corpus buildout done, at the low end of target (12 scenarios: 6
Category A + 6 Category B), and a real 48-run A/B pilot has completed
against it. Two real environment prerequisites the harness had never had
to satisfy (an abicheck --version inside the flagship's declared floor; a
CastXML build inside abicheck's own supported policy range) were found and
documented in the same pass — see agent-evals/skills/CLAUDE.md's
"Environment prerequisites for a real run". Full results, per-dimension and
per-scenario tables, and next steps are in
agent-evals/skills/pilot-results/README.md; the matching ADR-058 "PR 3"
amendment records the same. Read the pilot honestly, not as a validation
result: its own dominant finding is a harness confound — a 12-turn
runner ceiling (--max-turns 12) cut off 31% of all 48 runs before they
produced any answer, and did so asymmetrically by arm (46% of skill runs
vs. 17% of baseline runs), so the headline correct-verdict numbers are not
a fair skill-vs-baseline comparison as they stand. The one confound-clean
signal — dimension 1 (correct workflow chosen) passing 96% on the skill
arm vs. 25% on baseline — is real. Phase 3's corpus and pilot being done
does not advance Phases 4-6 below; those remain fully open, and the
pilot's own "Recommended next steps" (raise --max-turns and re-run, first)
is the actual next action here, not a fresh Phase-3 pass.
2026-08-21, additive Harbor task battery (user-requested, not a phase
advance). agent-evals/skills/harbor/tasks/ now carries a generated,
schema-validated Harbor task per
scenario, alongside the unchanged existing harness — real (validated
against the actual harbor package's schema; every Category A reference
solution runs end to end through the real graders), but never run through
an actual Harbor trial (no Docker in this environment). See
agent-evals/skills/harbor/CLAUDE.md and ADR-058's matching amendment for
the full account. Does not advance Phase 3's own done-ness or any later
phase — it is a second surface over the same corpus, not new corpus or a
new result.
Phase 4 — Cross-agent (M)¶
Codex and Gemini CLI runners emitting the same bundle schema, run against the
flagship skill only; Copilot and Cursor stay manual. G36 P1.5's cross-agent
table in skills-src/CLAUDE.md is then populated from generated results for
the scriptable targets rather than hand-maintained — one row
(native-binary-compatibility-review) per target until a prototype skill is
promoted, not all four.
2026-08-21: decided, not merely possible. Harbor was made the canonical
evaluation surface (ADR-058's "Harbor made canonical" amendment) — this
phase's own "Codex and Gemini CLI runners" line above is superseded by
that decision, not a parallel option. Harbor's own agent registry already
includes Codex CLI and Gemini CLI adapters (confirmed by reading its
source, not assumed), so this phase's real content shrinks to running the
existing agent-evals/skills/harbor/tasks/ battery with --agent codex/
--agent gemini-cli instead of building a second/third hand-written
runner — once a real Harbor trial has been run at all (still zero; see
agent-evals/skills/harbor/CLAUDE.md's own "What executing this decision
still needs"). Building a bespoke Codex/Gemini runner from here is now
out of scope; extending the Harbor generator to a still-open corpus gap
is in scope.
Phase 5 — agent-benchmark integration, L3 (M, separate repo)¶
Pack consumption, the four arms, the loader references/ fix, the scorecard
and its dashboard row. Scoped to the flagship skill (2026-08-11 scope
note): the pack's other three skills are prototype status and carry no
Phase 3 corpus for an arm to run against yet, so there is nothing for a
prototype skill's row to score until it re-enters scope.
Done when: the flagship skill has a published quality-per-cost number on
both comparisons — the gating skill-agent: vs baseline and the reported
skill: vs docs — whichever direction each comes out. Extended to a
prototype skill only after Phase 3's corpus buildout runs for it, the same
one-skill-at-a-time re-entry the scope note describes for Phases 3–4.
Phase 6 — Publication gate (S)¶
G36 P1.4's publication precondition becomes a check, and — 2026-08-11 scope
note — it gates the flagship skill's own re-publication (a
native-binary-compatibility-review content change), not a sweep over all
four: publication requires a fresh hash for that skill, a build digest
equal to the one at the published commit (D6 — over abicheck/ plus
pyproject.toml and the resolved runtime dependency versions, since the
narrow surface hash is not sufficient here and the package tree alone is not
the build; defined by inclusion, so committing the evidence itself cannot
invalidate it), a complete evidence set for the full suite, zero failures on
dimensions 2 and 6, dimensions 1/3/4/5 at or above baseline, and a Phase 5
scorecard showing non-negative lift on skill-agent: vs baseline —
D7's declared deployment comparator. The skill: vs docs number is
published alongside it and never gates: gating on it here would have blocked
exactly the skill D7 describes as legitimate — one that wins the
progressive-disclosure comparison it is deployed under and loses the content
comparison it is not. A prototype skill has no publication gate to satisfy
while frozen — it is already published and not being re-evaluated, so there
is no re-publication decision for this phase to gate; the gate applies to it
only once it is promoted, evaluated through Phases 3–5, and reaches its own
content-change publication event.
Cost model¶
The mechanism below is general — sized for the eventual four-skill,
~24-scenario corpus — not a claim about what runs today. The 2026-08-11
scope note caps "the skills the diff actually touches" and "the
affected-skill set" at one, the flagship, for as long as the freeze holds:
--suite full today means the ~12–16 flagship scenarios Phase 3 builds, not
24, and --suite risk's per-change numbers below shrink the same way. The
selection rule (risk-selected by moved hash, not a fixed sample) is what
this section documents, and it is unchanged by the freeze — only its inputs
are narrower until a prototype skill re-enters scope and the corpus grows
back toward the sizing below.
Per live run: ~4–8 agent turns over a small fixture repository. At 24 scenarios
× k=3 that is ~72 agent sessions per full pass (steady state, all four
skills in scope). Two knobs shrink a per-change run, and k is
deliberately not one of them:
--suite risk— the per-change evidence suite, atk=3: every Category B scenario, plus every Category A scenario whose ground truth is not a compatible verdict, plus a standing floor of compatible-ground-truth scenarios (see below), restricted to the skills the diff actually touches. Typically ~10–14 scenarios (30–42 sessions) at steady state; while the freeze holds, bounded by the flagship's own corpus instead.--suite full— every ready scenario for the skills in scope atk=3(~72 sessions at steady state; ~36–48 while the freeze holds), before publication and whenever the affected-skill set is wide.
The evidence suite is risk-selected, not a fixed sample, and that is what makes acceptance criterion 1 true. An earlier draft evaluated only Category B before merge, which quietly contradicted the plan's own goal: a skill change that manufactures a green result for a routine catalog case — a removed export, a signature change — would have carried passing evidence and merged, with the gap found only on some later full pass.
Selecting on non-compatible ground truth closes most of that, but not all of
it, and an earlier draft's justification for the rule was wrong. It claimed
"dimension 6 can only fail where a green claim would be wrong." Dimension 6 is
broader than that by this plan's own definition: it also fails a green reached
with an empty calls.jsonl, and a green reached by adding a suppression.
Both are reachable on a scenario whose ground truth is COMPATIBLE — the
claim matches the truth while resting on no evidence at all, which is precisely
the "right answer for no reason" failure the dimension exists to catch. Ground
truth predicts where a wrong verdict is reachable; it does not bound where an
unjustified one is.
So the suite carries a floor of compatible-ground-truth scenarios per affected skill (two, one exercising each of the evidence-free and suppression paths) alongside every non-compatible one. The full pass adds the rest, which are there for the process dimensions.
Which skills a diff "touches" is read off the generator's real dependency
graph, not guessed from the path. A change to skills-src/<name>/ affects
that skill; a change to skills-src/shared/<fragment>.md affects exactly the
skills that cite it, directly or transitively — which
scripts/gen_agent_skills.py already resolves, since that resolution is how it
decides which fragments to copy into which output tree. Citation breadth varies
widely across fragments — some are reached by a single skill, others by all
four — so a blanket "any shared edit escalates to the full suite" rule, which an
earlier draft asserted on the wrong premise that every fragment is universally
cited, would multiply the cost of editing a narrowly-used fragment for no
coverage gain. (The per-fragment numbers are the generator's to report, not
this plan's to restate.) Reuse the generator's graph rather
than restating the topology here; a fragment every skill really does cite
escalates to every skill by that rule anyway, without the plan hard-coding
which fragments those are.
Selection is derived from which hashes moved — it is not a list of
path rules. This is stated as an invariant rather than as another rule
because the rule-list form has now produced the same deadlock twice: D6
invalidates a bundle whenever any hash it records changes, so a selector
enumerating only some of those inputs can reject evidence while nominating no
skill to regenerate it, leaving the author with a failing check and nothing to
run. Both instances were real. A PR touching only the CLI tree, report schema,
or ChangeKind verdict mapping moves the build-surface hash while no
skills-src/ path changes. A PR editing scenarios.yaml, a fixture, or a
ground_truth.json entry moves a scenario hash while neither of the other two
moves.
The invariant: every hash the freshness check reads maps back to a set of skills, and the suite is the union over all moved hashes. Concretely —
| Moved hash | Skills selected |
|---|---|
| A skill's own tree hash | that skill (a skills-src/shared/ edit resolves through the generator's citation graph, below) |
| A scenario hash (manifest record, fixture closure, or ground-truth entry) | every skill whose scenarios reference that scenario |
The live-trigger corpus hash (tests/agent_skills/trigger_corpus.yaml) |
all of them — L1l precision is computed per skill across the whole corpus, so any prompt or label change invalidates every skill's activation evidence |
| The harness hash — the runner's own prompt/instruction text, its launch configuration, the agent binary and model identifiers, and the recording shim | all of them; a transcript produced under a different treatment is not evidence about the same thing |
| The abicheck build-surface hash | all of them |
The last row's practical effect is that a CLI/report-schema change costs a full re-evaluation, which is the correct price for the one change class that can silently alter what every skill's workflow produces; the hash is deliberately scoped to consumed surface (D6) so ordinary detector-internals commits do not trigger it. The invariant has a dual, and it failed too, which is why both are stated as checks rather than as prose. The mapping rule above assumes the hash set is complete. The complementary failure is an input the evaluation reads with no hash at all: the trigger corpus was exactly that for one commit — wired into L1l as Phase 2's input, hashed nowhere, so editing a prompt or relabelling one would have left every bundle "fresh" and reported activation precision against the previous corpus indefinitely. Note the two failures point opposite ways: a hash with no mapping rejects evidence with nothing to regenerate it; an input with no hash accepts evidence that no longer corresponds to anything.
So Phase 0 carries two round-trip checks over one list of inputs:
- Completeness — every input a run reads contributes to some hash the
freshness check reads. The list is observed, not declared: an enumeration
in prose was found short three times (fixtures, then the trigger corpus,
then the harness's own prompt and agent-version configuration), each time
because a real input existed that nobody had written down — and a runner
self-reporting its inputs has the identical failure mode one level up, as
a runner that starts reading a new config file and forgets to add it to its
own declaration produces a fully-hashed declared set and stale evidence
that passes. So every input reaches the runner through one accessor that
records what it read, the bundle carries that observed set, and a test
asserts no runner or shim module opens a file outside it — the same shape
as this repository's existing
banned-importscheck. A self-report would recreate exactly the gap this mechanism exists to close. - Mapping — every such hash resolves to a set of skills, so a moved hash always nominates something to re-run.
Completeness is derived from the accessor-observed set, never from a declaration; the declared mapping is used only to resolve each observed input to a hash and to the skills it affects. So an input added later fails one check or the other rather than silently escaping both — which is what stops this class recurring, having now recurred twice in each direction.
Deterministic rotation was considered and rejected for the wide cases: a rotating subset would make the gate's strength depend on when a PR happened to land.
k stays at 3 everywhere, and the suite size is what varies. These are not
interchangeable ways to buy the same saving. Dropping to k=1 would keep
scenario coverage while silently converting the pass^k safety gate into a
single-sample check — and k exists precisely to catch run-to-run variance,
which is the failure mode a stochastic agent has and a scenario list does not.
Dropping scenarios costs coverage, which is honest, visible in the evidence
set's own manifest, and recoverable on the next full pass.
Judged dimensions (4, 5) are skippable via --no-judge, leaving the four
deterministic dimensions — both zero-tolerance ones included — at near-zero
marginal cost. That is the intended everyday posture, and under D2 it is also
the permanent posture of CI itself: the checks that block cost nothing,
because they re-grade a recording rather than produce one. The model spend
is entirely in the off-CI step, is incurred once per skill change rather than
once per push, and never blocks on a rate limit or an expired credential in a
required job.
Risks¶
| Risk | Mitigation |
|---|---|
| Model nondeterminism makes a required check flaky and the team learns to ignore it | No model runs in CI at all (D2), so a required check re-grades a fixed recording and is bit-for-bit reproducible; within the off-CI run, only deterministic dimensions gate hard, judged ones use baselines, and k runs give pass^k for safety with pass@k reported for the rest |
| Vendor CLI/harness churn breaks runners | Bundle schema is vendor-neutral; only runners/*.py is vendor-specific, and grading never is |
| The grader silently stops detecting anything | L4 golden bad bundles run in pr on every commit — that is exactly what they exist to prevent |
| Scenario corpus overfits; skills are tuned to the eval | Category A derives expectations from ground_truth.json, which is owned by detector work and not editable from a skill PR; corpus growth requires a case the correct behavior already passes, mirroring the FP-rate corpus rule |
L3 shows a skill does not beat docs |
Not a failure and not a gate — D7 gates on skill-agent: vs baseline instead, and reports the docs comparison as a content-drift diagnostic |
| Eval cost grows unbounded | Risk-selected evidence per change, full pass only before publication, judge optional; CI itself spends nothing |
| The off-CI step is skipped and evidence silently rots | D6's freshness check treats missing and stale evidence identically, so skipping the step fails the same way as never running it |
| An external contributor cannot produce evidence | Acknowledged and accepted (D2): a maintainer re-runs the evaluation and pushes the bundles. The alternative — credentials reachable from a fork-controlled skill, shim, and prompt — is not one this plan will take |
Relationship to G36's own items¶
| G36 item | Status under G37 |
|---|---|
| P0.8 (trigger tests) | static half stands as-is; its deferred live half becomes G37 Phase 2's L1l |
| P1.1 (behavioral eval) | superseded in implementation detail. G37 keeps its substance — the two scenario categories, the six-dimension rubric, and the split gating model, all of which P1.1 got right — and changes: file locations (agent-evals/skills/, not validation/, per D8), the addition of the recording shim and replay grading (D3), pass^k rather than per-run grading for the safety dimensions, and live evaluation running off-CI with CI checking its evidence (D2) |
| P1.4 (publication) | its freshness precondition becomes mechanical (D6) and its "acceptable baseline rate" becomes G37 Phase 6's explicit four-part gate |
| P1.5 (cross-agent log) | generated from real results for scriptable targets (Phase 4); manual only for Copilot/Cursor |
| P1.2/P1.3 (contingent) | unchanged — still gated on findings, which G37 Phase 3 is what actually produces |
G36's P1.1/P1.4/P1.5 items carry a pointer to this plan, added in the same PR that introduced it, so neither document holds a second diverging design while Phase 0 is pending.
Decisions taken¶
Four open questions this plan raised were resolved before Phase 0; recorded here so the reasoning is not re-litigated:
| # | Question | Decision |
|---|---|---|
| 1 | Harness location | agent-evals/skills/ (D8), accepting the deviation from G36 P1.1's validation/ paths. validation/ runs abicheck against real-world package corpora and has no agent, model, or transcript in it; a third "evaluation" tree was rejected as surface sprawl. If holding two task kinds under agent-evals/ becomes confusing, splitting it into explicit code-tasks/ and skill-scenarios/ sub-trees is a later rename, not a re-architecture |
| 2 | What the docs arm gates |
Nothing — split the question (D7). skill: vs docs is reported as a content diagnostic; skill-agent: vs baseline is what gates publication, because progressive disclosure is how these skills actually deploy and the docs arm presupposes the retrieval decision the skill exists to make |
| 3 | When G36 is amended | Immediately, in this plan's own PR, rather than deferred to Phase 0 — the divergence window costs more than the edit |
| 4 | Live evaluation in CI | No. CI runs deterministic checks only; the live runner is an off-CI maintainer operation whose committed evidence CI re-grades (D2). This also resolves two structural problems the alternative has: a label-gated job does not block merge when skipped, and a fork PR cannot hold credentials without exposing them to PR-controlled content |
Out of scope¶
- Changing any skill's content. G37 measures; a measurement that finds a problem produces a G36 follow-up, not an in-plan edit.
- A public leaderboard or cross-project skill benchmark. agent-benchmark's dashboard is the aggregation surface; standing up a published ranking is a separate decision.
- Evaluating non-abicheck skills. The pack format is deliberately generic enough not to prevent it, but nothing here commits to it.
- Human preference studies. Real-user telemetry is the honest complement to all of the above and is a different project.