feat(cli): plan expands datasets and takes --split, plus four correctness fixes - #131
feat(cli): plan expands datasets and takes --split, plus four correctness fixes#131uipreliga wants to merge 1 commit into
Conversation
…ness fixes `coder-eval plan` now expands a dataset the way `run` does, so it previews the rows that will actually execute instead of the one unexpanded task. It takes all three row selectors, names which one narrowed the set, and warns on partial labelling and on an unseeded stratified draw. Four correctness fixes found by dogfooding the optimize-skill method on this repo: * skill_triggered's live and final verdicts agree again (d3d0f5b). The live verdict must stay undecided until ToolEndEvent, because the skill body is delivered AS the tool result — deciding at ToolStartEvent latched a verdict the final check then contradicted. * a --split that matches nothing aborts instead of exiting 0 (2483f5a). A labelled task with no matching row is a malformed INVOCATION applied to every task, not a malformed file, so it raises rather than being demoted to skipped_tasks. * every row id is validated, not just the selected ones (38efbce). Validating post-selection meant a duplicate or malformed id in an unselected row went unreported until someone changed the selector. * the activation template caps and isolates, as its own skill advises (1087cee). Corrections applied during the split: * The CE060/CE061 renumber from PR2 is carried through this range, including the surface this range itself introduces: CE061_LOCATOR_FIELDS, CE061_MIN_LEAK_CHARS and test_ce061_exemption_list_matches_claude_md. That test binds the constant to a CLAUDE.md sentence in BOTH directions, so the constant and the prose move in one edit. * A shipped source comment in `cli/plan_command.py` named the branch's split rule as CE035. That file arrives in this PR, so PR2 could not have renamed it. Found by a whole-tree sweep, not by the file list. * task_loader's `_load_dataset_rows` becomes the public `load_dataset_rows`. `cli/plan_command.py` imports it across a module boundary, and a leading underscore on a cross-module import is a claim the code does not honour. Squashed from feat/plugin-optimize-skill: b1893ac docs: the task guide documented the old skill_triggered detection rule d3d0f5b fix(criteria): 1/8 — skill_triggered's live and final verdicts agree again 2483f5a fix(dataset): 2/8 — a --split that matches nothing aborts instead of exiting 0 1087cee feat(plugin): 3/8 — the activation template caps and isolates, as its own skill advises 9098751 test(lint): 4/8 — CE036 gets unit fixtures, and exempts skill_name 38efbce fix(dataset): 5/8 — validate every row id, not just the selected ones b23b6a1 feat(cli): 6/8 — plan expands datasets and takes --split 775f8fc docs: 7/8 — state the method's caveats, and make tutorial 08's numbers true 514d8cd docs(plugin): 8/8 — extract the optimize method, and fix three seams fec1ff0 docs(plugin): the cost table's Stage B row priced only the activation gate 2774bf4 docs(tutorials): audit all nine, and measure what tutorial 08 could only assert edbfc63 docs(tutorials): name 08 for what it optimizes — a skill's description 94a9347 docs(tutorials): re-run Part 2's Stage A, and let it stop at Stage A Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @uipreliga's task in 1m 28s —— View job Code Review for PR #131
|
uipreliga
left a comment
There was a problem hiding this comment.
Review: coder_eval — pr:131 (34 files) axis:1,2,3,4,5,6,7,8
Scope: pr:131 (34 files) axis:1,2,3,4,5,6,7,8 · branch pr/split-dogfood-fixes · bcd2317 · 2026-08-21T04:09Z · workflow variant
Change class: complex — introduces a new SplitSelectorError re-raised run-wide, reorders dataset id validation to whole-set, changes skill_triggered engagement semantics from an error-denylist to a success-allowlist across two tool families, and adds a CLI --split selector to plan
Architecture, typing, error handling and security are in excellent shape (9.7-10 on four axes, zero critical or high findings across all eight), so the real risk is concentrated in measurement trust rather than code health: the skill_triggered engagement gate still credits a bare path MENTION in a Write/Edit/Bash parameter and two of its three gated read tools have no test, one committed outcome suite cannot pass under default task discovery, and the partial-labelling row drop plus the CLI-sampler-blind plan preview leave the row denominator unrecorded and the pre-spend count unfaithful — fix the five items below and the harness is releasable as-is.
Summary
| Axis | Score | 🔴 | 🟠 | 🟡 | 🔵 | Top Issue |
|---|---|---|---|---|---|---|
| 1. Code Quality & Style | 9.3 / 10 | 0 | 0 | 1 | 2 | Hand-rolled JSONL reader pasted at 3 new sites in tests/test_custom_lint.py (6 total), while the same file already routes one site through the runtime reader the PR just made public |
| 2. Type Safety | 9.8 / 10 | 0 | 0 | 0 | 2 | plan_command's `split: str |
| 3. Test Health | 8.4 / 10 | 0 | 0 | 3 | 1 | Two of the three members of the new _FILE_READ_TOOLS scoring gate (Glob, Grep) are exercised by no test |
| 4. Security | 9.8 / 10 | 0 | 0 | 0 | 2 | plan's new dataset expansion routes dataset-row content into an unescaped Rich markup sink — a row containing [/red] aborts the whole plan run with a MarkupError traceback, and [link=…] markup renders spoofed console output |
| 5. Architecture & Design | 9.7 / 10 | 0 | 0 | 0 | 3 | SplitSelectorError's docstring claims the CLI converts it to typer.BadParameter, but the new plan surface demotes it to a per-task file error |
| 6. Error Handling & Resilience | 10 / 10 | 0 | 0 | 0 | 0 | — |
| 7. API Surface & Maintainability | 9.2 / 10 | 0 | 0 | 1 | 3 | plan's dataset row preview applies no CLI sampler, so the printed "N selected" count can disagree with what run executes |
| 8. Evaluation Harness Quality | 9.4 / 10 | 0 | 0 | 1 | 1 | The partial-labelling row drop reaches no persisted artifact: only logger.warning (task_loader.py:540) and a plan console print (plan_command.py:52-57) — SuiteRollup.rows_total is the KEPT count with no denominator, and RunSummary records no row selection |
Overall Score: 9.5 / 10 · Weakest Axis: Test Health at 8.4 / 10
Totals: 🔴 0 · 🟠 0 · 🟡 6 · 🔵 14 across 8 axes.
Blockers
None.
Non-blocking, but please consider before merge
- [Axis 1] Hand-rolled JSONL reader pasted at 3 new sites in tests/test_custom_lint.py (6 total), while the same file already routes one site through the runtime reader the PR just made public (
tests/test_custom_lint.py:1787) — The same four-line comprehension is pasted again:
rows = [
json.loads(line)
for line in (sample.parent / "ci-outcome-rows.jsonl").read_text().splitlines()
if line.strip()
]git show origin/pr/split-execution-track:tests/test_custom_lint.py | grep -c json.loads = 3; at HEAD it is 7 — the PR adds copies at lines 1787, 2469 and 2647, bringing the file to six independent JSONL readers (1723, 1787, 1956, 1975, 2469, 2647). None handles a malformed line or a non-object row, unlike the real reader. The same tests deliberately route through row_split_label "the runtime's single definition of labelled" (comment at tests/test_custom_lint.py:1727-1729) while re-deriving the reader by hand — and this PR just made task_loader.load_dataset_rows public (task_loader.py:331) for exactly this kind of consumer. Extract one module-level def _jsonl_rows(path: Path) -> list[dict] in the test file (or export task_loader._load_jsonl) and call it from all six sites.
2. [Axis 3] Two of the three members of the new _FILE_READ_TOOLS scoring gate (Glob, Grep) are exercised by no test (src/coder_eval/criteria/skill_triggered.py:52) — _FILE_READ_TOOLS = frozenset({"Read", "Glob", "Grep"}) (line 52) is a scoring-relevant closed set: membership decides whether a failed/in-flight file read counts as skill engagement, i.e. whether a row scores yes or no. git show pr-131:tests/test_skill_triggered.py | grep -n 'Glob\|Grep' returns NOTHING, and in tests/test_early_stop.py Glob/Grep appear only inside comments (lines 2197 and 2237). Every gate test uses tool_name="Read" (test_skill_triggered.py:152, 162, 184). Failure scenario: a refactor drops "Glob" from the frozenset (or misspells it); antigravity's tool map renames find_file -> Glob and search_directory -> Grep, so a FAILED antigravity glob/grep whose path parameter contains skills/<name>/ then scores yes for a skill whose body never loaded — the exact false positive the PR exists to remove — and the entire suite stays green. Parametrize the existing test_failed_read_of_a_skill_path_is_not_engagement / test_pending_read_of_a_skill_path_is_not_engagement / test_unknown_read_still_counts over sorted(_FILE_READ_TOOLS) so the set is derived rather than sampled.
3. [Axis 3] plan's new dataset preview has no test for a suite carrying dataset.sample_per_stratum: the unseeded draw makes the printed "N selected" count, the set of rows whose ${row.*} substitutions get validated, and the plan exit code all nondeterministic (src/coder_eval/cli/plan_command.py:29) — _preview_dataset calls expanded = expand_dataset(task, task_file.parent, split=split_name) (line 29) passing no sampler args, but expand_dataset still applies the YAML-level sampler: n_per_stratum = sample_per_stratum if sample_per_stratum is not None else ds.sample_per_stratum with stratum_seed = ds.sample_seed (task_loader.py:567, 572), which is deliberately unseeded by default. Verified in the PR worktree: four successive expand_dataset calls on an 8-row, sample_per_stratum: 2 dataset returned ['r1','r2','r5','r6'], ['r0','r2','r5','r7'], ['r0','r3','r5','r6'], ['r2','r3','r4','r5']. Consequences with no test: (a) line 47 prints f" [dim]Dataset: {len(rows)} rows -> {len(expanded)} selected{suffix}[/dim]" where suffix attributes the narrowing solely to --split (line 46: suffix = f" (--split {split_name})"), so YAML sampling is silently folded into a line that names a different cause; (b) line 207 validates expanded[0] — "Resolve the FIRST expanded row" — which is a RANDOM row on such a suite, so coder-eval plan can exit 0 on one invocation and 1 on the next for an unchanged repo. Every new test in tests/test_plan_command.py::TestPlanCommandDatasetPreview builds inline rows via _make_dataset_task with no sampler, so this path is untested. Add a test with Dataset(rows=..., sample_per_stratum=N) pinning the intended behaviour (preferably: plan reports the unsampled count and states that sampling is redrawn at run time, or validates all rows rather than expanded[0]).
4. [Axis 3] The "CLI seam" test for the SplitSelectorError abort reimplements the handler in-test, so its assertion is a tautology and its exit-code comment pins nothing (tests/test_dataset_expansion.py:863) — test_unmatched_split_surfaces_as_a_cli_bad_parameter (line 863) is labelled "The CLI seam", but its body is try: resolve_all_tasks(...) except ValueError as e: # exactly what cli/run_command.py does / raise typer.BadParameter(str(e)) from e (lines 874-882). It never imports or invokes run_command, so it proves only that raise typer.BadParameter raises typer.BadParameter. Its own comment asserts an exit code it never checks: "typer.BadParameter exits 2, not 1 — do not 'fix' a later assertion to 1" — there is no exit-code assertion anywhere in the test. Failure scenario: someone changes cli/run_command.py:688-689 (except ValueError as e: raise typer.BadParameter(str(e)) from e) to log-and-continue, or moves the split filter ahead of that try block; the run then goes back to the exit-0/zero-rows outcome that motivates this whole change (see .claude/harness-candidates.md's "Narrow case CLOSED" note, which credits the CLI conversion) and this test still passes. git grep -n '\-\-split' tests/ finds no CliRunner-level run test at all. Replace the in-test mirror with a CliRunner invocation of the real run command (or at minimum call the module-level function under patch) asserting a non-zero exit and the splits-present message.
5. [Axis 7] plan's dataset row preview applies no CLI sampler, so the printed "N selected" count can disagree with what run executes (src/coder_eval/cli/plan_command.py:29) — _preview_dataset calls the expander with the split only:
29: expanded = expand_dataset(task, task_file.parent, split=split_name)run calls the same function with all three selectors (src/coder_eval/cli/run_command.py:681-687 -> orchestration/experiment.py:630-637: max_rows=config.max_rows, sample_per_stratum=config.sample_per_stratum, split=config.split), and plan exposes no --sample / --sample-per-stratum option at all. So the line printed at
47: console.print(f" [dim]Dataset: {len(rows)} rows -> {len(expanded)} selected{suffix}[/dim]")can disagree with the run in both directions. Verified on the in-repo suite tasks/skills/lint-tasks-activation.yaml (28 rows) at pr-131 HEAD:
plan --split train->28 rows -> 17 selected (--split train)run --split train --sample-per-stratum 2-> 8 rows executedrun --split train --sample 5-> 5 rows executed
The reverse (preview UNDER-reports) is reachable too, because a CLI --sample-per-stratum N overrides dataset.sample_per_stratum (task_loader.expand_dataset line n_per_stratum = sample_per_stratum if sample_per_stratum is not None else ds.sample_per_stratum): a YAML sample_per_stratum: 2 is applied by the preview, and run --sample-per-stratum 10 then executes up to 5x the previewed rows.
The claim is explicit, not incidental:
18: Runs at the one surface that costs nothing, so a suite's resolved row count — the
19: number every cost estimate and every A/B comparison depends on — is knowable beforeand the flag help asserts parity with run:
79: "matching `coder-eval run --split`. Tasks whose rows are all unlabelled are "The shipped plugin depends on that number: plugins/coder-eval/skills/optimize-skill/SKILL.md:45-47 tells the user to "read the printed row count, which is the number every cost estimate below depends on".
Fix, cheapest first: (a) add --sample / --sample-per-stratum to plan and pass all three through, so the preview is the run's selection by construction; or (b) if the extra flags are unwanted, stop claiming parity — drop "matching coder-eval run --split" and the "every cost estimate" sentence, and label the number as pre-sampling (e.g. 28 rows -> 17 selected (--split train; --sample/--sample-per-stratum not applied)). Also worth stating on the same line that a run multiplies by variants and --repeats, which the printed row count does not include.
6. [Axis 8] The partial-labelling row drop reaches no persisted artifact: only logger.warning (task_loader.py:540) and a plan console print (plan_command.py:52-57) — SuiteRollup.rows_total is the KEPT count with no denominator, and RunSummary records no row selection (src/coder_eval/orchestration/task_loader.py:540) — The new mitigation is logger.warning("Task '%s': --split %r kept %d of %d rows; %d row(s) carry no %r label and were DROPPED. Every metric below is computed over the smaller set.", ...) at line 540, plus the matching console.print in cli/plan_command.py:48-53. Neither reaches an artifact a consumer parses. SuiteRollup (models/results.py:876-912) carries rows_total / rows_passed / pass_rate, where rows_total is the KEPT count only — no dataset size, no drop count. RunSummary (models/results.py:1043-1093) has skipped_tasks but no row-selection record at all (there is no models/row_selection.py and no row_selection field on this branch). The --split value itself lands on disk only in resume_fingerprint.json via compute_run_fingerprint (batch.py:470-489, config.model_dump(mode="json")), which its own docstring calls "best-effort and informational only" for the --resume drift warning and which is not part of the run.json spine in docs/REPORT_SCHEMA.md. docs/DATASETS.md (in scope) already states the hazard — "during an incremental migration it silently shrinks the suite, which moves the aggregate metrics suite_thresholds gates on" — so the gap is acknowledged but left unrecorded. FAILURE SCENARIO: the nightly runs a mid-labelling suite with --split test. Night 1, 3 of 10 rows labelled and 1 is test: suite.json records rows_total: 1, pass_rate: 1.0. Night 2, labelling finishes and 4 rows are test: rows_total: 4, pass_rate: 0.5. The external coder-eval-uipath dashboard sees a 50-point drop with nothing in either artifact distinguishing "the skill regressed" from "the denominator quadrupled because 6 rows stopped being dropped" — the explanatory log line lives in run.log on the runner, not in the record the consumer reads. FIX: add the pre-selection dataset size and the dropped-row count to SuiteRollup (e.g. rows_available / rows_dropped_unlabelled) plus a row-selection block on RunSummary, and document both in docs/REPORT_SCHEMA.md.
Nits
- [Axis 1] Defensive scaffolding in
_preview_dataset: anassertused as the runtime guard on a user-facing CLI path (and placed after its own consumer), plus an over-broad return and a second full dataset read (src/coder_eval/cli/plan_command.py:58) — Three small items in the new helper, all removable: assert expanded, "expand_dataset returned no rows (the empty-dataset raise should have fired)"(line 58) is unreachable by construction —expand_datasetraisesValueError(f"Dataset for task '{task.task_id}' is empty")attask_loader.py:516— and it sits after line 47 already consumedlen(expanded), so it guards nothing its own function did not already do. It is also stripped underpython -O.- The helper returns
list[TaskDefinition](potentially hundreds of full models) for a single call site that reads onlyexpanded[0](line 207). Returning the first expanded task would delete both the list and the assert. - The docstring at lines 30-32 explains avoiding a second
expand_datasetcall, but line 33rows = load_dataset_rows(task.dataset, task_file.parent)re-reads the JSONL thatexpand_datasetalready read one line earlier —expand_datasetcould return or expose the pre-filter count instead.
Separately,split_name = split if isinstance(split, str) else None(line 116) shapes production code around tests callingplan_command(...)directly; it mirrors the pre-existingexperimentguard at line 132, but there are now two, and switchingtests/test_plan_command.pytoCliRunnerwould let both go. - [Axis 1] Dead
split_fieldparameter on the new_make_dataset_tasktest helper restates the model default and is never overridden (tests/test_plan_command.py:320) —split_field: str = "split",has exactly one use (line 331,dataset=Dataset(rows=rows, split_field=split_field)) and no caller in the file passes it — every call site is_make_dataset_task([...])or_make_dataset_task([...], prompt=...). It also restates a default the model already owns:Dataset.split_fieldisField(default="split", ...)atsrc/coder_eval/models/tasks.py:311-312. Drop the parameter and thesplit_field=argument;Dataset(rows=rows)is equivalent. - [Axis 2] plan_command's
split: str | Noneannotation is untrue at runtime and the compensatingisinstanceguard exists only because the tests bypass Typer, so neither pyright nor a test covers the option wiring (src/coder_eval/cli/plan_command.py:116) — The new option is annotatedsplit: str | None = typer.Option(...)(line 73) but the declared type is a lie for direct callers, so line 116 compensates at runtime:
split_name = split if isinstance(split, str) else Nonewith the comment "Tests call plan_command(...) directly rather than through CliRunner, so an unpassed option arrives as a typer OptionInfo". tests/test_plan_command.py confirms this (plan_command(task_files=[task_file], **kwargs)). Pyright sees split: str | None, so the else None branch looks like dead narrowing and pyright cannot flag the next option added without a guard — the type system has been opted out of exactly where the invariant lives. Evidence the convention does not hold: two lines earlier, resolved_task_files = task_files if task_files else discover_default_tasks() (line 112) has no guard, and typer.Argument(None, ...) returns an ArgumentInfo that is truthy and not iterable (verified: bool(ArgumentInfo) is True, list(...) raises TypeError: 'ArgumentInfo' object is not iterable), so plan_command() with no arguments crashes rather than discovering tasks.
Fix: make the tests go through CliRunner, or extract a typed inner _plan(task_files: list[Path], experiment: Path | None, split: str | None) -> None that the typer-decorated wrapper calls with real values, and delete both isinstance shims. That restores the annotation as the contract and lets pyright, not a hand-written guard per option, hold it.
4. [Axis 2] New --split accepts an empty string, which the label convention makes unsatisfiable — a value that can never match any row (src/coder_eval/cli/plan_command.py:73) — split: str | None = typer.Option(None, "--split", ...) (line 73) puts no non-empty constraint on the value, while its numeric siblings do (--sample / --sample-per-stratum carry min=1 in run_command.py, and the model fields carry ge=1). "" is inside str | None but outside the domain: row_split_label maps an absent/None/"" field to None (src/coder_eval/orchestration/task_loader.py:392, return None if value is None or value == "" else str(value)), so no row's label can ever equal "". Verified against the PR head:
expand_dataset(labelled_task, split='') -> SplitSelectorError: has no rows in split '' (split_field='split'); labelled splits present: ['test', 'train']
expand_dataset(unlabelled_task, split='') -> ['t2/a', 't2/b'] # runs everything
Because this PR promotes that raise to a run-wide abort (experiment.py:637 re-raises, run_command.py:688 turns it into typer.BadParameter), --split "" now aborts a labelled suite outright while silently running an unlabelled one whole. Fix: reject an empty/whitespace-only value at the flag (a callback= raising typer.BadParameter, or min_length=1 on BatchRunConfig.split) so a malformed selector is refused where it is typed, rather than reported as a missing split.
5. [Axis 3] The committed outcome suite's duplicate-includes scoring claim (load-bearing for the mean: 0.7 gate's variance) has no assertion anywhere (tasks/skills/ci-outcome.yaml:201) — The new second slot - "${row.expected_snippet_2}" (line 201) ships with a numeric claim at lines 190-193: "two identical entries score 0/2 or 2/2, i.e. numerically identical to a single include, so no constant sub-check is introduced and the variance the mean: 0.7 gate reads is untouched" — and nine of the ten rows in tasks/skills/ci-outcome-rows.jsonl do set expected_snippet_2 equal to expected_snippet. The claim currently holds (found = sum(1 for inc in criterion.includes if inc in content); includes_score = found / total in src/coder_eval/criteria/file_check.py:71-74, no dedup), but nothing asserts it: grep -n includes tests/test_file_check.py shows cases for 1, 2 and 5 DISTINCT includes only, never a repeated value. Failure scenario: FileCheckChecker is changed to de-duplicate or to weight includes, the nine identical pairs stop being score-neutral, and every outcome row's score shifts against an unchanged mean: 0.7 suite threshold with no test failing. Add one test_duplicate_includes_score_identically_to_one in tests/test_file_check.py asserting includes=["a","a"] scores the same as includes=["a"] for both the found and not-found cases.
6. [Axis 4] plan's new dataset expansion routes dataset-row content into an unescaped Rich markup sink — a row containing [/red] aborts the whole plan run with a MarkupError traceback, and [link=…] markup renders spoofed console output (src/coder_eval/cli/plan_command.py:228) — The new call expanded = _preview_dataset(task, task_file, split_name) (plan_command.py:187) makes JSONL row bodies reach the pre-existing sink console.print(f" [red]Error: {e}[/red]") (plan_command.py:228), because raise ValueError(f"Dataset row {i} for task '{task.task_id}' missing id_field '{id_field}': {row}") (orchestration/task_loader.py:412) interpolates the ENTIRE row dict, and SplitSelectorError's message interpolates row-derived split labels. Reproduced at PR HEAD: a single-row JSONL {"prompt": "harmless [/red] and [link=file:///etc/passwd]click-me[/link]"} with no id field gives uv run coder-eval plan tmp/t.yaml tmp/z-second.yaml -> MarkupError: closing tag '[/red]' at position 145 doesn't match any open tag, an unhandled traceback raised from INSIDE the except Exception handler, so the loop aborts and z-second.yaml is never validated (the per-file all_valid = False path is bypassed entirely). A well-formed row is enough for the spoofing half: [link=file:///etc/passwd]click-me[/link] renders as a clickable hyperlink whose visible text is row-chosen. This matters because the bundled skills (plugins/coder-eval/skills/optimize-skill/SKILL.md, check-skill) have the AGENT author dataset JSONL rows, so the content is not operator-typed. Fix: pass the untrusted half through rich.markup.escape() (or console.print(..., markup=False)) at plan_command.py:228, 221 and 224 — e.g. console.print(f" [red]Error: {escape(str(e))}[/red]"); the same escape is already used in streaming/renderers.py:103. Consider also trimming the whole-row dump at task_loader.py:412 to the row index plus its id-field keys, since that message is persisted into skipped_tasks in run.json. CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:L
7. [Axis 4] skill_triggered still credits engagement from a MENTION of skills/<name>/ in any Write/Edit/Bash parameter, so the evaluated agent can satisfy the new outcome suite's recall.yes: 1.0 engagement gate without the skill body ever loading (src/coder_eval/criteria/skill_triggered.py:118) — The rewritten gate is if not (cmd.tool_name in _FILE_READ_TOOLS and cmd.result_status in ("error", None)): (skill_triggered.py:118) followed by the scan over cmd.parameters.values() (119-121), with _FILE_READ_TOOLS = frozenset({"Read", "Glob", "Grep"}) (line 52). Everything else — Write, Edit, Bash, TodoWrite — is scanned unconditionally, and claude_code_agent.py:361 stores the tool input dict untruncated, so Write.content is scanned whole. Reproduced at PR HEAD against the real function: Write with content="# see plugins/coder-eval/skills/ci/SKILL.md" and result_status="error" -> _engaged_skill_names returns {'ci'}; Bash with command="echo skills/ci/" and result_status="error" -> {'ci'}. This PR ships tasks/skills/ci-outcome.yaml, whose gate is suite_thresholds: {recall.yes: 1.0} on skill_triggered and whose graded artifact is an agent-written .github/workflows/*.yml that plausibly contains a plugin path (the skill-source-path row's scenario is about SKILL_SOURCE_PATH), so a row can pass the engagement gate on a mention while the body never loaded — exactly the false yes the rest of this diff was written to remove. Note the resulting asymmetry: Read with result_status="unknown" counts (line 118 excludes only "error"/None) while Skill with "unknown" does not (line 89), and the module docstring's claim that 'a failed or unresolved Read/Glob/Grep loaded nothing and are not engagement' (lines 7-9) is therefore not what the code does. Fix: restrict the path scan to parameters that are plausibly READ targets (a _PATH_PARAMS allowlist such as file_path/path/pattern/command, mirroring LEAK_LOCATOR_FIELDS) rather than every string value, and make the status gate an allowlist (cmd.result_status in ("success", "unknown")) to match the Skill branch's stated allowlist-not-denylist rule. CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N
8. [Axis 5] SplitSelectorError's docstring claims the CLI converts it to typer.BadParameter, but the new plan surface demotes it to a per-task file error (src/coder_eval/orchestration/task_loader.py:52) — The class docstring asserts one CLI behaviour - lines 51-53: A ``ValueError`` subclass so every existing ``except ValueError`` caller keeps working; ``resolve_all_tasks`` re-raises it explicitly and the CLI turns it into a ``typer.BadParameter``. That holds for run (run_command.py:688-689, except ValueError as e: raise typer.BadParameter(str(e)) from e, exit 2) but not for the second CLI consumer this PR adds: _preview_dataset's raise at plan_command.py:29 lands in the per-task-file except Exception as e: at plan_command.py:226-229, printing ✗ <file> / Error: ..., setting all_valid = False, continuing with the remaining task files, and exiting 1 - the per-task demotion lines 40-45 of the same docstring argue against for this error class, and a different exit code from run for the identical user error. The behaviour is deliberate and tested (tests/test_plan_command.py::test_plan_reports_an_unmatched_split_as_an_error_and_exits_1), so fix the contract statement: say the class is run-wide fatal on run and per-file on plan, or catch SplitSelectorError explicitly in plan_command and raise typer.BadParameter so both surfaces agree.
9. [Axis 5] The new except-branch lands inside resolve_all_tasks's 240-line, CC-37 try block instead of narrowing it (src/coder_eval/orchestration/experiment.py:637) — resolve_all_tasks spans lines 538-777 and is the tree's second-worst function by radon (E (37), tmp/code-review-260820-2109/automated/radon.txt:981 F 538:0 resolve_all_tasks - E (37)); this PR adds except SplitSelectorError: / raise at 637-641 as a fourth exception arm on a try: (line 613) that already covers load_task, the skip: true short-circuit and expand_dataset together. The god-function pre-dates this change, so the worsening is marginal, but the cheap fix keeps it from growing: pull the load+skip+expand block into a _load_and_expand(task_file, config) helper that owns the two exception policies (re-raise SplitSelectorError, demote (FileNotFoundError, OSError, ValueError, yaml.YAMLError) at 647-651), leaving the loop body to the per-file resolution it is actually about.
10. [Axis 5] A new per-criterion contract dimension (which event seam a live verdict is decidable at) is documented in prose only, next to an existing declared mechanism for the analogous dimension (src/coder_eval/orchestration/early_stop.py:425) — Lines 420-426 introduce a requirement on every future member of the live-criterion extension point: Whether a given criterion can actually decide there is **per-criterion, not global** ... A new live criterion must state which seam its verdict is decidable at. Nothing carries that statement: the sibling dimension has a real declaration point, BaseSuccessCriterion.live_decidable_polarities() (models/criteria.py:306, overridden at 1078 and 1215) which EarlyStopWatcher reads and which CE036 covers, whereas the seam is only implicit in each checker's live_verdict body. Nor can CE036's replay reach it: tests/_fixtures/live_criteria.py:33 defaults result_status="success", so no ContractCase exercises an in-flight (None) or force-closed call. Fix: either express the seam the same way the polarity is expressed (a declared method/attribute the watcher consults, so "stating it" is code rather than a docstring), or say explicitly in that paragraph that the seam is expressed only by returning undecided from live_verdict and add a contract case with result_status=None so the boundary is exercised.
11. [Axis 7] New plan --split flag never reaches the canonical CLI reference: docs/USER_GUIDE.md's coder-eval plan flag table still lists only --experiment, while the sibling run table documents all three row selectors (src/coder_eval/cli/plan_command.py:73) — The PR adds
73: split: str | None = typer.Option(
74: None,
75: "--split",but leaves docs/USER_GUIDE.md untouched (it is not in the PR's 34-file diff). At pr-131 HEAD that file's ### \coder-eval plan` — validate taskssection (line 62) carries a two-row flag table whose only entry is line 73:| `--experiment, -e` | Experiment definition YAML to resolve variants against (default: `experiments/default.yaml`). |— while therun table above it documents all three selectors on lines 45-47 (--sample N, --sample-per-stratum N, --split NAME). Nothing mechanical catches this: there is no CLI-flag/doc-parity lint rule at this HEAD (tests/lint/cli_flags.pydoes not exist inpr-131`).
Add one row to that table — | \--split NAME` | For dataset-backed tasks, preview only rows whose `dataset.split_field` value matches. ... |— and consider a CE rule that resolves everytyper.Optiondeclared name incli/to a row in the matchingUSER_GUIDE.md flag table, which would also have caught this. (docs/DATASETS.md:158-160does mentioncoder-eval plan --split , which is why this is Low rather than higher.) 12. **[Axis 7] --split help text is a second hand-authored literal in plan_command, already drifted from run_command's, with nothing pinning them together** (src/coder_eval/cli/plan_command.py:76) — This PR writes a second, hand-authored description of the same flag. plan_command.py:76-82`:
"For dataset-backed tasks, preview only rows whose dataset.split_field value "
"(default field: split) matches this name — e.g. --split train / --split test, "
"matching `coder-eval run --split`. Tasks whose rows are all unlabelled are "
"unaffected; a labelled task with no row in this split is an error naming the "
"splits that exist."and run_command.py:304-310, edited in the same PR:
"For dataset-backed tasks, keep only rows whose dataset.split_field value "
"(default field: split) matches this name — e.g. --split train / --split test. "
"Applied BEFORE --sample / --sample-per-stratum, so a sampled split keeps a "
"predictable size. Tasks whose rows are all unlabelled are unaffected; a "
"labelled task with no row in this split aborts the run with an error naming "
"the splits that exist."The two are ~85% duplicated prose, and the drift is already load-bearing rather than cosmetic: only the run copy carries the "Applied BEFORE --sample / --sample-per-stratum" sentence, and that omission is the same gap as finding #1. Extract the shared sentences into one module (e.g. src/coder_eval/cli/row_selectors.py) that both commands import, and pin identity with a lint rule asserting the two help= values come from the same object, so a future edit to one surface cannot describe the flag two ways. (Note for the verifier: neither src/coder_eval/cli/row_selectors.py nor rule CE065 exists at pr-131 HEAD — git show pr-131:src/coder_eval/cli/row_selectors.py reports "exists on disk, but not in 'pr-131'" and git grep -n CE065 pr-131 is empty — so this PR is what creates the duplication those later land to fix.)
13. [Axis 7] skill_name's schema description says engagement requires "a successful file read", contradicting the implementation's deliberate ungating of Bash — and it is rendered verbatim into the shipped plugin criteria reference (src/coder_eval/models/criteria.py:1210) — The field description reads:
1208: skill_name: str = Field(
1209: description=(
1210: "Only count SUCCESSFUL Skill invocations whose 'skill' parameter matches this name "
1211: "(or a successful file read under skills/<name>/)."
1212: ),But src/coder_eval/criteria/skill_triggered.py:46-48 states the opposite for the file-read half, on purpose:
46: # File-read tools whose FAILURE means nothing was loaded. ``Bash`` is deliberately absent:
47: # ``cat skills/x/SKILL.md | grep foo`` exits 1 after genuinely reading the file, so a status
48: # gate there would drop real Codex engagement.and line 118 gates only Read/Glob/Grep, and only on result_status in ("error", None) — so "unknown" counts there too. Concretely: a Codex row whose Bash step is cat skills/my-skill/SKILL.md | grep nothing exits non-zero and still scores observed="yes", which a task author reading the schema would predict as "no". This is not an internal docstring: make plugin-reference renders the string verbatim into the shipped plugins/coder-eval/reference/criteria.md:260, which for plugin users is the only description of the criterion. docs/TASK_DEFINITION_GUIDE.md gets it right ("Bash is deliberately left ungated"), so only the schema SSOT is wrong. Reword to match, e.g. "...or a file read under skills// (a failed or in-flight Read/Glob/Grep does not count; Bash is ungated)."
14. [Axis 8] tasks/skills/ci-outcome.yaml is committed without skip: true even though it cannot pass against the in-repo plugin, so any full-discovery run reds and spends ~$4.30 (tasks/skills/ci-outcome.yaml:41) — The file's own header states the two blocking conditions: ci sets disable-model-invocation: true, so "mounting the plugin as-is loads no body" and "Mount plugins/coder-eval directly instead and engagement is 0/6"; and $SKILL_SOURCE_PATH must point at a hand-prepared copy with that frontmatter line stripped (sed -i '' '/^disable-model-invocation: true$/d' tmp/ci-arm/skills/ci/SKILL.md). Its skill_triggered criterion carries suite_thresholds: recall.yes: 1.0, so with the in-repo plugin (or an unset path) the gate fails, failed_suite_gates > 0, and run_command.py:561 exits 1. cli/run_helpers.py:41 discovers tasks with DEFAULT_TASKS_DIR.rglob("*.yaml"), which reaches tasks/skills/, and this file declares tags: [outcome, skills] (line 41) with no skip: key. FAILURE SCENARIO: a contributor or a nightly entrypoint runs coder-eval run with no task arguments; discovery includes this suite, the Skill call is refused on all 10 rows, recall.yes is 0, the recall.yes: 1.0 gate fails, and the run exits 1 after roughly $4.30 and ~25 minutes of agent time, with no way to pass short of the manual sed the comments document. Repo CI is unaffected — .github/workflows/pr-checks.yml uses the non-recursive tasks/*.yaml glob plus --tags smoke-* — but default discovery is not. FIX: add skip: true; models/tasks.py:423 documents that flag plus --include-skipped as exactly the opt-in-suite mechanism, and the sibling tasks/skills/lint-tasks-activation.yaml does not need it because it passes when SKILL_SOURCE_PATH=plugins/coder-eval.
What's Missing
Parallel paths:
- 🟡
planvalidates only ONE row of a dataset per variant (plan_command.py:207,expanded[0]), whilerunvalidates EVERY expanded row × variant (experiment.py:665-684loopsfor expanded_task in expanded_tasks: for variant in ...and callsvalidate_early_stop(resolved_task)on each). A criterion that only becomes invalid after${row.*}substitution on row 7 — or astop_early:guardrail violation that only that row's substituted criterion trips — prints "All tasks are valid!" at the free pre-spend surface and then hard-errors at resolution once money is committed, which is exactly the divergence the new preview exists to close. Either loop overexpanded(cheap: resolution is in-process) or state in the printed line that only the first row was resolved. (trigger: src/coder_eval/cli/plan_command.py) - 🟡
--spliton a task with NOdataset:block is a silent no-op at both new surfaces:expand_datasetreturns[task]before any split handling (task_loader.py:511) and_preview_datasetreturns[task]with nothing printed (plan_command.py:26-27) — even though the same function prints an explicit "selector did not apply" note for the neighbouring unlabelled-ROWS state ((--split train: not labelled, all rows kept), line 44). The shipped skill names this as a live hazard in its own words —plugins/coder-eval/skills/optimize-skill/SKILL.md:167-169: "A task carrying nodataset:block is untouched by it, so Stage C's--split testsilently re-runs the train rows and reports a confirmation" — so a held-out confirmation can be run on training data with no signal at either surface. There is also no test:test_plan_leaves_a_non_dataset_task_unannotated(tests/test_plan_command.py:399) calls_run(...)WITHOUTsplit=, so the state is unexercised. Print the same style of note for a dataset-less task under--split. (trigger: src/coder_eval/cli/plan_command.py) - 🟡
plangained--splitbut not the other two row selectors, so its printed "N selected" is CLI-sampler-blind whilerunpasses all three (experiment.py:629-635); the shipped skill reads that number as the basis of every cost estimate (plugins/coder-eval/skills/optimize-skill/SKILL.md:45-47). (trigger: src/coder_eval/cli/plan_command.py) (restates: Axis 7: plan's dataset row preview applies no CLI sampler, so the printed "N selected" count can disagree with what run executes) - 🔵 Three of the four surfaces that describe
skill_triggeredengagement were updated for the new "the signal must have DELIVERED" rule (models/criteria.py:1172-1215,docs/TASK_DEFINITION_GUIDE.md— which adds two explicit paragraphs — and the regeneratedplugins/coder-eval/reference/criteria.md), but CLAUDE.md's own criterion table still reads "Claude'sSkilltool call, or (Codex) reading the skill's files off disk" (line 168) and the tree comment still reads "(Skill tool / file read)?" (line 64), i.e. the pre-change semantics. Nothing mechanical covers this: CE030's doc/schema parity tracksTaskDefinition/RunLimits/Dataset/SimulationConfigfields, not the criterion table. (trigger: CLAUDE.md)
Tests:
- 🟡 Two of the three members of the new scoring-relevant
_FILE_READ_TOOLSgate (Glob,Grep) are exercised by no test — every gate test usestool_name="Read", so an antigravityfind_file->Glob/search_directory->Grepfailure that names askills/<name>/path could scoreyeswith the suite green. (trigger: src/coder_eval/criteria/skill_triggered.py) _(restates: Axis 3: Two of the three members of the new FILE_READ_TOOLS scoring gate (Glob, Grep) are exercised by no test) - 🟡 The shipped activation template now ships
train/testlabels on all six rows plussplit_field: "split"(activation.yaml:68-70, 4/2), and the plugin instructscoder-eval run <this file> --split train(line 11), but the CI step that proves the template works OUTSIDE the source tree (.github/workflows/pr-checks.yml:299-308, "Scaffold assert") still runs onlyplan activation.yamland assertslen(expand_dataset(task, Path("."))) == 6— the unsplit count. No test or CI step anywhere exercisesplan/expand_datasetwith--split trainagainst the shipped template, so a template whose labels were misspelled (trian) or dropped from one row would pass every gate and only fail in a user's repo, mid-workflow. Extend that assert with a--split train-> 4 case. (trigger: plugins/coder-eval/reference/templates/activation.yaml) - 🟡 No test drives
--splitthrough the realruncommand: the test labelled "the CLI seam" re-implementsrun_command.py'sexcept ValueError -> typer.BadParameterconversion inside the test body, so itspytest.raisesis a tautology and the exit code its own comment argues about is never asserted.git grep -n '\-\-split' tests/finds no CliRunner-levelruninvocation. (trigger: tests/test_dataset_expansion.py) (restates: Axis 3: The "CLI seam" test for the SplitSelectorError abort reimplements the handler in-test, so its assertion is a tautology)
Downstream consumers:
- 🟡 The engagement classification was tightened (a refused / in-flight / force-closed
Skillcall and a failed or unresolvedRead/Glob/Grepnow scorenowhere they scoredyes), which movesobserved_labelfor IDENTICAL agent output — and every downstream number re-derives from that label throughoverlay_classification_metrics: therecall.yes: 0.7/precision.yes: 0.7gates intasks/skills/lint-tasks-activation.yaml:59-61andplugins/coder-eval/reference/templates/activation.yaml:83-85, therecall.yes: 1.0gate intasks/skills/ci-outcome.yaml:171-172andoutcome.yaml:189-190, and the measured recall/precision/F1 tables the two new tutorials publish as reproducible (docs/tutorials/08-*.mdlines 233, 319, 344, 474, 590, 652, and its "Re-running Part 2's Stage A today measures the description this page shipped: F1 1.000" at line 760). None of those thresholds or numbers is re-derived, re-measured or annotated with the scoring version that produced it, so any A/B or tutorial re-run that straddles this commit compares two different instruments. State which committed numbers were measured under which semantics, or re-measure them. (trigger: src/coder_eval/criteria/skill_triggered.py) - 🔵 The new
Dataset: N rows -> M selectedline is a per-suite, pre-fan-out count: a run multiplies it by variants and--repeats(experiment.py:695-700fans outfan_count = n_trials if n_trials > 1 else effective_repeats), and the optimize skill's Stage B/C recipes use--repeats 3with two or three variants (SKILL.md:662-665) while telling the user that printed number is "the number every cost estimate below depends on". Say what the number does and does not include. (trigger: src/coder_eval/cli/plan_command.py) (restates: Axis 7: plan's dataset row preview applies no CLI sampler, so the printed "N selected" count can disagree with what run executes)
Display & mapping dicts:
- 🟡
CommandTelemetry.result_statusis a CLOSED domain of four values (Literal["success","error","unknown"] | None, models/telemetry.py:372) and this PR adds two more partitions of it, inline and inconsistent with the three already in the tree: theSkillbranch is an allowlist (!= "success", skill_triggered.py:88) while the file-read branch is a DENYLIST (in ("error", None), line 118), so"unknown"is engagement for aReadand not for aSkill;analysis.py:55-57bucketsunknown/Nonetogether against success/error;reports_html.py:655andcommand_executed.py:173use success-vs-everything-else. A fifth status added to the Literal defaults to "counts as engagement" in the one place that decides a score. Give the closed domain one shared predicate (e.g.delivered_result(cmd)) and make the scoring branch an allowlist like its sibling. (trigger: src/coder_eval/criteria/skill_triggered.py) (restates: Axis 4: skill_triggered still credits engagement from a MENTION of skills// in any Write/Edit/Bash parameter)
Daily/nightly:
- 🟡 The PR adds a SECOND opt-in-only suite to recursive
tasks/discovery, and the Axis-8 finding onci-outcome.yamlexplicitly excuses it — buttasks/skills/lint-tasks-activation.yamlis equally unrunnable by default: it carries noskip: true, mountspath: "$SKILL_SOURCE_PATH", and its own header says "An unset or wrong path is only a warning and leaves every skill unreachable", so with that variable unexported (the default for any contributor or nightly entrypoint that usesdiscover_default_tasks'rglob("*.yaml")) all 28 rows score recall 0, therecall.yes: 0.7gate fails and the run exits 1 after paying for 28 agent rows — a red run caused by ambient environment, not by an agent regression. Both new suites wantskip: true(+--include-skippedin whatever wrapper is meant to run them). (trigger: tasks/skills/ci-outcome.yaml) (restates: Axis 8: tasks/skills/ci-outcome.yaml is committed without skip: true even though it cannot pass against the in-repo plugin) - 🟡 Nothing in the PR states the nightly / cross-repo blast radius of the two behaviour changes, and no artifact carries it either:
--splitand the dropped-row count reach only alogger.warningand aplanconsole line (neverrun.json/suite.json), and the scoring tightening ships with NO version bump (pyproject.tomlstays0.11.0), sorun.json'sframework_version— the only provenance chip in the documented schema, docs/REPORT_SCHEMA.md:52 — is identical either side of the change. The externalcoder-eval-uipathdashboard therefore sees a metric step (moved denominator, or a row flippingyes->no) with nothing in the record able to distinguish a real regression from an instrument change, andreports_junit.py's testcase count silently shrinks under--splitfor CI test-report ingestion. Say in the PR what the nightly does on the first run after merge, and persist the selection + a scoring-semantics marker. (trigger: src/coder_eval/orchestration/task_loader.py) (restates: Axis 8: The partial-labelling row drop reaches no persisted artifact)
Harness & Lint Improvements
Static checks (lint / type):
- [ruff] Enable
PT012(flake8-pytest-style,pytest-raises-with-multiple-statements) in[tool.ruff.lint] select— currently only E/F/I/N/W/UP/B/SIM/RUF + C90 are selected. Measured cost on the tree today: exactly 4 violations (tests/test_cli_empty_glob.py:34and:74,tests/test_dataset_expansion.py:874,tests/test_logging.py:378), so this is a same-day flip, not a campaign. Optionally addPT011(pytest.raiseswithoutmatch=, 34 sites) as a second, slower ratchet — the tautological test's only real assertion was a substring check done after the block, whichmatch=would have forced inside it. Prevents: Finding A3 "the CLI seam test reimplements the handler in-test" —tests/test_dataset_expansion.py:874is one of the 4 PT012 hits:with pytest.raises(typer.BadParameter): try: resolve_all_tasks(...) except ValueError as e: raise typer.BadParameter(str(e)) from e. A multi-statementraisesblock is the mechanical signature of a test that re-raises the exception it asserts, i.e. of a tautology. Still live at HEAD. - [ce-lint] CE067 — a Typer-registered command function must not be called directly from
tests/; drive it throughtyper.testing.CliRunner. Class-wired@pytest.mark.lint(it needs a resolved Typer app plus an AST sweep oftests/, not one.py), reusing the existing readertests/lint/cli_flags.py, which already resolves the app's registered commands and theirOptionInfos — wire it besideTestCE065RowSelectorParityintests/lint_tests/test_lint_harness_meta.py. Detection: collect the command callables registered oncoder_eval.cli.app, then flag anyast.Callintests/**whose func resolves to one of those names.tests/test_cli_set_overrides.py,test_cli_row_selectors.pyand eight other suites already useCliRunner, so the pattern is the majority one; the ~10 directplan_command(...)call sites (tests/test_plan_command.py:77,102,128,159,tests/test_early_stop.py:1092) are the exception the rule closes. Prevents: Findings A1/A2/A6 oncli/plan_command.py— theisinstanceshims that exist only because tests bypass Typer, and which make the declared annotations untrue for the one caller shape that is tested. This has gotten worse since the reviewed PR, not better: HEAD now carries four of them (plan_command.py:339split,:340sample,:342sample_per_stratum,:359experiment). CE047 already covers theassert-as-guard half of the same finding (its docstring namesplan_command.pyexplicitly), so this is the remaining half of that class. - [ce-lint] CE068 — every field in
ROW_SELECTOR_FLAGSmust declare a value-domain constraint:ge/gtfor numeric fields,min_length=1for string fields. Class-wired next to CE065 (same module, same derived source:set(ROW_SELECTOR_FLAGS) == set(RowSelection.model_fields)is already asserted there), readingRowSelection.model_fields[...].metadata. CE065's own docstring declares it "pins DECLARATION and HELP TEXT, never behaviour", so the domain is an explicit hole in the sibling rule rather than an overlap. A fourth selector then cannot land without a domain decision. Prevents: Finding A2/A7 "--splitaccepts an empty string, which the label convention makes unsatisfiable" —row_split_labelmaps""toNone, so no row's label can ever equal"", yet--split ""aborts a labelled suite and silently runs an unlabelled one whole. The numeric siblings already carryge=1/min=1; only thestrselector has no floor. - [ce-lint] CE069 — under
src/coder_eval/criteria/, a scan over a tool call's parameters must key on a declared parameter-name allowlist, not onparameters.values(). AST detection: any attribute chain ending.parameters.values()(and.parameters.items()used only for its values). The intended fix shape already exists in-tree asleak_detection.LEAK_LOCATOR_FIELDS— a named, reviewable set of path-bearing field names — so the rule points at a precedent rather than inventing one. One live site:src/coder_eval/criteria/skill_triggered.py:157. Prevents: Finding A4 "skill_triggered credits engagement from a MENTION ofskills/<name>/in any Write/Edit/Bash parameter".Write.contentis stored untruncated and is scanned whole, so an agent-authored workflow file that merely names a plugin path scoresobserved=yesfor a skill whose body never loaded — a false positive on the exact metric (f1.yes/recall.yes) the activation gate promotes on. CE054 (oneresult_statuscomparison site per criterion module) deliberately does not cover this: its docstring records the denylist-shape mirror as considered and not built. - [ce-lint] CE070 — an in-repo task that default discovery reaches must not gate on a skill it cannot engage. For every
tasks/**/*.yamlreachable bycli/run_helpers.py'sDEFAULT_TASKS_DIR.rglob("*.yaml"): if it declares askill_triggeredcriterion for skillSandplugins/coder-eval/skills/S/SKILL.mdfrontmatter setsdisable-model-invocation: true, the task must declareskip: true. Class-wired over YAML + frontmatter, on the CE052/CE060 precedent (both already reason overtasks/YAML). Derived from the shipped frontmatter, so it cannot rot into an allowlist. Live violation at HEAD:tasks/skills/ci-outcome.yaml(noskip:key anywhere undertasks/skills/; the onlyskip: truein the whole tree istasks/agents/codex_disallowed_tools_test.yaml:12). Prevents: Finding A8 "ci-outcome.yaml is committed withoutskip: trueeven though it cannot pass against the in-repo plugin" — a barecoder-eval runreds after roughly $4.30 and 25 minutes, withrecall.yes: 1.0unreachable by construction. A generic "env var ⇒ skip" rule was rejected as over-broad: 10 in-repo task files reference an env var and the siblinglint-tasks-activation.yamlpasses withSKILL_SOURCE_PATH=plugins/coder-eval, so the frontmatter condition is what actually separates the two. - [ce-lint] CE071 — a module-private helper's defaulted parameter that no call site in the same module ever supplies is dead. Extend CE037's shape (
no dead private helper) with a second violation class, and give it atests/-scoped sweep:BaseRules only seesrc/, and this defect class lives in test fixtures. Detection is module-local AST: for eachdef _helper(..., p=<default>), collect calls to_helperin the file; flagpif no call passes it positionally or by keyword. Note the gap this fills: ruff'sARGfamily cannot see it, because the parameter is used inside the body — it is the call sites that never vary it. Prevents: Finding A1 "deadsplit_fieldparameter on_make_dataset_task" (tests/test_plan_command.py:320) — a knob that restatesDataset.split_field's ownField(default="split")and that no caller turns, which reads as coverage of a configurable field that nothing configures. - [ce-lint] CE072 — one shared reader for dataset-row JSONL under
tests/. Addtests/lint/jsonl.py::jsonl_rows(path)(or exporttask_loader._load_jsonl, which already raises a located error on a malformed line and on a non-object row) and forbidjson.loadsapplied to an element of aread_text().splitlines()iterable intests/**. AST-detectable: ajson.loadscall inside a comprehension whoseiteris a.splitlines()call, or subscripted directly. Live at HEAD: 4 dataset-row sites —tests/lint/outcome_prompt_leak.py:84,tests/lint_tests/plugin_base.py:84,tests/lint_tests/test_lint_plugin_docs.py:162,tests/test_classification_match.py:571. Scope the rule to dataset rows: the threetests/test_litellm_cost_logger.pyhits read a cost log, a different artifact (though they also dropencoding=, which the existingread_text_explicit_encodingrule would catch if its sweep reachedtests/). Prevents: Finding A1 "hand-rolled JSONL reader pasted at 3 new sites (6 total)". The reviewed PR's own file has since been split, which is why the count is now 4 rather than 6 — the class survived the refactor untouched, which is the argument for a gate rather than another cleanup. Note the verifier's correction:load_dataset_rows(dataset, dir)is not the drop-in (it takes aDataset, these sites hold bare paths); the reusable primitive is_load_jsonl(path). - [ruff] Ratchet
[tool.ruff.lint.mccabe] max-complexityfrom 30 to 29 now — measured: withmax-complexity = 28the tree is clean, and the single binding function is_build_argvat 28 (src/coder_eval/isolation/docker_runner.py:1279). The comment atpyproject.toml:242-243already names that constraint; this makes the ratchet actually tight against it (the CLAUDE.md rule is "one above the worst function", and today it sits two above). Prevents: Finding A5 "the new except-branch lands insideresolve_all_tasks's 240-line try block" — partially, and the measurement matters for scoping the fix: mccabe scoresresolve_all_tasksat 19, not radon'sE (37), so the C901 ceiling can never fire on it at any plausible setting. The ratchet is worth taking on its own merits; the actual finding needs the statement-cap rule below. - [ce-lint] CE073 — a
try:body undersrc/coder_eval/orchestration/may not exceed N statements (set N as a ratchet one above the current worst, same discipline asmax-complexity). Precedent in-tree: CE022 is already a statement cap (ce022_dialog_loop_statement_cap.py), so this is that rule's shape one directory over, and it is aBaseRule(one.py, one AST) wired intests/lint/runner.py. What it forces is the fix the finding asks for: atry:coveringload_task+ theskip: trueshort-circuit +expand_datasetunder fourexceptarms with two different policies (re-raiseSplitSelectorError, demote(FileNotFoundError, OSError, ValueError, yaml.YAMLError)) has to become a_load_and_expand(...)helper that owns those policies. Prevents: Finding A5 onorchestration/experiment.py:637— a fourth exception arm bolted onto a try block long enough that the two exception policies inside it are no longer readable together, in the tree's second-worst function by radon. - [ce-lint] CE074 — every
LiveSuccessCriterionsubclass must declare the event seam its verdict is decidable at, the same way it already declares polarity. TodayBaseSuccessCriterion.live_decidable_polarities()(models/criteria.py:306, overridden at 1078 and 1215) is a real declaration point thatEarlyStopWatcherreads and CE036 covers; the seam is prose only (orchestration/early_stop.py:420-426). Class-wired via runtime subclass introspection over the criterion registry, beside CE036. If a declared method is judged too heavy, the honest alternative is to narrow the docstring to say the seam is expressed solely by returningundecided— but then say so, rather than requiring future authors to "state" something with no place to state it. Prevents: Finding A5 "a new per-criterion contract dimension is documented in prose only, next to an existing declared mechanism for the analogous dimension" — a requirement placed on every future member of an extension point, with nothing to carry it. - [ce-lint] Make
skill_name's schema description DERIVED from the gate constants (CE040's "derived rather than respelled" pattern, and CE042/CE062's one-declaration family): move_FILE_READ_TOOLSto a cycle-free leaf bothmodels/criteria.pyandcriteria/skill_triggered.pycan import, interpolate the gated tool names into theField(description=...), and pin with a lint-marked test that the rendered description names exactly the gated tools and the ungated exception (Bash). CE033 already regeneratesplugins/coder-eval/reference/criteria.mdfrom that string, so making the description derived fixes the schema SSOT and the shipped plugin reference in one move. Claim the next free id after the above (verify first withgrep -rhoE "CE[0-9]{3}" tests/ .claude/—runner.py's uniqueness assert coversALL_RULESonly, and CE049/CE055/CE056/CE061-CE065 are reserved, CE044 retired). Prevents: Finding A7 "skill_name's description says engagement requires a successful file read, contradicting the deliberate ungating ofBash" — wrong in the one surface plugin users read, whiledocs/TASK_DEFINITION_GUIDE.mdgets it right. A derived string cannot describe a gate the code does not enforce. - [ce-lint] No new rule for four of the findings — they are already mechanically enforced; spend the effort on the boundaries those rules declare instead. Recorded so a green
make lintis not mistaken for a proof: the Rich-markup injection finding (A4,plan_command.py:228) is CE050; the missingplan --splitrow indocs/USER_GUIDE.md(A7) is CE046; the duplicated/divergent--splithelp literal and the CLI-sampler parity gap (A7/A8) are CE065 +cli/row_selectors.py; the untested_FILE_READ_TOOLSmembers (A3) are now derived (tests/test_skill_triggered.py:251parametrizes oversorted(_FILE_READ_TOOLS), and:381/:394pin it against the Codex and antigravity tool maps). The two residual holes each rule states about itself are worth closing: CE050 never resolves a name to its value, somsg = f"[red]{e}[/red]"followed byconsole.print(msg)is invisible to it (add the one-hop follow in the unsafe direction, mirroring theescape(...)-assignment hop it already does); and CE065 "pins DECLARATION and HELP TEXT, never behaviour", soplancould accept--sampleand ignore it — which is what the preview-parity test below must cover. Prevents: Findings A4 (markup), A7 (USER_GUIDE flag row), A7/A8 (help-text duplication, sampler parity), A3 (_FILE_READ_TOOLScoverage) — all landed since the reviewed PR. The proposal is the delta: the two declared blind spots, which is where the same defect classes will reappear.
Harness improvements (not statically reachable):
- A determinism guard on
plan, and a preview-parity test that covers the YAML-declared sampler. Two assertions: (1) two consecutiveplaninvocations on a dataset carrying an unseededdataset.sample_per_stratummust print the same selected count and return the same exit code; (2) extendtests/test_cli_row_selectors.py's preview-parity test — the test CE065's docstring names as the behaviour cover — with a suite whose sampler comes from YAML rather than from a flag. Fix the surface too: the count line should report the pre-sampling size (or state that sampling is redrawn at run time), sinceexpand_datasetappliesds.sample_per_stratumwithds.sample_seed(nondeterministic by default) and validates${row.*}over only the drawn rows. Why not static: The defect is a property of an RNG draw at run time: no AST or type check can see that two identical invocations select different rows. Reproduced by the verifier — 12 identicalplaninvocations on a 4-row stratum with one unsubstitutable row gave exit 0 seven times and exit 1 five times, viaexpand_dataset's own substitution validation reaching the outerexceptatplan_command.py:226-229. Prevents: Finding A3/A7 "plan's dataset preview has no test fordataset.sample_per_stratum" — a pre-spend surface whose printed row count, validated row set and exit code are all nondeterministic for an unchanged repo, and which the shippedoptimize-skillSKILL.md tells the user is "the number every cost estimate below depends on". - One cross-surface error-policy parity test for an unmatched
--split, driven throughCliRunneron BOTH commands, asserting each surface's documented outcome (run→ non-zero viatyper.BadParameter, exit 2;plan→ exit 1 with the per-file error) and the splits-present message in both. Then delete the in-test mirror of the handler attests/test_dataset_expansion.py:864-883, and fixSplitSelectorError's docstring (task_loader.py:52), which claims one CLI behaviour while the second consumer demotes it to a per-file error with a different exit code.tests/test_plan_command.py:383is the in-repo precedent for asserting the exit code. Why not static: Both halves are behaviour: which handler catches an exception and what exit code the process returns are only observable by invoking the CLI. A lint rule can see that the docstring and the code exist; it cannot see that they disagree about an exit code. Prevents: Findings A3 ("the CLI seam test's assertion is a tautology and its exit-code comment pins nothing") and A5 ("SplitSelectorError's docstring claims the CLI converts it to typer.BadParameter, but plan demotes it") — one test closes both, and it is the test the dangling comment was written for. - Persist the row-selection accounting into an artifact a consumer parses. Add the pre-selection dataset size and the unlabelled-drop count (e.g.
rows_available/rows_dropped_unlabelled) besideSuiteRollup.rows_total/rows_excluded, document both indocs/REPORT_SCHEMA.md, and add a run-level test on a partially labelled suite +--splitasserting the drop reachessuite.json.RunSummary.row_selectionhas landed since the reviewed PR (it records which selectors ran), so the remaining hole is the denominator: at HEAD the drop exists only aslogger.warning(... DROPPED ...)attask_loader.py:560and aplanconsole print. Why not static: The counts do not exist until a run expands a dataset — there is nothing in the tree for an AST check to read. CE060 covers the in-repo half only (tasks/**must be fully labelled or not at all) and its own docstring concedes the gap in the same words: "Nothing in the output says how many rows went missing." Datasets underplugins/coder-eval/reference/templates/and in user repos are outside every CE. Once the field exists, CE030-style doc-schema parity can keep it documented — that half is static. Prevents: Finding A8 "the partial-labelling row drop reaches no persisted artifact" — the failure it describes is an external dashboard reading a 50-point pass-rate drop with nothing in either artifact distinguishing a regression from a quadrupled denominator. - A scoring-invariance test for duplicate
includes:includes=["a","a"]must score identically toincludes=["a"], in both the found and not-found cases (tests/test_file_check.pycurrently covers 1, 2 and 5 distinct includes only). Consider also widening CE064's computed-claims surface set to task-YAML comment blocks, so a numeric claim written next to a suite gate is checked by computing it. Why not static: The claim is arithmetic over the scorer's behaviour (found / totalincriteria/file_check.py:71-74, no dedup), not over text: a static check can locate the sentence but cannot evaluate whether a scoring change made it false. Prevents: Finding A3 "the committed outcome suite's duplicate-includes scoring claim has no assertion anywhere" — nine of ten rows inci-outcome-rows.jsonlsetexpected_snippet_2 == expected_snippeton the stated ground that duplicates are score-neutral, and themean: 0.7gate's variance depends on it staying true. - Exercise the live-criterion seam boundary in CE036's replay: add a
ContractCasetotests/_fixtures/live_criteria.pywithresult_status=None(in-flight) and one with a force-closed"unknown"call, since the fixture defaults to"success"at line 33 and therefore no contract case reaches the seam the new prose paragraph is about. Why not static: The seam is defined by whatlive_verdictreturns when it is handed a partial event stream; that requires replaying a stream, which no static check can synthesize. Prevents: Finding A5 onearly_stop.py:425— the runtime half of the same finding whose declaration half is CE074 above. It is also the exact status value that produced CE054's original false positive (a crash force-closing aReadto"unknown"), so the boundary has already bitten once. - A default-discovery guard in CI: run
coder-eval plan(zero-cost) over the full default discovery set —DEFAULT_TASKS_DIR.rglob("*.yaml"), which repo CI's non-recursivetasks/*.yamlglob never touches — and fail if any discovered suite is unsatisfiable with in-repo assets and not markedskip: true. Pair it with the printed cost estimate so the spend a barecoder-eval runwould incur is visible in CI output. Why not static: "Can this suite pass at all?" depends on resolving template sources, mounted fixtures and environment, i.e. on running the resolver — CE070 above catches only the one shape (disable-model-invocation) that is visible in committed frontmatter. Prevents: Finding A8 ontasks/skills/ci-outcome.yaml— the discovery/CI divergence is the root cause:.github/workflows/pr-checks.ymlusestasks/*.yaml --tags smoke-*, so nothing in CI ever exercises the set a contributor's barecoder-eval runactually resolves. - A false-positive golden for
skill_triggeredengagement: replay a transcript whose onlyskills/<name>/mention is insideWrite.content(and a second whose mention is inside aBashcommand string) and assertobserved="no", plus keep the existingcat skills/x/SKILL.md | grep foocase assertingyesso the deliberateBashungating stays covered. The two cases together are what pin the intended behaviour that CE069's shape check cannot express. Why not static: CE069 forbids the over-broadparameters.values()fan-out, but the scoring consequence — whether a given transcript yieldsyesorno— is a runtime property of the checker over an event stream, and it is a metric the activation gate promotes on, so it needs a value assertion rather than a shape assertion. Prevents: Finding A4 "skill_triggered still credits engagement from a MENTION ofskills/<name>/" — verified live at HEAD (skill_triggered.py:157), and the same falseyesthe rest of that diff was written to remove.
Top 5 Priority Actions
- Restrict the path scan in
src/coder_eval/criteria/skill_triggered.py:118to plausible read-target parameters (a_PATH_PARAMSallowlist, mirroringLEAK_LOCATOR_FIELDS) and make the status test an allowlist ("success"/"unknown"), because today aWritebody orecho skills/<name>/scoresobserved="yes"with the skill body never loaded — a false positive that directly satisfiestasks/skills/ci-outcome.yaml'srecall.yes: 1.0gate for identical agent output. - Add
skip: truetotasks/skills/ci-outcome.yaml:41, sincecli/run_helpers.py:41discovers it throughrglob("*.yaml")and the suite cannot pass without the hand-preparedSKILL_SOURCE_PATHcopy its own header documents, so any argument-freecoder-eval runfails the gate and exits 1 after roughly $4.30 of agent time. - Parametrize the three gate tests at
tests/test_skill_triggered.py:146/156/177oversorted(_FILE_READ_TOOLS)soGlobandGrepare derived rather than sampled —src/coder_eval/criteria/skill_triggered.py:52is a scoring-relevant closed set that no test touches, and antigravity'sfind_file -> Glob/search_directory -> Greprename (agents/antigravity_agent.py:152-154) means a dropped member reintroduces the exact falseyesthis PR removed, with the suite green. - Persist the row denominator the warning at
src/coder_eval/orchestration/task_loader.py:540only logs — addrows_available/rows_dropped_unlabelledtoSuiteRollupand a row-selection block toRunSummary, documented indocs/REPORT_SCHEMA.md— so a downstream dashboard can tell a real regression from a denominator that changed when rows stopped being dropped. - Make
planhonest about whatrunwill execute: either accept--sample/--sample-per-stratumand pass all three selectors atsrc/coder_eval/cli/plan_command.py:29, or drop the "every cost estimate depends on" claim (line 19 andplugins/coder-eval/skills/optimize-skill/SKILL.md:45-47) and label the count CLI-sampler-blind; in the same pass wrap the error sinks at lines 221/224/228 inrich.markup.escape(), because one dataset row containing[/red]raisesMarkupErrorfrom inside the handler and aborts validation of every later task file.
Stats: 0 🔴 · 0 🟠 · 6 🟡 · 14 🔵 across 8 axes reviewed.

PR 3 of 3 extracted from #109. Based on
pr/split-execution-track(#130), notmain, sothe diff above is its own slice rather than a cumulative one. Merge order is #129 → #130 → this.
Afterwards #109's remaining work is Waves 2-4 (PRs 4-12); it stays open as the reference tree.
This is the behaviour-carrying PR of the three. Please read the left column closely.
Review map
.github/CODEOWNERSmaps*to four owners with no per-path rules, so the repo cannot route thebehaviour half of this PR to a specific reviewer. This table is the routing.
criteria/skill_triggered.pyplugins/coder-eval/reference/optimize-method.md(new, 205 lines)models/criteria.pydocs/tutorials/08-optimizing-a-skill-description.md(renamed)orchestration/early_stop.pydocs/tutorials/09-…,07-…,04-…orchestration/task_loader.pyplugins/coder-eval/skills/optimize-skill/SKILL.mdcli/plan_command.pyplugins/…/templates/*.yaml,tasks/skills/ci-outcome*orchestration/experiment.py,cli/run_command.pyCLAUDE.md,docs/DATASETS.md,docs/PLUGIN.mdThe four fixes
All four were found by running the optimize-skill method against this repo's own skills.
skill_triggered's live and final verdicts agree again (d3d0f5b). The live verdict nowstays undecided until
ToolEndEvent, because the skill body is delivered as the toolresult — deciding at
ToolStartEventlatched a verdict the final check then contradicted. Thistouches
EarlyStopWatcher's per-criterion decision seam; the reason is in the docstring andtests/test_early_stop.pycovers it. Please do not simplify it back to a global rule.--splitthat matches nothing aborts instead of exiting 0 (2483f5a). A labelled taskwith no matching row raises
SplitSelectorError, andresolve_all_tasksre-raises ratherthan demoting it to
skipped_tasks: it describes a malformed invocation applied to everytask, not a malformed file. Previously an all-skipped run exited 0 and rendered a report over
zero rows.
38efbce). Validating afterselection meant a duplicate or malformed id in an unselected row stayed invisible until someone
changed the selector.
planexpands datasets and previews whatrunwill execute (b23b6a1). It takes allthree row selectors, names which selector narrowed the set rather than re-deriving the
win-order, prints a per-stratum breakdown, and warns on partial labelling and on an unseeded
stratified draw.
Two corrections applied during the split
itself introduces:
CE061_LOCATOR_FIELDS,CE061_MIN_LEAK_CHARSandtest_ce061_exemption_list_matches_claude_md. That test binds the constant to aCLAUDE.mdsentence in both directions, so the constant and the prose have to move in one edit — a
half-applied rename fails it either way. PR2's collision guard is what proves the renumber
survived this rebase.
cli/plan_command.pysaid the splitconvention is shared with
CE035— the branch's number for it. That file arrives in this PR,so PR2 could not have renamed it, and the plan's edit list did not list it. A whole-tree sweep
found it; the file list would not have.
_load_dataset_rowsbecomes the publicload_dataset_rows.cli/plan_command.pyimportsit across a module boundary, and a leading underscore on a cross-module import is a claim the
code does not honour. Six call sites, one signature, no behaviour change —
pyrightreports0 errors.
Notes for reading the diff
docs/tutorials/08-optimizing-a-skill.mdis renamed, not rewritten, to08-optimizing-a-skill-description.md— it optimizes a skill's description, and tutorial 09optimizes its body.
mkdocs.yml,docs/llms.txt,docs/tutorials/README.md,docs/PLUGIN.mdand
07-…mdmove with it, and CE028 gates those index surfaces. If GitHub renders the 789-linefile as delete+add rather than a rename, try
?w=1.plan_command.pyalso importsexpand_datasetandrow_split_label; both were already public.Only
_load_dataset_rowsmoves.Verification
ruff format --check(376 files),ruff checkclean,pyright 0 errors, 0 warnings, and4802 passed, 0 failed — including
test_ce061_exemption_list_matches_claude_md(the two-waybinding) and the collision guard from PR2, which was run first after the rebase precisely
because resolving its conflict the wrong way would have reintroduced the id collision green.