feat(plugin): add optimize-skill's execution track and the outcome-suite template - #130
feat(plugin): add optimize-skill's execution track and the outcome-suite template#130uipreliga wants to merge 1 commit into
Conversation
…ite template Adds the second half of `/coder-eval:optimize-skill`: the EXECUTION track, which asks whether a skill's BODY produces better outcomes, next to the activation track's question of whether its DESCRIPTION gets the skill engaged. Like PR1 this is mostly prose — the skill's execution track, the bundled `outcome.yaml` template, the `ci-outcome` sample suite and its fixture, and tutorial 09. Tutorial 09 reports a NULL result and says so. One product change: an errored `Skill` call no longer counts as engagement (3b2802b). A refused or failed call loaded no skill body, so scoring it as engagement inflates the activation metric on exactly the rows where the measurement failed. Corrections applied during the split: * The two lint rules this range introduces were numbered CE035 and CE036 on the branch, and `main` already owns both — `TestCE035WorkflowOutputParity` and `TestCE036LiveVerdictContract`. They are renumbered CE060 and CE061 here, the numbers they carry at the branch tip, so no later PR in the stack has to move an id. The rename reaches five files, two of them user-facing: tutorial 09 and the ci-outcome sample both name the rules by number. * `test_rule_ids_are_unique_across_baserules_and_test_classes` is pulled forward into this PR — the one that introduces the two ids. `runner.py`'s import-time uniqueness assert covers `ALL_RULES` only, and roughly a third of the CE rules are `@pytest.mark.lint` classes whose id lives in a class NAME, so nothing in the build could see this collision. The guard was observed failing on ['CE035', 'CE036'] before the renumber and passing after. Squashed from feat/plugin-optimize-skill: 35e7714 feat(plugin): add an execution track to optimize-skill — optimize the body, not just the description 9526d54 fix(plugin): repairs from live-testing optimize-skill with cold agents 2a0d72d docs(tutorial): conform 08 to the tutorial front-matter and title convention 6e9c1b4 refactor(dataset): rename the split values tune/holdout to train/test, and label them for the user 6ebe2d9 docs(tutorial): use blockquotes, not mkdocs admonitions, in tutorial 08 a50a356 fix(plugin): 1/8 — correct optimize-skill's execution track and cost table a90f849 feat(plugin): 2/8 — ship the outcome-suite template for the execution track 17aa999 fix(docs): 3/8 — repair the stale skill counts, and the sensor that missed them b910adc feat(tasks): 4/8 — add the ci-outcome sample, the execution track's worked example f252e6e feat(lint): 8/8 — CE035 catches the partly-labelled split dataset b375fb8 fix(plugin): 5/8 — execution-track A/B returns a NULL result, and corrects the slash-form claim it disproved c0d6959 docs(tutorial): 6/8 — add tutorial 09, the content track, reported as a null eb758e4 docs(tutorial): 7/8 — make tutorial 08's A/B reproducible, and untangle it d5b4fb0 fix: code review fixes for the optimize-skill plan c2315ed test(lint): CE036 — a row's prompt must not contain what its criteria grade 95553ec docs(harness): defer three gaps found reviewing the optimize-skill plan 3b2802b fix(criteria): an errored Skill call is not engagement 2ddb737 docs: correct tutorial 09 — the round's null had a different cause than reported 5f14f73 docs(tutorial): 09 gets the real baseline — a verified ceiling, 1.000 on 6/6 c40cdd4 fix(tasks): gate engagement, in the artifacts that call it a gate bc9125b docs(tasks): make the ci-outcome sample runnable in two lines, and record the dead end 3ce116b chore: close the last loose ends from the optimize-skill plan Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @uipreliga's task in 2m 8s —— View job Code Review for PR #130
|
uipreliga
left a comment
There was a problem hiding this comment.
Review: coder_eval — pr:130
Scope: pr:130 · branch pr/split-execution-track · 884fe56 · 2026-08-21T05:25Z · workflow variant
Change class: complex — changes dataset split-filter semantics, adds a Skill-tool error-gating branch in engagement detection, and ships a new outcome-eval suite template plus two new CE lint rules; correctness requires reasoning about label conventions and measurement validity
Security, type safety and architecture are excellent (10/9.9/9.9) and the harness's own lint/provenance machinery is unusually disciplined, but the measurement path is where the risk sits: three confirmed defects can change a row's score or a run's final status for identical agent output — a denylist engagement gate that counts unresolved and "unknown" Skill calls as engagement, an ungated file-path signal that counts failed reads, and a mistyped --split that exits 0 with zero rows measured — so the bottom line is that this is a healthy codebase whose weakest axis (Error Handling 7.4) is precisely the half that decides whether a reported number is real, and those fixes should land before the next optimize round spends money.
Summary
| Axis | Score | 🔴 | 🟠 | 🟡 | 🔵 | Top Issue |
|---|---|---|---|---|---|---|
| 1. Code Quality & Style | 8.8 / 10 | 0 | 0 | 2 | 2 | The slash-form prompt rule in optimize-skill/SKILL.md contradicts the shipped artifacts: neither outcome.yaml, ci-outcome.yaml nor the exemplars' initial_prompt blocks use the form the docs mandate, and the 6/6 figure is attributed three ways |
| 2. Type Safety | 9.9 / 10 | 0 | 0 | 0 | 1 | Test helper widens the result_status Literal to a bare str and silences the resulting error with a self-inflicted, unjustified # type: ignore[arg-type] |
| 3. Test Health | 7.9 / 10 | 0 | 1 | 2 | 1 | Fixture eval YAMLs under templates/ci-outcome-fixture/evals/ escape every tasks/** validation scan, and activation.yaml is an invalid TaskDefinition (suite_thresholds with no dataset:) |
| 4. Security | 10 / 10 | 0 | 0 | 0 | 0 | — |
| 5. Architecture & Design | 9.9 / 10 | 0 | 0 | 0 | 1 | tests/test_custom_lint.py grows to 4385 lines / 59 marked rule classes in one module |
| 6. Error Handling & Resilience | 7.4 / 10 | 0 | 2 | 1 | 1 | skill_triggered's new engagement gate is a denylist over a 4-valued result_status: only "error" is refused, so "unknown" and None still count as skill engagement (sibling command_executed allowlists "success") |
| 7. API Surface & Maintainability | 8.8 / 10 | 0 | 0 | 2 | 2 | CE061's locator-exemption list is declared twice (inline rule + CLAUDE.md prose) with nothing binding them: the copies already disagree, one entry is dead, and skill_name/expected_skill are omitted although the same PR mandates them |
| 8. Evaluation Harness Quality | 8.5 / 10 | 0 | 1 | 1 | 0 | skill_triggered now ignores errored Skill calls, but three definition surfaces still document the old contract (one of them generated) and no score-comparability marker was added |
Overall Score: 8.9 / 10 · Weakest Axis: Error Handling & Resilience at 7.4 / 10
Totals: 🔴 0 · 🟠 4 · 🟡 8 · 🔵 8 across 8 axes.
Blockers
-
[Axis 3] Fixture eval YAMLs under templates/ci-outcome-fixture/evals/ escape every tasks/ validation scan, and activation.yaml is an invalid TaskDefinition (suite_thresholds with no dataset:)** (
templates/ci-outcome-fixture/evals/activation.yaml:29) —tasks/skills/ci-outcome.yamlstates the placement rationale explicitly: "The fixture lives undertemplates/, NOT beside this file, and that is deliberate: anything matchingtasks/**/*.yamlis loaded as a real TaskDefinition by CE034, CE060, test_tags and test_yaml_migration". Every repo-wide task scan is rooted attasks/(tests/test_tags.py:154for task_file in sorted(Path("tasks").rglob("*.yaml")):; CE060/CE061 attests/test_custom_lint.py:4228and:4336use(Path(__file__).parent.parent / "tasks").rglob("*.yaml")), so the four new files undertemplates/ci-outcome-fixture/evals/are validated by nothing.tests/test_custom_lint.py:1732 test_checked_in_outcome_fixture_lets_the_skill_actreads the fixture but only globs.github/workflows/*.ymland countsevals/depths — it never callsload_taskon the eval files. The gap already hid a defect:load_task(templates/ci-outcome-fixture/evals/activation.yaml)raises at PR HEAD —ValueError: Invalid task definition: ... success_criteria['skill_triggered'].suite_thresholds requires a dataset: block (thresholds are evaluated on aggregated across-row metrics)— because lines 29-30suite_thresholds:
recall.yes: 0.7
sit on a task with no dataset: block (validator check_suite_thresholds_require_dataset, src/coder_eval/models/tasks.py:665-679). This is the one fixture file tasks/skills/ci-outcome.yaml calls load-bearing ("one task interpolating $SKILL_SOURCE_PATH"). Fix: either drop suite_thresholds from the fixture task (or give it a dataset: block), and add an assertion inside test_checked_in_outcome_fixture_lets_the_skill_act that every non-experiments/ YAML under the mounted fixture round-trips through load_task — that is the replacement for the tasks/** scans the placement deliberately escapes.
2. [Axis 6] skill_triggered's new engagement gate is a denylist over a 4-valued result_status: only "error" is refused, so "unknown" and None still count as skill engagement (sibling command_executed allowlists "success") (src/coder_eval/criteria/skill_triggered.py:74) — The new guard tests one value of a four-valued field. models/telemetry.py:372 declares result_status: Literal["success", "error", "unknown"] | None, and the guard is if cmd.result_status == "error": (line 74) with everything else falling to the else: at line 80 that adds the skill. Two non-"error" states remain fail-open:
(a) None — the live/early-stop path. claude_code_agent.py:364 constructs the tool's CommandTelemetry(... result_status=None ...) at dispatch and hands that exact object to ToolStartEvent(task_id=..., turn_id=..., tool=telemetry) (claude_code_agent.py:380). EarlyStopWatcher._on_event_impl evaluates on the CALL: elif isinstance(event, ToolStartEvent): / self._evaluate_impl(in_flight=event.tool) (early_stop.py:451,454), and _collect_verdicts appends it to the trajectory (record.commands = sorted([*record.commands, in_flight], ...), early_stop.py:538). So live_verdict (line 197) sees a Skill command with result_status=None, counts it as engagement, and returns "pass" for a positive criterion — for a Skill call that has not returned and may return the disable-model-invocation refusal this PR exists to reject. With stop_early: {on_pass: stop} the run is then cut before the refusal ever arrives, and the row is scored observed=yes. Arming skill_triggered is the documented canonical example (docs/TASK_DEFINITION_GUIDE.md:366-370).
(b) "unknown" — the finalized path. claude_code_agent._finalize_commands sets cmd.result_status = "unknown" for any tool that never got a result (claude_code_agent.py:1355, logged as a WARNING), so a Skill call cut short by a turn timeout / crash / early stop is scored as engagement too.
This also falsifies the docstring immediately above: "Because engagement is monotonic (a skill, once engaged, stays engaged), a latched verdict never flips, so it agrees with _check_impl on the frozen trajectory by construction" (lines 186-188) — engagement is no longer monotonic across the in-flight → resolved transition once an errored call is subtracted.
Fix: make it an allowlist per the project's own Review Criteria #6 ("status classification uses explicit allowlists, not denylists") — if cmd.result_status == "success": add the skill, else log and skip. For Claude a resolved Skill call is always success/error, so nothing legitimate is lost; the pending call then stays undecided until its ToolEndEvent, which the watcher already re-evaluates ("The matching ToolEndEvent still evaluates, which covers a verdict that only becomes decidable once the result is known", early_stop.py:425-427). Add a test for result_status=None and "unknown"; the PR's new tests cover only "error" (tests/test_skill_triggered.py, test_errored_skill_call_is_not_engagement).
3. [Axis 6] A --split value matching no row is a global invocation error, but it is demoted to skipped_tasks and the run exits 0 with zero rows measured (src/coder_eval/orchestration/task_loader.py:498) — The split miss is raised as a bare ValueError:
if not rows:
raise ValueError(
f"Dataset for task '{task.task_id}' has no rows in split {split!r} "
(task_loader.py:497-499). resolve_all_tasks catches it in the load/expand handler — except (FileNotFoundError, OSError, ValueError, yaml.YAMLError) as exc: … skipped.append(SkippedTask(path=str(task_file), reason=reason)) … continue (experiment.py:638-642) — and the CLI's exit gate ignores skipped entirely: if summary.tasks_failed > 0 or summary.tasks_error > 0 or failed_suite_gates > 0: (cli/run_command.py:560); skipped only prints a yellow line at run_command.py:690-693. Net effect for the single-suite invocations this PR's workflow issues: coder-eval run tasks/skills/ci-outcome.yaml --split trian skips the only task, resolves zero tasks, and exits 0.
That contradicts the policy resolve_all_tasks states for the other error class in the same function: "If EVERY task that reaches resolution fails, the cause is a global invocation error (a bad --type / -D value, repeats over the cap) rather than a per-task incompatibility, so it is re-raised and aborts the run" (experiment.py:562-567). --split is exactly that shape — one CLI value applied to every task — but takes the demote path.
The PR makes this the primary invocation path (--split train / --split test on nearly every command in plugins/coder-eval/skills/optimize-skill/SKILL.md, e.g. lines 414, 657, 686-688) and mitigates it only with prose, three times: "A mistyped split name (--split holdou) is reported as a skipped task and the run still exits 0 — a green run of zero rows" (SKILL.md:417-419; also 402-403 and 649).
Fix: raise a dedicated exception type (e.g. SplitSelectorError) that resolve_all_tasks does NOT demote, so a bad --split aborts at plan/run time; or make the CLI exit non-zero when zero tasks resolve. Related silent-degradation sibling one line up — rows = [r for r, label in labelled if label == split] (line 496) drops unlabelled rows from a partly-labelled dataset with no runtime warning; CE060 (new in tests/test_custom_lint.py:4189) covers only tasks/** in this repo, not the suites users build from the shipped template, so a runtime warning is worth adding there too.
4. [Axis 8] skill_triggered now ignores errored Skill calls, but three definition surfaces still document the old contract (one of them generated) and no score-comparability marker was added (docs/TASK_DEFINITION_GUIDE.md:1275) — src/coder_eval/criteria/skill_triggered.py:74 now adds if cmd.result_status == "error": ... else: names.add(skill.split(":")[-1]), so a Skill tool call that the tool refused no longer counts as engagement. The reference section that defines this criterion was not updated and is now false. It still reads, verbatim at docs/TASK_DEFINITION_GUIDE.md:1275:
"Agent-agnostic — scans the run's turn_records for either signal: Claude's explicit Skill tool call whose skill parameter matches skill_name (namespace prefixes like plugin:skill are stripped, ...)"
No mention of result_status. CLAUDE.md:64 ("Binary: did the agent engage the target skill (Skill tool / file read)?") and CLAUDE.md:168 are equally silent.
Second half: this is a scoring-semantics change on a persisted metric. A run.json / task.json written before this PR scores 1.0 / observed_label: "yes" on a trajectory that now scores 0.0 / "no", and nothing in the artifact distinguishes the two definitions beyond RunSummary.framework_version (src/coder_eval/models/results.py:1088). The nightly pipeline and its dashboard live in a separate repo (coder-eval-uipath / eval-runner), so any skill_triggered trend line there spans the change silently — recall/F1 can step down for identical agent behaviour and read as a skill regression.
This repo already has the convention for exactly this, in this same file: the reference: migration block carries a bold Score-comparability warning that names the external consumer ("Every task suite outside this repo (the coder-eval-uipath / eval-runner suites among them) must migrate before its next scheduled run" and "judge scores are not comparable across this migration").
Fix: (a) amend line 1275 to say the Claude signal is a successful Skill tool call and that a refused call (e.g. disable-model-invocation: true) scores no; (b) add a score-comparability note in the same shape as the reference: one, naming eval-runner and the version at which the definition changed; (c) mirror the one-line summary in CLAUDE.md:168.
Non-blocking, but please consider before merge
- [Axis 1] The slash-form prompt rule in optimize-skill/SKILL.md contradicts the shipped artifacts: neither outcome.yaml, ci-outcome.yaml nor the exemplars' initial_prompt blocks use the form the docs mandate, and the 6/6 figure is attributed three ways (
plugins/coder-eval/skills/optimize-skill/SKILL.md:222) — Line 222 reads**Use the slash form, not a description of it.** Open \initial_prompt` with/:`, and the YAML example at lines 225-232 shows the slash form ALONE:
initial_prompt: |
/coder-eval:ciTwenty lines later (242-245) the same file reports that this exact form is the weakest of the three measured: "measured at 3/6 rows, against 5/6 for a plain prose instruction and 6/6 for an explicit imperative. Pair the two". A reader (or a model) following the bolded instruction and its worked example copies the 3/6 form. The two shipped exemplars silently do a third thing — neither carries a slash at all:
plugins/coder-eval/reference/templates/outcome.yaml:121says# Invoke the skill EXPLICITLY, and pair the slash form with an imperative.while its prompt at 149-152 isUse the \my-plugin:my-skill` skill to handle this request. Invoke it with the Skill tool...` — imperative only.tasks/skills/ci-outcome.yaml:6-7claimsso the rows MUST use the slash form — dogfooding the mechanism the template documents, and its comment table at 131-133 listsslash + explicit imperative 6/6 engaged, yet the prompt at 153-157 isUse the \coder-eval:ci` skill...` with no slash.
Pick one form and make the instruction, the example and both exemplars agree. Either put /coder-eval:ci back on the first line of both prompts (matching the 6/6 claim), or demote line 222 to "open with an explicit imperative; the slash form alone is a hint, not a mechanism" and drop the slash-only example. Also correct ci-outcome.yaml:6-7, which asserts a property of the rows that the rows do not have. _assert_outcome_suite_shape in tests/test_custom_lint.py only checks that the bare plugin:skill substring appears, so nothing currently catches the drift.
2. [Axis 1] Two competing missing-value conventions kept side by side: _stratified_sample still buckets an explicit null into a separate "None" stratum (src/coder_eval/orchestration/task_loader.py:342) — The PR extracts row_split_label (line 349) as "the single definition of the split-filter convention" and then spends ~10 lines of prose in two places explaining why a SECOND convention stays. task_loader.py:342 is unchanged:
groups.setdefault(str(row.get(field, "")), []).append(row)and the new note at 333-337 states the defect outright: "it also turns an explicit None into the string \"None\"". So two rows that are both unlabelled land in different strata depending on whether the key is absent ("") or explicitly null ("None"), and the stratified draw silently treats them as separate populations. Nothing forces the divergence — the stated goal ("folding a missing key to "" groups it with the genuine-empty stratum") is exactly row_split_label(row, field) or "", which also preserves the 0-is-a-label rule the docstring is careful about:
groups.setdefault(row_split_label(row, field) or "", []).append(row)Make that one-line change, delete both prose notes (task_loader.py:333-337 and 363-366), and the codebase has one convention instead of a documented trap. If the divergence really must stay, add a test pinning the "None" stratum so the behaviour is asserted rather than only described.
3. [Axis 3] CE061 has no synthetic-offender test: every weakening of its inlined detection (threshold 12 -> 120, dropping .lower(), adding locator exemptions) leaves the suite green (tests/test_custom_lint.py:4339) — TestCE061RowPromptsDoNotLeakWhatTheyGrade (class at line 4318) ships exactly one test, test_repo_task_prompts_do_not_contain_the_graded_string (line 4339), and it only asserts assert not offenders over the live tasks/ tree. The entire detection logic is inlined in that test body and is never exercised on a known leak: the locator pops for locator in ("path", "agent_file", "file_path", "command"): dumped.pop(locator, None) (line 4361-4362), the description exclusion (line 4357), and above all the threshold at line 4366 — if len(value) >= 12 and value.lower() in prompt:. Raise 12 to 120, drop the .lower(), or add a field to the locator tuple, and the test still passes green forever. Its sibling CE060 (line 4189) is the house standard and got seven cases including test_detects_a_partly_labelled_dataset (line 4258). Fix: extract the per-row scan into a module-level _prompt_leaks(row) -> list[str] helper (mirroring CE060's _offenders) and add positive cases — a prompt containing a >=12-char file_check includes value must be flagged; a prompt containing only the criterion path and only a <12-char value must not be.
4. [Axis 3] Two outcome-suite contracts (threshold metric vocabulary, per-row cost cap) are asserted only against the bundled template, never against the checked-in suite that spends the money (tests/test_custom_lint.py:1702) — test_checked_in_outcome_sample_matches_the_shipped_template_shape (line 1702) says the worked example is "Asserted through the SAME helper as the bundled template, so the shipped shape and the worked example cannot drift" — but two of the four template sensors are outside that shared helper and run against reference/templates/outcome.yaml only. _assert_outcome_suite_shape (line 1433) checks dataset-backedness, row/split counts, ${row. substitution, prompt invocation and the engagement criterion — and nothing else. Not shared: test_outcome_template_thresholds_use_real_metric_keys (line 1633), whose own comment says "A threshold naming a metric nothing emits is not a loose gate — _attach_row_accounting records it with actual_value=None and passed=False, so the suite fails forever", and test_outcome_template_caps_cost (line 1651), "without it a single runaway row can consume a whole stage's budget". tasks/skills/ci-outcome.yaml is the artifact that actually spends the money (its own header: "$4.30" a pass, "$40 and several hours" an A/B round) and it carries suite_thresholds: {recall.yes: 1.0}, {mean: 0.7, completion_rate: 1.0} and run_limits: {max_turns: 20, max_usd: 2.00} — all valid today, all unasserted. Fix: move the metric-vocabulary loop and the run_limits cost-cap assertions into _assert_outcome_suite_shape so both call sites get them.
5. [Axis 6] The file-read engagement signal is ungated on result_status, so a FAILED or merely incidental skills/<name>/ path reference counts as engagement (skill_triggered.py:88-90) (src/coder_eval/criteria/skill_triggered.py:88) — The scan runs over every string parameter of every command, whatever the tool and whatever its result:
for value in cmd.parameters.values():
if isinstance(value, str):
names.update(_SKILL_PATH_RE.findall(value))
(lines 88-90). The comment the PR adds to justify exempting it is the part that does not hold: "This is deliberately NOT gated on result_status above — a failed Skill tool call loaded nothing, whereas a path reference means the file was opened" (lines 86-87). A path reference means a path was mentioned in a tool's inputs, not that a file was opened — Bash: cat /host/plugin/skills/ci/SKILL.md that fails with "No such file", a Grep whose pattern/path contains skills/ci/, or a Write to skills/ci/SKILL.md all set result_status="error" (or never read anything) and still add ci to the engaged set. The failed-lookup case is real in this PR's own environment: "the plugin sits at a host path the sandbox cannot discover. Tested: 0 of 2 rows found the file" (plugins/coder-eval/skills/optimize-skill/SKILL.md:273-275).
That produces a false observed=yes on exactly the criterion the execution track uses as its hard engagement gate (tasks/skills/ci-outcome.yaml:162-165), i.e. the round reports "the body was measured" when it was not — the failure class this PR set out to close, surviving in the other half of the same function. The skill body already concedes the hole ("Treat the criterion as necessary, not sufficient", SKILL.md:299-301), which is why this is Medium rather than High.
Fix: gate the path scan on cmd.result_status != "error" as well (a failed read loaded nothing either), and narrow it to tools that actually read files (Read/Bash/Grep inputs) rather than every parameter of every tool.
6. [Axis 7] CE061's locator-exemption list is declared twice (inline rule + CLAUDE.md prose) with nothing binding them: the copies already disagree, one entry is dead, and skill_name/expected_skill are omitted although the same PR mandates them (tests/test_custom_lint.py:4361) — The list is written inline as for locator in ("path", "agent_file", "file_path", "command"): (tests/test_custom_lint.py:4361) and again in prose in CLAUDE.md:221 as "Location fields (path, agent_file, command) are exempt". Three concrete defects: (1) the two copies disagree — CLAUDE.md omits file_path, and no test binds them, so make lint stays green on the drift; (2) file_path is dead — git show pr-130:src/coder_eval/models/criteria.py | grep -n file_path returns nothing, no criterion in the SuccessCriterion union has that field; (3) two real locators are missing — SkillTriggeredCriterion.skill_name (models/criteria.py:1203) and CliCalledCriterion.log (models/criteria.py:579). (3) is the live one: the new execution track REQUIRES the prompt to name the skill (SKILL.md:236 initial_prompt: | / /coder-eval:ci, and outcome.yaml:158 "Use the my-plugin:my-skill skill to handle this request") while the criterion sets skill_name: to that same name. For any skill whose bare name is >= 12 chars — e.g. optimize-skill (14) — skill_name and the substituted expected_skill both appear verbatim in the prompt and CE061 flags a correctly-authored suite as an answer leak. It is masked in-repo only because ci is 2 chars and lint-tasks is 10, both under the len(value) >= 12 floor on line 4366. Fix: hoist the tuple to ONE named constant, add skill_name and log, drop file_path (or keep it with a comment saying which criterion is expected to grow it), and add a test asserting the constant and the CLAUDE.md sentence list the same fields.
7. [Axis 7] The check-skill activation template (plugins/coder-eval/reference/templates/activation.yaml:41-44) omits type: "claude-code" + setting_sources: [], unlike every sibling activation/outcome surface in this PR — and it is the one such surface copied verbatim into user repos (plugins/coder-eval/reference/templates/activation.yaml:41) — The template's whole agent block is agent: / plugins: / - type: "local" / path: "$SKILL_SOURCE_PATH" (lines 41-44) — no type: "claude-code" and no setting_sources: []. Every sibling activation surface this PR touches or adds has both, with the reason spelled out: tasks/skills/lint-tasks-activation.yaml:24-32 ("this measures the skill listing, and injecting a 20 KB project guide into every call is both expensive and a confound"), the new templates/ci-outcome-fixture/evals/activation.yaml:11-15, the new plugins/coder-eval/reference/templates/outcome.yaml:41, and the rewritten docs/tutorials/08-optimizing-a-skill.md:127. CLAUDE.md's own sandbox-isolation guidance says the same. check-skill copies this file verbatim (plugins/coder-eval/skills/check-skill/SKILL.md:144-158 lists only task_id / skill_name / expected_skill / prompt / split as substitutions) and never mentions setting_sources, so every user-generated activation suite pays host-CLAUDE.md injection on every probe row and carries the confound. Add type: "claude-code" + setting_sources: [] to the template's agent block with the same one-line rationale the outcome template uses.
8. [Axis 8] The outcome template ships 2 train rows and the execution track states no minimum-n, while its gate is a paired t-test that can promote on n=2 with p=0.000 (plugins/coder-eval/reference/templates/outcome-rows.jsonl:1) — outcome-rows.jsonl ships four placeholder rows — core-1 and regression-1 on split: "train", core-2 and regression-2 on split: "test". Stage B runs --split train (SKILL.md:738-739), so the paired comparison that decides promotion pairs two rows.
src/coder_eval/reports_stats.py:381-389 only short-circuits below 2: if len(common_tasks) < 2: return PairedComparison(..., None, None, None, None, None). At exactly 2 it computes a real CI with df=1 (t* ≈ 12.7), so Stage B's promotion rule — "The paired mean difference favours the candidate and its 95% CI excludes zero" (SKILL.md:764) — is evaluated on an interval that is almost never informative, with no warning anywhere in the output.
The activation twin has the guidance the execution track lacks. plugins/coder-eval/reference/templates/activation.yaml (unchanged block): "A split HALVES each side, so it multiplies the sizing rule: a suite you intend to optimize wants 16-24 of each polarity, not the 8-12 a one-shot check aims for." And SKILL.md:141-143 — "Below check-skill's un-doubled minimum, hand back rather than gate — a metric over three or four rows moves in 25–33 point jumps" — sits inside ### Activation track — the activation suite (lines 125-146) and so does not govern ### Execution track — an outcome suite (147-368), whose "Five requirements specific to this track" list contains no row-count floor.
Fix: state a minimum train-row count for an outcome suite in the execution-track section and in outcome.yaml's header (the derived tasks/skills/ci-outcome.yaml uses 6 train / 4 test, which is a usable anchor), and either ship more placeholder rows or make the template's header say plainly that four rows is a shape, not a sample size.
Nits
- [Axis 1] The artifact-scoring criterion-type set is a hardcoded literal duplicated in two tests in the same file (
tests/test_custom_lint.py:1621) — The identical literal appears at line 1621 (test_outcome_template_scores_artifacts_not_prose) and line 1726 (test_checked_in_outcome_sample_matches_the_shipped_template_shape):
artifact_scoring = {"file_check", "json_check", "run_command", "cli_called", "command_executed"}The same file goes to real trouble elsewhere to derive its vocabularies from live code (_outcome_metric_vocabulary calls BaseCriterion.aggregate and reports._attach_row_accounting rather than writing metric names down), so the hand-copied set is inconsistent with the file's own standard and will drift when a new artifact-scoring criterion type lands. Lift it to one module-level constant beside _outcome_metric_vocabulary and reference it from both tests.
2. [Axis 1] Tutorial titles 08 and 09 use Title Case while 01-07 use sentence case (mkdocs.yml:108) — mkdocs.yml:107-109 now reads 07 · Driving Coder Eval from Claude Code, 08 · Optimizing a Skill Description, 09 · Optimizing a Skill Body — the PR changed 08 from the previous sentence-case Optimizing a skill description and added 09 in Title Case, so the nav mixes two conventions (Comparing two models, Docker isolation vs Optimizing a Skill Body). The same casing propagates through the generated surfaces: docs/tutorials/README.md:23-24 and docs/llms.txt:53-54. Restore sentence case (08 · Optimizing a skill description, 09 · Optimizing a skill body) in mkdocs.yml and re-run make docs-indexes so the three derived tables follow.
3. [Axis 2] Test helper widens the result_status Literal to a bare str and silences the resulting error with a self-inflicted, unjustified # type: ignore[arg-type] (tests/test_skill_triggered.py:24) — The helper declares the parameter as a free str and then suppresses the mismatch at the construction site:
result_status: str = "success", # line 24
...
result_status=result_status, # type: ignore[arg-type] # line 32The field it feeds is Literal["success", "error", "unknown"] | None (src/coder_eval/models/telemetry.py:372). Two problems: the ignore carries no justifying comment (the axis-2 Medium anchor's exact shape), and it is inert — pyproject.toml's [tool.pyright] sets exclude = [..., "tests", ...], and the second make typecheck pass only includes tests/lint/live_verdict_contract.py and tests/_fixtures/live_criteria.py (tests/lint/pyright_config.py::INCLUDE), so nothing type-checks this file at all. Impact is bounded to Low because pydantic still rejects a bad literal at runtime, so a typo fails the test loudly rather than silently.
Fix: annotate the parameter with the real type and delete the ignore —
result_status: Literal["success", "error", "unknown"] | None = "success",This also documents the closed set at the criterion/agent seam, which is the contract finding #1 is about.
4. [Axis 3] CE061 scans rows through expand_dataset, so an unseeded stratified sample would silently reduce it to a random subset (tests/test_custom_lint.py:4348) — Line 4348 is for row in expand_dataset(task, path.parent):. expand_dataset applies dataset.sample_per_stratum seeded by dataset.sample_seed, and src/coder_eval/orchestration/task_loader.py:518-522 states "When that is None the sample is deliberately nondeterministic — re-drawn every run". No tasks/** suite sets sample_per_stratum today (git grep -n 'sample_per_stratum' -- tasks/ is empty), so the rule is deterministic right now; the moment a suite adds one without a seed, CE061 checks a different random subset of rows each run and a leaking row passes CI intermittently. Its sibling CE060 avoids this by reading the unsampled rows — line 4211-4214 uses _load_dataset_rows(task.dataset, task_file_dir). Fix: have CE061 read _load_dataset_rows too and substitute ${row.*} itself, or assert task.dataset.sample_per_stratum is None or task.dataset.sample_seed is not None before scanning.
5. [Axis 5] tests/test_custom_lint.py grows to 4385 lines / 59 marked rule classes in one module (tests/test_custom_lint.py:4188) — git show pr-130:tests/test_custom_lint.py | wc -l is 4385, up from 3425 at the base (origin/pr/split-mvp) — a +1060 line, ~28% growth in one commit, and grep -c "^class \|^@pytest.mark.lint" counts 59 entries. The two new rules land as @pytest.mark.lint class TestCE060SplitLabelsAllOrNothing (line 4188) and class TestCE061RowPromptsDoNotLeakWhatTheyGrade (line 4318), plus the module-level helper _string_leaves at the very bottom. This follows the documented convention (CLAUDE.md line 221: Markdown/YAML-reasoning rules are @pytest.mark.lint classes rather than BaseRules), so it is not a pattern violation — but the convention says nothing about WHICH file those classes live in, and the module is now a single grab-bag spanning doc parity, plugin artifacts, workflow outputs, dataset labelling and prompt leakage. Split it by subject into a tests/lint_tests/ package (one module per family, e.g. test_dataset_rules.py for CE060/CE061) and keep make lint's marker selection as-is so coverage is unchanged by the move; also give _string_leaves a home beside the rule that uses it rather than at module scope where any of the other 58 classes can pick it up.
6. [Axis 6] The refused Skill call is recorded only as a DEBUG line and never reaches the criterion's details, so the report shows observed='no' with no attribution (src/coder_eval/criteria/skill_triggered.py:75) — The drop is traced with logger.debug("Skill call for %r errored (%s); not counting it as engagement", ...) (lines 75-79) and nothing else: the returned result carries only details=f"observed={observed!r}, expected={expected!r}{filt}", (line 165), so task.json and every report render a plain observed='no' — indistinguishable from "the model never called the skill", which needs the opposite remedy (fix the description vs. remove disable-model-invocation: from the frontmatter). The DEBUG line does land in task.log (task_log_handler lowers the app logger to DEBUG, logging_config.py:329-330, and criteria run inside that context, orchestrator.py:518/781), so the evidence exists — but only per-task, and the operator reading an aggregate recall of 0.0 across 24 rows has no pointer to it.
Compare the repo precedent for the analogous abnormal-flow case: "Permission-blocked tool use is abnormal flow — warn so it surfaces in runs that don't have DEBUG enabled" → self._log.warning(...) (src/coder_eval/agents/claude_code_agent.py:1799-1806); that phrase list ("permission", "not allowed", "requires approval", "denied", "blocked") does not match "cannot be used with Skill tool due to disable-model-invocation", so no warning fires for this case today.
Fix: append the refusal to the criterion's details (e.g. refused Skill call(s): <skill> — <first 120 chars>) so the attribution travels with the score, and raise the log to warning for a measurement-invalidating refusal.
7. [Axis 7] optimize-skill's only worked max_usd figure is the value both shipped templates document as a hazard (plugins/coder-eval/skills/optimize-skill/SKILL.md:348) — SKILL.md:347-349 reads: "Set run_limits.max_usd on the suite. It is a per-row cap ... so a 12-row suite at 0.50 bounds one arm at about $6 per replicate". 0.50 is the only concrete value in the skill body, and both artifacts this same PR ships set 2.00 and call 0.50 a trap: plugins/coder-eval/reference/templates/outcome.yaml:80-83 — "A real outcome row here cost $0.43, and a draft cap of 0.50 sat 15% above that: a slightly longer row would have aborted as COST_BUDGET_EXCEEDED and scored as a body failure that never happened — a fabricated result of exactly the kind this file warns about" — and tasks/skills/ci-outcome.yaml:83-89 repeats it. Change the illustration to 2.00 (or restate the arithmetic on a value the templates endorse) and carry the "set it from a measured row, generously" sentence into the skill body so the agent following it does not reproduce the failure the templates were edited to prevent.
8. [Axis 7] The new capability preflight claims to verify --split exists, but coder-eval plan has no such flag (plugins/coder-eval/skills/optimize-skill/SKILL.md:45) — SKILL.md:44-48 says "run coder-eval plan <suite> and require it to exit 0 before spending. Two binaries can report the same version and differ in whether --split and dataset.split_field exist at all". plan only takes --experiment — git show pr-130:src/coder_eval/cli/plan_command.py | grep typer.Option yields exactly one option, at line 20-22 — and it does not expand datasets or accept any row selector. It therefore proves only the dataset.split_field half (via Dataset's model_config = ConfigDict(extra="forbid") in models/tasks.py); it says nothing about whether coder-eval run accepts --split. Either narrow the claim to dataset.split_field, or add a second explicit probe the sentence can stand behind (e.g. coder-eval run --help | grep -q -- --split).
What's Missing
Parallel paths:
- 🟠 🟠
skill_triggered.py— the sibling that already solved "did the tool actually succeed?" was not reused:criteria/command_executed.py:173gates oncmd.result_status != "success"behind an author-facingrequire_successflag, while the new Skill gate hardcodes a== "error"denylist with no opt-out. The same question now has two answers incriteria/, and only one of them is an allowlist. (trigger: src/coder_eval/criteria/skill_triggered.py) (restates: Axis 6: skill_triggered's new engagement gate is a denylist over a 4-valued result_status) - 🟡 🟡
skill_triggered.py— the gate landed on one of the two engagement signals inside the same function. The_SKILL_PATH_REscan over every parameter of every command (lines 88-90) is left ungated, so the half of the criterion that Codex and Antigravity actually exercise never sees the new rule. (trigger: src/coder_eval/criteria/skill_triggered.py) (restates: Axis 6: The file-read engagement signal is ungated on result_status) - 🟡 🟡
skill_triggered.py— the cross-harness parity surfaces were not touched.docs/agents/CODEX.md:228documents this criterion's per-harness divergence verbatim ("Codex has no distinctSkilltool … the file-read signal is weaker") anddocs/agents/HARNESS_PARITY.mddoes not mention the criterion at all; the Claude-only refusal gate widens that divergence (a refused Claude call now scoresno, a failed Codexcatof the same SKILL.md still scoresyes) with neither doc updated. (trigger: src/coder_eval/criteria/skill_triggered.py) (restates: Axis 8: skill_triggered now ignores errored Skill calls, but three definition surfaces still document the old contract) - 🟡 🟡
task_loader.py—expand_datasetwas migrated to the newrow_split_label,_stratified_sample(line 342) was not. The PR documents the divergence in two prose blocks instead of applying the one-linerow_split_label(row, field) or ""that satisfies both conventions. (trigger: src/coder_eval/orchestration/task_loader.py) (restates: Axis 1: Two competing missing-value conventions kept side by side) - 🟡 🟡
run_command.py— onlyrunlearned about splits.cli/plan_command.pystill exposes exactly one option (--experiment, line 20) and expands no dataset, so the preview command the skill body makes its mandatory preflight cannot show per-split row counts, cannot detect a mistyped split, and cannot prove the binary supports--splitat all. (trigger: src/coder_eval/cli/run_command.py) (restates: Axis 7: The new capability preflight claims to verify --split exists, but coder-eval plan has no such flag) - 🟡 🟡
reference/templates/activation.yaml— three of the four activation/outcome surfaces this PR touches gained (or already had)type: "claude-code"+setting_sources: []+ arun_limits:cap block; the one file that is copied verbatim into user repos was left without any of them, andsetting_sourcesdefaults to["project"](pinned bytests/test_agent.py::test_setting_sources_default_is_project). (trigger: plugins/coder-eval/reference/templates/activation.yaml) (restates: Axis 7: The check-skill activation template omits type: "claude-code" + setting_sources: [])
Tests:
- 🟠 🟠
skill_triggered.py— CE036's live-verdict contract table was not extended for the new branch, although CLAUDE.md states that adding a live-criterion behaviour means addingContractCases in the same change.tests/lint/live_verdict_contract.py:146 _skill()builds only success-status Skill calls, and both fixture builders typeresult_statusasLiteral["success","error","unknown"]with no| None(tests/_fixtures/live_criteria.py:33,live_verdict_contract.py:141) — so the in-flightresult_status=Noneshape theEarlyStopWatcheractually feedslive_verdictis not merely untested, it is unrepresentable in the harness that exists to prove monotonicity. (trigger: src/coder_eval/criteria/skill_triggered.py) (restates: Axis 6: skill_triggered's new engagement gate is a denylist over a 4-valued result_status) - 🟠 🟠
templates/ci-outcome-fixture/evals/*.yaml— four new task YAMLs ship with noload_taskround-trip anywhere, while the bundled plugin templates get exactly that check two hundred lines earlier in the same file (tests/test_custom_lint.py:1548,:1565). The missing assertion is why an invalidsuite_thresholds-without-datasettask shipped. (trigger: templates/ci-outcome-fixture/evals/activation.yaml) (restates: Axis 3: Fixture eval YAMLs under templates/ci-outcome-fixture/evals/ escape every tasks/** validation scan) - 🟡 🟡
tests/test_custom_lint.py— CE061 ships one repo-sweep assertion and no positive case, so every weakening of its inlined detection (threshold,.lower(), locator tuple) stays green; its sibling CE060, added in the same commit, has seven synthetic cases behind an extracted_offendershelper. (trigger: tests/test_custom_lint.py) (restates: Axis 3: CE061 has no synthetic-offender test) - 🟡 🟡 CE060/CE061 scan
tasks/only, so neither rule sees the artifacts this PR exists to ship:plugins/coder-eval/reference/templates/outcome-rows.jsonl+activation-rows.jsonl(the rows every user's suite is copied from) andtemplates/ci-outcome-fixture/evals/. A shipped template could carry a partly-labelled dataset or a verbatim answer leak and the new rules would be silent — the first thing a user inherits is the un-linted copy. (trigger: tests/test_custom_lint.py) - 🟡 🟡
tasks/skills/ci-outcome.yaml— the metric-vocabulary check and the per-rowmax_usdcap check run against the bundled template only, never against the checked-in suite that actually spends ~$4.30 a pass; both sensors sit outside the helper the sample test claims to share. (trigger: tasks/skills/ci-outcome.yaml) (restates: Axis 3: Two outcome-suite contracts are asserted only against the bundled template) - 🔵 🔵
task_loader.py—row_split_labelis a new PUBLIC function (three lint tests already import it directly) with no direct unit test. Its contract — absent/null/""are unlabelled but a falsy0is a real label — is asserted only throughexpand_datasetintests/test_dataset_expansion.py, so a regression in the helper is caught only where a caller happens to exercise it. (trigger: src/coder_eval/orchestration/task_loader.py)
Downstream consumers:
- 🟠 🟠
run_command.py/models/results.py—--splitis now the primary invocation shape (Stage A/B on--split train, confirmation on--split test), but it is recorded nowhere.BatchRunConfig.split(orchestration/config.py:89) is the request only;RunSummaryhas no split/row-selection field, andreports.py/reports_html.py/reports_junit.pycontain nosplitreference at all. So arun.jsonfrom the train arm is byte-indistinguishable from the test arm or from an unfiltered run, and nothing — not the skill, not an external consumer — can verify which population produced a number it is comparing. (trigger: src/coder_eval/cli/run_command.py) - 🟠 🟠
skill_triggered.py— the repo already has a worked pattern for a scoring-semantics change (docs/REPORT_SCHEMA.md:143-151: a marker field in the run record plus an explicit "not score-comparable, consumers should segment on it" note, as done forsystem_prompt_semantics). The criterion's definition changed and neither half shipped: no marker, no REPORT_SCHEMA entry, so every storedskill_triggeredaccuracy/recall/F1 silently spans two definitions. (trigger: src/coder_eval/criteria/skill_triggered.py) (restates: Axis 8: skill_triggered now ignores errored Skill calls, but three definition surfaces still document the old contract) - 🟡 🟡
skill_triggered.py— thresholds tuned against the old definition were not revisited. Every existingsuite_thresholds: {recall.yes: …}on askill_triggeredcriterion (tasks/skills/lint-tasks-activation.yaml, both shipped templates, the fixture suite) is now evaluated by a stricter classifier, so a suite that passed at 0.7 can fail on unchanged agent behaviour with no note saying why. (trigger: src/coder_eval/criteria/skill_triggered.py) (restates: Axis 8: skill_triggered now ignores errored Skill calls, but three definition surfaces still document the old contract)
Display & mapping dicts:
- 🟡 🟡
models/criteria.py:1204still renders "Only count Skill invocations whose 'skill' parameter matches this name" into the GENERATEDplugins/coder-eval/reference/criteria.md:260— the criteria reference bundled into the published plugin and read by the skills.make plugin-referencewas not re-run, and CE033 forbids hand-editing the output, so this one must be fixed at the model. (trigger: src/coder_eval/criteria/skill_triggered.py) (restates: Axis 8: skill_triggered now ignores errored Skill calls, but three definition surfaces still document the old contract) - 🟡 🟡 No rendering surface gained a slot for the new outcome. The refusal is dropped into
logger.debugand the criterion'sdetailsstring (line 165) still renders onlyobserved=… expected=…, sotask.json, the markdown report and the HTML report all show a plainobserved='no'— identical to "the model never called the skill", which needs the opposite remedy. (trigger: src/coder_eval/criteria/skill_triggered.py) (restates: Axis 6: The refused Skill call is recorded only as a DEBUG line and never reaches the criterion's details) - 🟡 🟡 A selector-emptied run has no report representation.
reports_junit.py:328rendersskipped_tasksas a syntheticskippedtestsuite (green in every CI reader), andreports.pydoes not render skipped tasks at all — so the only surface distinguishing "zero rows because the split name was mistyped" from a completed run is one yellow console line. (trigger: src/coder_eval/orchestration/task_loader.py) (restates: Axis 6: A --split value matching no row is a global invocation error but is demoted to skipped_tasks)
Daily/nightly:
- 🟠 🟠 The split LABEL VALUES were renamed
tune/holdout→train/testin the in-repo suite and in every doc example, and the PR states no blast radius for it. Any external caller that pins the old value — a saved eval-runner /coder-eval-uipathnightly command, a scheduled--split tuneinvocation, a dashboard keyed on the label — now lands exactly on the exit-0 hole:ValueError→skipped_tasks→ green run of zero rows. This is a data-value break dressed as a terminology cleanup. (trigger: tasks/skills/lint-tasks-activation-rows.jsonl) (restates: Axis 6: A --split value matching no row is a global invocation error but is demoted to skipped_tasks) - 🟠 🟠 The PR does not say what happens to any recurring run that already scores
skill_triggered. Recall/F1 can step down for byte-identical agent behaviour the first night after this merges, and the only discriminator in the artifact isRunSummary.framework_version— which no trend line segments on. State the version at which the definition changed and name the consumers that must re-baseline. (trigger: src/coder_eval/criteria/skill_triggered.py) (restates: Axis 8: skill_triggered now ignores errored Skill calls, but three definition surfaces still document the old contract) - 🟡 🟡 Nothing executes the new suite. No workflow references
tasks/skills/**(CI'scoder-eval run tasks/*.yamlis depth-1 by design, per the comment atpr-checks.yml:548), so the checked-in worked example, its 10 rows and the mountedtemplates/ci-outcome-fixture/are exercised by shape assertions only — which is precisely why the fixture's invalidactivation.yamlshipped. The PR should say whether a nightly adopts this suite (and who pays the ~$4.30/pass, ~$40/A-B-round) or state plainly that it is a manually-run reference artifact. (trigger: tasks/skills/ci-outcome.yaml)
Harness & Lint Improvements
Static checks (lint / type):
- [ce-lint] CE067 — a lint rule that enumerates dataset rows must read the UNSAMPLED rows. AST check over
tests/lint/**andtests/lint_tests/**: a call toorchestration.task_loader.expand_dataset(...)inside a@pytest.mark.lintclass body (or a shared reader it calls) is a violation unless the same function first assertstask.dataset.sample_per_stratum is None or task.dataset.sample_seed is not None. The sanctioned reader is_load_dataset_rows+ local${row.*}substitution, which is what CE060 already does. Live offender at HEAD:tests/lint_tests/test_lint_task_surfaces.py:194(for row in expand_dataset(task, task_file_dir)) — CE061's whole row scan. Wire as aBaseRuleintests/lint/rules/ce067_lint_rules_read_unsampled_rows.py+tests/lint/runner.py(its subject is one.pyAST, so it belongs in the runner, unlike CE060/CE061 themselves). Prevents: Finding A3-low "CE061 scans rows throughexpand_dataset": the moment anytasks/**suite setssample_per_stratumwithout asample_seed, CE061 silently checks a different random subset each run and a leaking row passes CI intermittently. Also protects any future row-reading rule from the same nondeterminism. - [ce-lint] CE068 — the CLI exit gate must consult every failure bucket
RunSummarydeclares. Model-driven check (the CE058mirrored_result_fieldsshape): collect the fields ofmodels.results.RunSummarywhose name matchestasks_failed|tasks_error|skipped*|*_gates, then AST-walkcli/run_command.py's exit-code expression and require each to be referenced or carry an explicit# noqa: CE068 — <reason>naming why that bucket is not a failure. Today the gate isif summary.tasks_failed > 0 or summary.tasks_error > 0 or failed_suite_gates > 0:(run_command.py:560) andskipped_tasksis referenced nowhere in it. Wire as a@pytest.mark.lintclass (it reads model metadata + one AST) intests/lint_tests/test_lint_wiring_pointers.py. Prevents: Finding A6-high "a--splitvalue matching no row … the run exits 0 with zero rows measured" — reproduced end-to-end at pr-130. The per-finding fix (SplitSelectorError, now intask_loader.py:39) closes ONE demoted error; this closes the class, i.e. any future demoted-to-skippedcause producing a green run of zero rows. - [ce-lint] CE069 — every
coder-eval <command> … --flagwritten inplugins/**ordocs/**must resolve to a flag that command declares. The inverse direction of CE046, over the same readertests/lint/cli_flags.py::long_flags(which resolves TyperOptionInfo.param_decls), so both directions share one declaration source. Scan fenced/inlinecoder-eval <cmd>invocations, extract--longflags, and require each inlong_flags(<cmd fn>). Declare the existing blind spot in the docstring: Typer-derived flags carry noparam_decls, so the checker must union in the parameter-name-derived form or exempt it explicitly. Prevents: Finding A7-low "the new capability preflight claims to verify--splitexists, butcoder-eval planhas no such flag" (SKILL.md:44-48). Same rule catches the recurring class: a skill or tutorial telling a user to run a flag combination the binary rejects, which costs a paid round to discover. - [ce-lint] CE070 — a shipped measurement template's per-split row count must meet a single declared minimum, and the estimator must enforce the same number. Declare
MIN_PAIRED_Nonce inreports_stats.py(replacing the bareif len(common_tasks) < 2:at reports_stats.py:549), then have the rule (a) read everyplugins/coder-eval/reference/templates/*-rows.jsonl, group bysplit, and assert each split's count ≥MIN_PAIRED_N, and (b) assert the execution-track section ofoptimize-skill/SKILL.mdstates that number. At HEADoutcome-rows.jsonlis still 2 train / 2 test. Same family as CE040 (bootstrap_p_floorseam): the constant is derived, never respelled. Prevents: Finding A8-medium "the outcome template ships 2 train rows and the execution track states no minimum-n, while its gate is a paired t-test that can promote on n=2 with p=0.000". Also closes the asymmetry the verifier found:activation.yaml:13-16states the sizing rule,outcome.yamlhas no counterpart. - [ce-lint] CE071 — no
# type: ignorein a file that no pyright pass analyzes. Derive the analyzed set from[tool.pyright].include/excludeinpyproject.tomlplustests/lint/pyright_config.py::INCLUDE(the second-pass generator, which already reads the same TOML), then flag any# type: ignorecomment in a file outside it. An inert suppression is worse than none: it reads as "a reviewer decided this" while suppressing nothing. There are 158 such comments undertests/today, so land it with a one-time sweep or a checked-in baseline. Pair with the ruff item below. Prevents: Finding A1/A2-low "test helper widens theresult_statusLiteral to a barestrand silences the resulting error with a self-inflicted, unjustified# type: ignore[arg-type]" (tests/test_skill_triggered.py:24,32). The ignore is unjustified AND inert; this rule makes the second half mechanical, and deleting it forces the honest annotationLiteral["success", "error", "unknown"] | None. - [ruff] Add
"PGH"to[tool.ruff.lint].selectinpyproject.toml(currentlyE,F,I,N,W,UP,B,SIM,RUF,PLR0915,PLR0912,C90).PGH003forbids a blanket# type: ignorewith no rule code andPGH004a blanket# noqa; both are zero-config and complement CE071, which handles the coded-but-inert case. Expect a small sweep across the 189 existing ignore comments; runruff check --select PGH --statisticsfirst to size it. Prevents: Same finding as CE071 (tests/test_skill_triggered.py:24). Ruff reaches it today with no new rule module, so it is the cheapest half of that fix. - [pyright] Set
reportUnnecessaryTypeIgnoreComment = "error"in[tool.pyright].tests/lint/pyright_config.py::build_configcopies every rule setting verbatim into the generated second-pass config, so one edit governs both passes and the two cannot drift. This turns a suppression that no longer suppresses anything into a hard failure instead of a fossil. Prevents: Finding A1/A2-low (# type: ignore[arg-type]at tests/test_skill_triggered.py:32). Once the parameter is annotated as the realLiteral, this setting is what deletes the stale comment rather than leaving it to the next reader. - [ce-lint] CE072 — under
src/coder_eval/criteria/, a comparison against a proper subset of a closed statusLiteralmust be an allowlist. Widen CE018's existing engine (tests/lint/rules/ce018_no_final_status_name_denylist.py, which already detects==/!=/inagainst a member-name set) from a hardcodedFinalStatusname set to a small registry that also derivesCommandTelemetry.result_status's members frommodels/telemetry.py. Flagif <expr>.result_status == "error":where the ACCEPTING branch is theelse— i.e. the comparison names the rejected values rather than the accepted ones. State the boundary in the docstring: the rule reads branch polarity syntactically, so# noqa: CE072is the escape for a genuine reject-side test..claude/harness-candidates.mdrecords this denylist mirror as deliberately deferred under CE054's rationale; the pr-130 finding is the second occurrence, which is the promotion trigger. Prevents: Finding A6-high (cross-axis 1/2/6/7/8) "skill_triggered's new engagement gate is a denylist over a 4-valuedresult_status: only "error" is refused, so "unknown" andNonestill count as engagement" (skill_triggered.py:74) and the sibling A6-medium ungated file-read scan at :88-90. The fix has since landed as_delivered(an explicit allowlist), so this rule guards the NEXT criterion rather than the current one — which is exactly the CLAUDE.md standing rule. - [ce-lint] CE073 — one declaration of the artifact-scoring criterion-type set, derived from the
SuccessCriterionunion. Forbid a set/tuple/frozenset display of ≥2 string literals intests/**whose members are all criteriontypetags (read the tags frommodels.criteria.SuccessCriterion). The sanctioned form is one module-level constant beside_outcome_metric_vocabulary, itself derived from the union. Same seam family as CE062 (single f1 implementation) and CE040. Prevents: Finding A1-low "the artifact-scoring criterion-type set is a hardcoded literal duplicated in two tests in the same file" ({"file_check", "json_check", "run_command", "cli_called", "command_executed"}at the pr-130 lines 1621 and 1726). A new artifact-scoring criterion type would leave both copies stale and both tests green. - [ce-lint] CE074 — mkdocs
nav:titles are sentence case. Add a check to the CE028 doc-index family (tests/lint/doc_indexes.py, which already ownsnav:+extra.docs_indexas the SSOT for the three generated tables): after stripping aNN ·prefix, a title whose second-or-later word starts uppercase and is not in a small proper-noun allowlist (Docker,Claude,Bedrock,GitHub, …) fails. CE028 already regenerates README.md / docs/index.md / docs/llms.txt from the same nav, so one fix propagates. Prevents: Finding A1-low "tutorial titles 08 and 09 use Title Case while 01-07 use sentence case" (mkdocs.yml:107-109, propagated into docs/tutorials/README.md:23-24 and docs/llms.txt:53-54 by the generator). Casing drift in a generated surface is invisible to review and permanent once shipped. - [ce-lint] CE075 — every tree a skill copies into a user's repo has its task YAML round-tripped through
load_task, discovered from ONE registry.tests/lint/task_yaml_discovery.pyalready parses YAML correctly for two consumers (CE052 overtemplates/, the plugin-template test overplugins/coder-eval/reference/templates/), but the ROOTS are hardcoded per consumer, so a new fixture tree is covered by nobody until someone remembers. Derive the root set instead: everysandbox.template_sources[*].pathreferenced by any in-repo task, plustemplates/andplugins/coder-eval/reference/, minusexperiments/subdirs (which are experiments, not tasks). Fail on a task YAML under a registered root thatload_taskrejects. Prevents: Finding A3-high "fixture eval YAMLs undertemplates/ci-outcome-fixture/evals/escape everytasks/**validation scan, and activation.yaml is an invalid TaskDefinition (suite_thresholdswith nodataset:)" — a shipped-invalid artifact a README told users to run. CE052 now covers that specific tree; deriving the roots is what stops the NEXT deliberately-placed-outside-tasks/fixture from being unvalidated. - [ce-lint] CE076 — a measured figure quoted on more than one plugin surface has ONE declaration. Extend CE064's claim registry (
tests/lint/computed_claims.py, which already enforces "an arithmetic-bearing table that no registered claim names is a failure") with a MEASURED-FIGURE claim class: a span matching\b\d+/\d+ (rows|engaged)\banywhere inplugins/coder-eval/**or an in-repo suite's comment block must map to a registry entry that records the figure AND the form it measures; two surfaces quoting the same ratio with different attributions fail. Second half, same rule: parse theinitial_promptout of the SKILL.md YAML fence and require every shipped exemplar's prompt opening to match its SHAPE (slash-prefixed or not) — todaytests/lint_tests/shared.py:405-406asserts onlyinvocation in opening, a bare substring the backticked imperative already satisfies. Prevents: Finding A1/A8-medium "the slash-form prompt rule in optimize-skill/SKILL.md contradicts the shipped artifacts … and the 6/6 figure is attributed three ways". Verified still live at HEAD:shared.py:369still says "by slash form",shared.py:406still substring-matches, andoutcome.yaml:186/SKILL.md:344still carry the imperative-only form. A doc rule no shipped artifact obeys is a rule the next author copies the wrong side of. - [ce-lint] CE077 — module-size ratchet for
tests/lint_tests/**and a cap on rule classes per module. Same shape as the existing CLAUDE.md size gate (tests/lint_tests/test_lint_claude_md_size.py): a per-module line ceiling set one step above today's worst, plus "no module holds rule classes from more than one subject family" enforced via a declared<module> -> familymap that must cover every@pytest.mark.lintclass (a class in no family is a failure — the CE064 coverage shape). Prevents: Finding A5-low "tests/test_custom_lint.py grows to 4385 lines / 59 marked rule classes in one module". The split intotests/lint_tests/has since happened; without a ratchet the grab-bag reforms one module at a time, and the coverage half is what makes the split a rule instead of a one-time cleanup.
Harness improvements (not statically reachable):
- A score-comparability ledger, diff-gated like the estimator protocol. Add a second gate beside
tests/lint/estimator_ledger.py(thepull_request-onlyestimator-protocoljob): when a PR's diff touches a decision predicate insrc/coder_eval/criteria/**— the expression that maps a trajectory toobserved_label/ a score — require a row in a new## Scoring changestable indocs/REPORT_SCHEMA.mdgiving the criterion, the version, the direction of the change, and the named external consumers (coder-eval-uipath/ eval-runner). Model it verbatim on thereference:migration block's existing bold Score-comparability warning (docs/TASK_DEFINITION_GUIDE.md:1332), which is this repo's own precedent for exactly this. Why not static: It is a DIFF property: the gate must compare a criterion's scoring predicate against a base ref, and a working tree has no base ref — the same reasonestimator_ledger.pycarries no CE number and cannot run insidemake verify. Whether a change alters the meaning of a persisted metric is also a judgement no AST walk can make; the gate can only force the judgement to be recorded. Prevents: Finding A8-high "skill_triggered now ignores errored Skill calls, but three definition surfaces still document the old contract (one of them generated) and no score-comparability marker was added". Arun.jsonwritten before the change scores 1.0 on a trajectory that now scores 0.0, and onlyRunSummary.framework_versiondistinguishes them — so a trend line in the separate nightly repo steps down for identical agent behaviour and reads as a skill regression. - Every lint rule class must fire on a synthetic offender — asserted by a meta-test, not by convention. Extend
tests/lint_tests/test_lint_harness_meta.pywith a sweep over every@pytest.mark.lintclass: it must expose its detection as a callable (a module-level reader undertests/lint/or aclassmethod, theCE060::_offenders/outcome_prompt_leak.leaksshape), and must contain at least one test that feeds that callable atmp_pathfixture built to violate the rule and asserts a NON-EMPTY result. Detection logic inlined in the body of anassert not offenderssweep is a failure, because that shape cannot be exercised in the firing direction at all. Why not static: The property is "this detector returns findings on a known-bad input", which requires constructing the input and executing the rule. A static check can see that a test function exists; it cannot see that the assertion isassert offendersrather thanassert not offendersover a fixture that is actually an offender. Prevents: Finding A3-medium "CE061 has no synthetic-offender test: every weakening of its inlined detection (threshold 12 -> 120, dropping.lower(), adding locator exemptions) leaves the suite green" — empirically confirmed by the verifier: four separate weakenings each produced 0 offenders and a green suite. CE060 next door is the house standard with seven synthetic cases; this makes the standard mechanical. - A degenerate-certainty guard on the paired estimator, with a pinned fixture.
paired_comparisonmust return a result that cannot promote when the evidence is degenerate: n belowMIN_PAIRED_N, orstddev(diffs) == 0(which today makespaired_t_ci's half-width 0, so the CI is[d, d]and "excludes zero", whilepaired_t_testshort-circuits top = 0.0at reports_stats.py:459-461). Add a fixture asserting the concrete false-promotion case — incumbent 0.5/0.5 vs candidate 1.0/1.0 on two train rows yields mean_diff +0.500, CI [+0.500, +0.500], p = 0.000 — and assert the guarded result is NOT promotable. Register the change in the estimator ledger, since it moves a rendered number. Why not static: It is numerical behaviour of an estimator on specific inputs; no lint rule can evaluate a t-distribution. The static half (CE070's row-count floor) prevents the shipped template from producing the input, but a user-authored two-row suite still reaches this code path. Prevents: Finding A8-medium (escalated by the verifier from "almost never informative" to a live false-promotion path): the template's own shape — onefile_checkplus the engagement criterion — is enough to produce identical per-row diffs and promote a skill on two rows with zero evidence about between-row variance. - A CLI exit-code smoke for invocation-scope selector errors. A test that actually invokes
coder-eval run <suite> --split <typo>and asserts a non-zero exit, plus a companion asserting a run that resolves ZERO tasks exits non-zero with a named reason. Extend it to the other one-value-applied-to-every-task selectors (--sample,--sample-per-stratum,-Dtypos) so the policyresolve_all_tasksstates in prose — "a global invocation error … is re-raised and aborts the run" (experiment.py:563-567) — is asserted per selector rather than per exception type. Why not static: Exit code is a property of the assembled process: the demote path runs inresolve_all_tasks, the gate incli/run_command.py, and only running the CLI joins them. The verifier reproduced the pr-130 bug this way (Running 0 task(s),Results: 0/0 succeeded, exit 0) after static reading alone had only suggested it. Prevents: Finding A6-high "a--splitvalue matching no row … the run exits 0 with zero rows measured", and it is the behavioural counterpart to CE068 above: CE068 pins the gate's SHAPE, this pins the OUTCOME. - One shape-assert helper per artifact family, with a coverage assert that names every sensor. Today
_assert_outcome_suite_shapecarries five checks and is shared by the bundled template and the checked-in suite, while three further template sensors (metric vocabulary, per-row cost cap, artifact-scoring) sit outside it — one of them copy-pasted inline for the second call site. Invert it: enumerate the sensor functions for the family (by naming convention or a decorator), assert every shipped artifact of that family goes through ALL of them, and fail when a sensor exists that some artifact does not run — the same "an artifact no claim names is a failure" rule CE064 already applies to arithmetic tables. Why not static: The subject is which assertions execute against which artifact, which is a pytest-collection property; a source-level check can see the call sites but not that the two lists are the same SET, and the copy-pasted inline duplicate reads as a call site while being a divergent copy. Prevents: Finding A3-medium "two outcome-suite contracts (threshold metric vocabulary, per-row cost cap) are asserted only against the bundled template, never against the checked-in suite that spends the money" —tasks/skills/ci-outcome.yamlcosts ~$4.30 a pass and ~$40 an A/B round, and itssuite_thresholdsandrun_limitsare unasserted. Also prevents the A1-low duplicated criterion-type literal from re-appearing as the shape that motivates it. - A criterion evidence-contract test: a suppression that changes the score must reach
details. Extend thetests/lint/live_verdict_contract.pyengine (already the place where criteria are executed against synthetic trajectories and type-checked by the second pyright pass) with a per-criterion case: run the checker over a trajectory containing a suppressed signal, and assert the returnedCriterionResult.detailsnames it. A DEBUG log line is not enough — it lands intask.logper task, while the operator reads an aggregate recall of 0.0 across 24 rows and has no pointer to the cause. Why not static: It needs a constructed trajectory pushed through the real checker and the producedCriterionResultinspected; a static rule can see thatlogger.debugis called but not what the returneddetailsstring contains. Prevents: Finding A6-low "the refused Skill call is recorded only as a DEBUG line and never reaches the criterion'sdetails, so the report showsobserved='no'with no attribution" — anofrom "the model never called the skill" needs the opposite remedy from anofromdisable-model-invocation, and the report cannot tell them apart. (The_SUPPRESSED_RENDER_LIMITnote now inskill_triggered.pyis the fix; this is what keeps the next criterion honest.) - Bind every prose-declared exemption list to its single constant, both directions. The pattern that worked for CE061 —
LEAK_LOCATOR_FIELDSinsrc/coder_eval/leak_detection.pyplustest_ce061_exemption_list_matches_claude_md— should become the standing shape: any allowlist/denylist aCLAUDE.mdsentence or a skill surface enumerates must exist as ONE named constant insrc/, and a test must fail when either side is edited alone. Sweep the remaining prose-enumerated lists (the merge-strategy defaults, the CE030 EXEMPT set, theLEAK_LOCATOR_FIELDSprose sentence's own membership) and add the binding test per list. Why not static: Matching a natural-language sentence's enumeration against a Python tuple is only mechanical once someone has declared WHICH sentence corresponds to WHICH constant; that pairing is authored, not inferable. Once paired, the assertion is trivial — which is precisely why the pairing should be a required artifact rather than a remembered convention. Prevents: Finding A7-medium (cross-axis 1/5/7/8) "CE061's locator-exemption list is declared twice (inline rule + CLAUDE.md prose) with nothing binding them: the copies already disagree, one entry is dead, andskill_name/expected_skillare omitted although the same PR mandates them". Note the verifier's open residue:expected_skillcarries the same value asskill_nameand is exempt in NEITHER copy, so a skill whose bare name is ≥12 chars (e.g.optimize-skill) still trips CE061 on a correctly-authored suite — the sweep should settle that while the binding is added. - Two conventions for one missing-value question must not coexist; assert the survivor.
stratum_key/row_split_labelnow share a definition of "unlabelled", but the general lesson is the reviewable one: when a PR's own docstring documents a divergence ("it also turns an explicitNoneinto the string\"None\"") instead of removing it, the review should require either the convergence or a test that PINS the divergent behaviour. Add the missing pin: aTestStratifiedSamplecase for an explicitnullstratify value, so the grouping behaviour is asserted rather than described. Why not static: Whether two spellings of "absent" are meant to be the same population is a semantic judgement about the data model; no rule can distinguish a deliberate two-bucket design from an accident. What IS enforceable is the follow-up — that the chosen behaviour has a test — and that only exists once the behaviour is executed. Prevents: Finding A1-medium "two competing missing-value conventions kept side by side:_stratified_samplestill buckets an explicitnullinto a separate\"None\"stratum". Unreachable with any in-repo dataset today, which is exactly why only a test — not a greenmake lint— would catch a user-authored JSONL hitting it.
Top 5 Priority Actions
- Replace the denylist at src/coder_eval/criteria/skill_triggered.py:74 with an allowlist on result_status == "success", because today an in-flight Skill call (result_status=None, seen live via ToolStartEvent) can pass-stop an armed run and every early-stopped or timed-out call finalizes as "unknown" and still scores observed='yes' — add tests for None and "unknown", which the PR's "error"-only tests do not cover.
- Fix the silent no-op run: make the split miss at src/coder_eval/orchestration/task_loader.py:498 raise a dedicated exception that resolve_all_tasks does NOT demote to skipped_tasks (or make cli/run_command.py:560 exit non-zero when zero tasks resolve), since
--split trianwas reproduced exiting 0 with "Running 0 task(s)" on the very invocation shape optimize-skill now makes primary. - Gate the file-read engagement scan at src/coder_eval/criteria/skill_triggered.py:88-90 on result_status != "error" and narrow it to tools that actually read file bodies, so a failed
cat .../skills/ci/SKILL.mdor an incidental Grep path no longer produces a false observed='yes' on the execution track's hard engagement gate (tasks/skills/ci-outcome.yaml:171-172, recall.yes: 1.0). - State a minimum train-row count for an outcome suite in plugins/coder-eval/skills/optimize-skill/SKILL.md's execution-track section and in the outcome.yaml header, because plugins/coder-eval/reference/templates/outcome-rows.jsonl ships 2 train rows and identical per-row diffs make reports_stats give a degenerate CI with p=0.000, satisfying the promotion rule at SKILL.md:765 on zero evidence about between-row variance.
- Repair templates/ci-outcome-fixture/evals/activation.yaml:29 (suite_thresholds with no dataset: block — load_task raises today) and add a load_task round-trip assertion for every non-experiments YAML under the mounted fixture in tests/test_custom_lint.py:1732, since placing the fixture under templates/ deliberately escapes every tasks/** scan and that gap is what hid the defect.
Stats: 0 🔴 · 4 🟠 · 8 🟡 · 8 🔵 across 8 axes reviewed.

PR 2 of 3 extracted from #109. Based on
pr/split-mvp(#129), notmain, so the diffabove is exactly this PR's own slice rather than a cumulative one. It merges after #129.
Blast radius
One product change.
criteria/skill_triggered.py: an erroredSkillcall is no longerengagement (
3b2802b). A refused or failed call loaded no skill body, so counting it inflatesthe activation metric on precisely the rows where the measurement itself failed. This matters for
ci, which setsdisable-model-invocation: trueand therefore makes the Skill tool refuse.Everything else is skill prose, a bundled template, a sample task suite, a fixture and two
tutorials:
optimize-skill/SKILL.mdreference/templates/outcome.yaml+outcome-rows.jsonltasks/skills/ci-outcome.yaml+-rows.jsonl,templates/ci-outcome-fixture/**ciskilldocs/tutorials/09-optimizing-a-skill-body.mdorchestration/task_loader.pytune/holdout→train/testsplit-value rename.gitignore.optimize-skill/, the skill's multi-megabyte per-arm working treeThe split-value rename (
6e9c1b4) landed before any of this shipped —tune/holdoutwerenever in a release, so reviewers seeing those values disappear are seeing a rename, not a
breaking change.
The correction — two lint rules were squatting on
main's numbersThe branch numbered its two new dataset rules CE035 and CE036.
mainalready owns both:TestCE035WorkflowOutputParity(tests/lint/workflow_outputs.py) andTestCE036LiveVerdictContract(tests/lint/live_verdict_contract.py). This PR renumbers the newpair to CE060 and CE061 — the numbers they carry at the branch tip, so no later PR in the
stack has to move an id.
Nothing in the build could have caught this, which is the more important half. From
CLAUDE.md:So this PR also adds
test_rule_ids_are_unique_across_baserules_and_test_classes, which readsthe
TestCE<NNN>class names — the one place every rule surfaces, since aBaseRulehas a testclass and a class-wired rule is one. It was observed failing on
['CE035', 'CE036']before therenumber and passing after.
Why it matters beyond tidiness: a
# noqakeys on the id string, so while two rules shared anumber one suppression would have disarmed both — including
main's live-verdict contract.The renumber reaches five files, because two of them are user-facing: tutorial 09 tells the
reader
CE035/CE036fails the build on a partly-labelled dataset and on a verbatim leak, andtasks/skills/ci-outcome.yamlnames the rules that loadtasks/**/*.yaml. Left at the oldnumbers, each of those sentences would point a reader at an unrelated rule on
main.The renumber was applied by explicit occurrence, never by pattern. A global
s/CE036/CE061/gwould have renamed
main'sTestCE036LiveVerdictContract, orphaning its contract file andsilently retiring a live rule.
main's six CE034/CE035/CE036 mentions inCLAUDE.mdsit on oneline; exactly three moved. Of the six mentions in
.claude/harness-candidates.md, exactly one —the "Semantic answer-leak" bullet — refers to this branch's rule, and only that one moved.
What this PR does not do
significant winner. It is published as a documented method with an honest outcome, not as a
success story.
Stage B remains a human reading runs. The gate is not in this wave.
coder_eval.optimizepackage.Verification
ruff format --check(375 files),ruff check,pyright 0 errors, and the full suite green:4753 passed, 104 skipped — the checkpoint's 4752 plus exactly one, the new guard.
The 90-skip rise versus PR1 is benign and expected: CE061 parametrises per task file and skips
every task that carries no
dataset:block.