Repository navigation
[qa-lead S2] Make the qa-lead eval set scorable and capture a first held-out baseline #1645
Description
Story
As a maintainer who will be asked whether the qa-lead persona is any good,
I want its held-out eval set to actually be scorable and scored,
so that the evals.required_before: stable gate in its manifest is a real gate rather than a declaration, and there is a baseline to compare against before its exposure is broadened.
The problem, reproduced on origin/main (85658ac)
personas/qa-lead/persona.yml declares an eval gate. It cannot run. Three independent breakages:
1. The cases do not conform to the case schema.
$ python3 evals/validate-cases.py evals/qa-lead/holdout/cases.jsonl
::error::eval cases invalid: line 1: schema violation at expected:
Additional properties are not allowed ('recommend', 'risk_tier' were unexpected)
evals/case.schema.json requires expected to be exactly {escalate, risk} with additionalProperties: false. Every qa-lead case uses {risk_tier, escalate, recommend}.
2. CI is green over an unscorable set. The tree-mode validator that CI runs only checks split structure and id discipline:
$ python3 evals/validate-cases.py
OK: 12 skill(s), 96 case(s) across dev+holdout, no cross-split id overlap.
Per-case schema conformance is only checked in file mode, which nothing invokes. That is why this has been invisible since #1174.
3. The scorer cannot find the prompt.
$ bash scripts/evals/run-eval.sh qa-lead
::error::eval scorer: no skill prompt for 'qa-lead' (expected .../prompts/qa-lead.md)
run-eval.sh resolves prompts/<skill>.md. The persona runtime's convention is prompts/<id>/advisory.md (pr_advisory_prompt_path in scripts/lib/persona-runner.sh). The two conventions have never been reconciled.
And even with 1 and 3 fixed, the default scorer is the wrong one. score_deterministic compares .expected.escalate and .expected.risk by equality. risk is absent from all six qa-lead cases, and a test-risk advisory is prose — a paragraph of "what I'd shore up" cannot be scored by string equality against a reference. run-eval.sh already supports the right mode:
scorer.json: { "mode": "llm-judge", "judge_prompt": "judge.md", "pass_threshold": 0.7 }
evals/judge.md exists and deep-review already uses this mode.
Consequence: skill-eval-report.yml has run 82 times (78 scheduled, 4 dispatched) and every single run scored triage — the workflow's default (SKILL: ${{ inputs.skill || 'triage' }}). qa-lead has never been scored, not once.
Acceptance Criteria
python3 evals/validate-cases.py evals/qa-lead/dev/cases.jsonland.../holdout/cases.jsonlboth pass in file mode (per-case schema conformance), not just tree mode.- The
expectedpayload is reconciled withevals/case.schema.json. Either the cases adopt{escalate, risk}, or the schema is extended to describe the advisory shape — but if the schema is extended, the extension must be deliberate and documented, not a widening ofadditionalProperties. State which path was taken and why in the PR. - No case's meaning changes. The six held-out cases encode real judgments (money-path idempotency, coverage gaming, and the negative control that must not manufacture risk on a well-covered refactor). Renaming
risk_tier→riskis mechanical; silently dropping therecommendguidance or flipping anescalatevalue is not. Preserve every judgment; ifrecommendhas no home in the schema, preserve it somewhere the judge can read. evals/qa-lead/scorer.jsonselectsmode: llm-judgeagainstevals/judge.mdwith an explicitpass_threshold. Deterministic equality scoring is not acceptable for a prose advisory — justify the threshold chosen.bash scripts/evals/run-eval.sh qa-leadresolvesprompts/qa-lead/advisory.mdand runs to completion. Reconcile the two prompt-path conventions rather than special-casing qa-lead —scripts/lib/persona-runner.shalready ownspr_advisory_prompt_path; prefer reusing it over duplicating the convention.- CI catches this class of breakage from now on. Per-case schema validation runs over the whole tree in CI, so a non-conforming case set fails a PR instead of sitting green for six weeks. Expect this to fail loudly on the other 11 skills — that is [qa-lead S8] Fleet-wide: 11 of 12 eval sets fail case.schema.json and CI never notices #1651's job, so scope the CI gate so it can be introduced without blocking every unrelated PR (e.g. gate qa-lead now with a documented allowlist for the rest, removed by [qa-lead S8] Fleet-wide: 11 of 12 eval sets fail case.schema.json and CI never notices #1651).
- A first baseline is captured:
run-eval.sh qa-leadis executed againstprompts/qa-lead/advisory.mdand the per-case results plus the aggregate score are recorded in the PR. This is the "before" number; it is the whole point of the story. - The offline bats coverage (
tests/test_skill_evals.bats) exercises the llm-judge path for qa-lead with a stubbed engine, so scoring is testable with no network.
Tasks / Subtasks
- Decide and document the schema reconciliation (AC Go-live improvements for PR review agent #2), then rewrite the 10 case payloads preserving every judgment. (AC: test issue from agent #1, Go-live improvements for PR review agent #2, Add @claude delegation, auto-merge, and rebase handling #3)
- Add
evals/qa-lead/scorer.jsonwithmode: llm-judge,judge_prompt,pass_threshold. (AC: Optimize review: small-PR and incremental fast paths #4) - Reconcile the prompt-path convention between
run-eval.shandscripts/lib/persona-runner.sh. (AC: feat: add Copilot engine support via REVIEW_ENGINE toggle #5) - Add tree-wide per-case schema validation to CI with a scoped allowlist. (AC: feat: feature ideation framework — adversarial proposer/challenger/synthesizer #6)
- Run the scorer, capture the baseline, paste per-case results + aggregate into the PR. (AC: Workflow should skip no-op PRs in MAX_PRS count #7)
- Extend
tests/test_skill_evals.batsfor the qa-lead llm-judge path with a stub engine. (AC: Add Claude Code workflow #8)
Dev Notes
- The held-out set is CODEOWNER-gated and
holdout-guard.ymlcovers it. Editingevals/qa-lead/holdout/will require CODEOWNER review — that gate is intentional and must not be circumvented. Expect the PR to need it. - Do not tune the cases to make the scorer pass. The dev/holdout split exists so a proposer cannot teach to the test (
evals/README.md, "The no-overlap rule"). This story fixes encoding and plumbing. If the baseline score is bad, that is a finding to report — not a reason to edit the held-out cases. validate-personas.pyenforcesmin_casesonly oncestatushas reachedevals.required_before(stable). Atdraftit is unenforced, which is a second reason nothing has caught this. Fixing that is out of scope here but worth noting in the PR.run-eval.shdistinguishes infra failure (engine exited non-zero — throttled) from quality miss (engine answered, answer wrong), exiting 2 vs 1. Do not collapse that distinction; Skill eval: API throttling masquerades as a held-out regression (infra failure scored asregression, noterror) #920 exists because a transient throttle once opened a false regression tracker.- Scoring runs through the engine abstraction (
scripts/engine.sh), never a re-implemented model call, and both the skill and judge commands are injectable (EVAL_ENGINE_CMD/EVAL_JUDGE_CMD) so offline tests drive stubs. - This story gates [qa-lead S3] Serve the pull_request surface so qa-lead runs without being summoned #1646 (serve the
pull_requestsurface) and [qa-lead S8] Fleet-wide: 11 of 12 eval sets fail case.schema.json and CI never notices #1651 (the fleet-wide fix). Sequencing follows [Phase 4] Capture the pre-rollout baseline for the three success metrics, including a new no-action-comment noise metric #1411's precedent: capture the baseline before the change that alters what is being measured, or the "before" is contaminated.
Project Structure Notes
evals/qa-lead/{dev,holdout}/cases.jsonl (CODEOWNER-gated), new evals/qa-lead/scorer.json, scripts/evals/run-eval.sh, possibly evals/case.schema.json, tests/test_skill_evals.bats, and the CI wiring in .github/workflows/lint.yml.
References
evals/case.schema.json,evals/validate-cases.py,evals/README.mdscripts/evals/run-eval.sh(scorer modes,scorer.jsoncontract, infra-vs-quality exit codes)evals/judge.md,evals/deep-review/(the existing llm-judge precedent)scripts/lib/persona-runner.sh—pr_advisory_prompt_path.github/workflows/skill-eval-report.yml— defaults totriage.github/workflows/holdout-guard.yml- Add Murat persona instance — worked example for the persona standard #1174 (added the seed cases), Skill eval: API throttling masquerades as a held-out regression (infra failure scored as
regression, noterror) #920 (infra-vs-quality distinction)
⚑ AC amendment — 2026-09-07 (maintainer). Supersedes where it conflicts.
PR #1656 has burned all 3 automated review cycles and is at needs-human-review with the
same findings carried across four passes. The blockers are not code quality — CI is
fully green and ACs #1, #2, #3, #5, #6, #8 are independently verified MET at head. The
amendment below exists because two of the original ACs asked for work the agent will not
do, and one review finding turned out to be correct after the automated reviewer dismissed
it. Read this section as authoritative over the original ACs where they differ.
A. AC #7 (baseline) is now MAINTAINER-DELIVERED — do not attempt it
The original AC #7 asked the implementer to run the scorer and record the baseline. That
was a mis-scoped ask: capturing it requires a tokened engine run, and dev-lead has
correctly answered no-changes to it four times rather than fabricate a number.
The maintainer has run it. The result is posted as a comment on PR #1656 and is
reproduced there in full. AC #7 is CLOSED. Do not run the scorer, do not edit the
held-out cases, and do not treat a missing baseline as outstanding work.
B. AC #4 — record the threshold rationale (small, in the PR body)
Add one or two sentences to the PR description justifying pass_threshold: 0.7,
covering: it matches the deep-review precedent for the same shared judge, and it encodes
"must get the escalate/risk decision right and surface most of the expected guidance,"
so a prose advisory that flips the decision cannot clear it. No code change.
C. NEW AC #9 — the --schema-tree gate must enforce the STRUCTURAL invariants too
Two codeant-ai threads on evals/validate-cases.py were dismissed by the automated
reviewer on the grounds that "split structure, duplicate ids, and cross-split overlap
remain enforced by the directory-mode validator CI already runs." That is false and the
threads are correct. Verified 2026-09-07:
--schema-treeis the onlyvalidate-cases.pyinvocation in any workflow
(.github/workflows/lint.yml:390). Directory/tree mode is invoked by nothing in CI.- On
mainthere is novalidate-cases.pyCI invocation at all — so--schema-tree
is net-new and this PR is not a regression. But it is the only gate that will exist,
and it currently checks schema conformance only. validate_schema_tree()doesif not cases_path.is_file(): continue— a missing
dev/orholdout/split is silently skipped, so a skill can report schema-valid
while violating the split contract (thread 1, exactly as written).- It performs no id-uniqueness check within a split and no cross-split overlap
check (thread 2, exactly as written).
The cross-split overlap rule is not cosmetic: evals/README.md calls it the whole point of
the split — "the proposer could see — and overfit to — a case the gate later scores
against, which defeats the entire held-out guarantee ('teaching to the test')." Today
nothing in CI enforces it.
Required:
--schema-treefails when a gated skill is missing either split'scases.jsonl
(replace the silentcontinue). An allowlisted skill stays skipped as today.- It enforces
iduniqueness within each split, and disjointness of ids across
dev/holdout, for every gated skill — reusing the existing directory-mode logic
rather than reimplementing it. - Failures name the skill, split, and offending id, matching the existing diagnostic style.
- Bats coverage for each new failure mode: missing split, duplicate id within a split,
id present in both splits — plus a passing control asserting qa-lead still validates. - The allowlist semantics are unchanged: exempted skills are skipped wholesale, so this
cannot fail unrelated PRs before [qa-lead S8] Fleet-wide: 11 of 12 eval sets fail case.schema.json and CI never notices #1651 lands.
Then reply to both codeant-ai threads stating what changed, and resolve them. Do not
resolve them as "won't fix" — they were right.
D. What "done" means now
| AC | Status |
|---|---|
| #1, #2, #3, #5, #6, #8 | MET — verified at head, do not revisit |
| #4 | Add the threshold rationale to the PR body (§B) |
| #7 | CLOSED — maintainer-delivered (§A). Do not attempt. |
| #9 | NEW — implement §C |
Scope discipline: change evals/validate-cases.py, its bats suite, and the PR description.
Do not touch scripts/evals/run-eval.sh, evals/qa-lead/**, evals/case.schema.json, or
.github/workflows/lint.yml — all four are already correct at head, and evals/qa-lead/**
is CODEOWNER-gated held-out data that must not be edited to fit a score.
E. NEW AC #10 — qa-lead needs its OWN judge (added 2026-09-07, after the baseline run)
The AC #7 baseline is captured: 0.167 aggregate, 1/6 passed
(run 34133681006, full
per-case table in the PR comment). engine_rc=0 throughout, so nothing was throttled.
The dominant, in-scope cause is a judge-rubric mismatch, and it is a defect in AC #4's delivery:
evals/qa-lead/scorer.json points at the shared evals/judge.md, which begins
"The deep-review skill emits a JSON verdict with at least
decision(approve|escalate),
risk(LOW|MEDIUM|HIGH), and afindingsarray."
qa-lead emits prose, so the judge marks a correct advisory down for structure it was never
supposed to produce. Its own words on the 0.00 case: "missing decision field (approve/escalate),
risk classification, and findings array entirely."
The correct pattern already exists in this repo. #1642 gave the sibling persona its own judge —
evals/solution-architect/scorer.json → "judge_prompt": "solution-architect/judge.md". qa-lead
must do the same.
Required:
- Add
evals/qa-lead/judge.md— a judge that grades a prose test-risk advisory against the
case'sexpectedreference: does it reach the rightrisktier, the rightescalatecall, and
surface the substance ofrecommend? Model it onevals/solution-architect/judge.mdfor shape;
do not copy deep-review's JSON-verdict rubric. - Point
evals/qa-lead/scorer.jsonat it ("judge_prompt": "qa-lead/judge.md"). Match
solution-architect's scorer shape, which also carriesgate_thresholdalongsidepass_threshold. - Keep
pass_threshold: 0.7— the rationale is recorded in the PR description. Do not lower the
threshold to raise the score. That is precisely the reward-hacking the held-out split exists to
prevent. - Do not edit any file under
evals/qa-lead/dev/orevals/qa-lead/holdout/. The cases are
correct and CODEOWNER-gated; the judge is what is wrong. - Judge-path coverage in
tests/test_skill_evals.batswith a stubbed judge (offline), asserting
qa-lead resolvesqa-lead/judge.mdrather than the sharedjudge.md.
Out of scope here — do not attempt: the other two confounds behind the 0.167 are shared-harness
problems (the harness scores the Haiku tier while production runs Opus; a live-GitHub prompt is fed a
static case string). They are filed as #1686 and affect all nine personas. Do not change
scripts/evals/run-eval.sh, scripts/engine.sh, or prompts/qa-lead/advisory.md in this PR.
Re-baselining is not required of you. After AC #10 lands, the maintainer re-runs the scorer and
records the delta.
F. Revised definition of done
| AC | Status |
|---|---|
| #1, #2, #3, #5, #6, #8 | MET — verified at head, do not revisit |
| #4 | Threshold rationale — DONE, added to the PR description by the maintainer |
| #7 | CLOSED — maintainer-delivered. Do not run the scorer |
| #9 | Structural invariants in --schema-tree (§C) |
| #10 | qa-lead's own judge (§E) |
Remaining implementer work is exactly #9 and #10. Touch only:
evals/validate-cases.py, evals/qa-lead/judge.md (new), evals/qa-lead/scorer.json,
tests/test_validate_cases.bats, tests/test_skill_evals.bats.
Activity
don-petry commentedon Sep 1, 2026
Dev-Lead Implementation Plan
Issue: #1645 — [qa-lead S2] Make the qa-lead eval set scorable and capture a first held-out baseline
Scope
Make qa-lead's held-out eval set schema-conformant and scorable end-to-end: reconcile the case
expectedpayload withcase.schema.json, add anllm-judgescorer, reconcile the prompt-path convention sorun-eval.shresolvesprompts/qa-lead/advisory.md, and add a tree-wide per-case schema CI gate scoped to qa-lead.Schema reconciliation decision (AC #2)
Extend
evals/case.schema.jsondeliberately by adding one optional namedrecommendstring property toexpected(keepingadditionalProperties: false,escalate+riskstill required). This preserves qa-lead's judgment (recommend) where the llm-judge can read it, renamesrisk_tier→riskmechanically, and does not widenadditionalProperties. Triage cases ({escalate,risk}) stay valid; the deep-review/other shapes remain out of scope (#1651).Implementation Checklist
evals/case.schema.json— add optionalrecommendstring toexpected; document the extensionevals/qa-lead/holdout/cases.jsonl—risk_tier→risk, keepescalate+recommend(CODEOWNER-gated; no judgment changes)evals/qa-lead/dev/cases.jsonl— same mechanical renameevals/qa-lead/scorer.json— new:mode: llm-judge,judge_prompt: judge.md,pass_threshold: 0.7scripts/evals/run-eval.sh— sourcescripts/lib/persona-runner.sh; default prompt resolvespr_advisory_prompt_path(prompts/<skill>/advisory.md) then falls back toprompts/<skill>.mdevals/validate-cases.py— add--schema-treemode: tree-wide per-case schema validation with a documented allowlist exempting the other 11 skills (removed by [qa-lead S8] Fleet-wide: 11 of 12 eval sets fail case.schema.json and CI never notices #1651); qa-lead gated.github/workflows/lint.yml— wirevalidate-cases.py --schema-tree evalsas a CI stepTests to Write
tests/test_skill_evals.bats— qa-lead llm-judge path with stubbed engine + judge (offline)tests/test_validate_cases.bats—--schema-treegates qa-lead, skips allowlisted skillsTest Command
bats tests/test_skill_evals.bats tests/test_validate_cases.bats && python3 evals/validate-cases.py --schema-tree evalsdon-petry commentedon Sep 1, 2026
Dev-Lead: Progress (in progress — not yet durable)
Plan Execution (intended)
evals/case.schema.jsonby adding one deliberately-named optionalrecommendstring property toexpected(keptadditionalProperties: false;escalate+riskstill required). This is not anadditionalPropertieswidening — it's a specific documented field carrying qa-lead's advisory guidance where the llm-judge reads it.risk_tier→riskrename only across the 6 held-out + 4 dev cases.escalateandrecommendvalues are byte-identical to before — no judgment changed (verified by diff). File-modevalidate-cases.pynow passes for both splits.evals/qa-lead/scorer.json→mode: llm-judge,judge_prompt: judge.md,pass_threshold: 0.7. 0.7 matches the deep-review precedent for the same shared judge, and encodes "must get the escalate/risk decision right and surface most of the expected guidance" — a prose advisory that flips the decision cannot clear it.run-eval.shnow sourcesscripts/lib/persona-runner.shand resolves the default prompt via itspr_advisory_prompt_path(prompts/<skill>/advisory.md), falling back to the flatprompts/<skill>.md— no qa-lead name branch. Reuses the existing convention owner rather than duplicating it.--schema-treemode invalidate-cases.pyvalidates per-case schema across the whole tree, with a documentedSCHEMA_TREE_ALLOWLISTexempting the other 11 skills (removed by [qa-lead S8] Fleet-wide: 11 of 12 eval sets fail case.schema.json and CI never notices #1651). Wired as a hermeticvalidate-eval-casesjob inlint.yml. Only qa-lead is gated today.tests/test_skill_evals.batsgains a qa-lead llm-judge path (stub engine + stub judge, no network), plustests/test_validate_cases.batscovers--schema-treegate/allowlist.Baseline capture (AC #7) — plumbing proven, scored number pending tokened run
bash scripts/evals/run-eval.sh qa-leadruns end-to-end: it resolvesprompts/qa-lead/advisory.md, executes all 6 held-out cases through the engine, and dispatches llm-judge scoring. In this offline workspace the engine has no token (Not logged in), so every case is an infra failure (engine_rc=1) → the scorer exits 2 (outcome=error), not 1, correctly refusing to record a false quality regression from an un-answered run (#920). The real scored "before" number therefore must come from a tokened CI run (skill-eval-report.ymldispatched withskill=qa-lead), where the engine can actually answer — that is the number to paste as the baseline.Local check output (provisional — not the pushed run)
Files touched (this workspace, not yet pushed)
evals/case.schema.json,evals/qa-lead/{dev,holdout}/cases.jsonl(CODEOWNER-gated),evals/qa-lead/scorer.json(new)scripts/evals/run-eval.sh,evals/validate-cases.py,.github/workflows/lint.ymltests/test_skill_evals.bats,tests/test_validate_cases.batsNotes
holdout-guard.yml+ CODEOWNERS) — expect the PR to require that review; that gate is intentional and was not circumvented.validate-personas.pyonly enforcesmin_casesoncestatusreachesevals.required_before(qa-lead isdraft), a second reason this stayed invisible — left for a separate change.don-petry commentedon Sep 1, 2026
Dev-Lead: Implementation Complete — PR #1656
The implementation for issue #1645 is durable: commits are pushed and a pull request is open. This record is backed by verifiable artifacts you can confirm in one click.
7debf6db1ee7f3e825cc62f673b748c51aa7dc2cReview happens on the PR. Acceptance criteria and test results are evidenced by the PR's diff and CI checks — not asserted here. If a later stage fails, this claim is retracted in place.
fix: qa-lead llm-judge test judge stub to accept any expected reference
feat: implement issue #1645 — [qa-lead S2] Make the qa-lead eval set …
fix: qa-lead llm-judge test judge stub to accept any expected reference
feat: implement issue #1645 — [qa-lead S2] Make the qa-lead eval set …