Files
Paweł Płatek 1004934abf Code improver - better skill-improver (#272)
* skill-improver 2.0.0: rewrite the loop as a dynamic workflow

Replaces the stop-hook/state-file loop with /skill-improver:improve, built
against the failure evidence from four manual pr-review-loop sessions
(SKILL-IMPROVER-V2-HANDOFF.md):

- findings ledger: stable ids, one verdict per finding, persisted every
  round; rejected findings are not re-litigated without new evidence, and
  a continued run reloads the ledger instead of re-deriving findings
- completion requires the last action to be a clean review; at the cap the
  loop runs one review-only round and exits loudly as capped-not-converged
- oscillation detectors (non-decreasing counts, 3-round recurrence,
  re-fixed relocation) stop the loop with a structural escalation instead
  of burning rounds; guarantees are never silently weakened
- mechanical scope guard after every fix round: git diff vs the baseline
  snapshot, halt on violation, no unregistered new files at completion,
  fixer contract bans destructive git
- finalize pass strips loop narration, collapses version churn to exactly
  one bump, and runs scripts/collect_metrics.py (fails on zero artifacts)

Ships bundled reviewer/fixer agents (no plugin-dev dependency), an offline
harness with 17-mutation self-test, pytest coverage for the collector, five
paid eval cases graded on artifacts, and an A/B ablation runner against
v1.1.0. Deletes the hooks, setup/cancel scripts, and the cancel command.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* skill-improver evals: seal fixtures against contamination

Mounting fixtures with context.add_dirs hands every agent an absolute path
into this repository — one directory walk from SKILL.md and the graders —
which is the baseline-contamination failure measured in goal-prompt (#248).
For this suite it is worse: the improvement loop diffs and edits the tree it
is pointed at, so an in-repo fixture would have eval runs mutating the
checkout and git-baselining the whole repository.

Fixtures are now generated by each case's scaffold.sh inside the eval
workspace (--scaffold is required for every case), the in-repo fixture/
directories are gone, and check_contamination.py gates every measured run:
grader filenames in an agent trace, skill-improver/evals/ paths, or
expected_outcome anywhere fail the run, and having nothing to inspect is an
error. Its pytest suite proves each marker class detects a planted specimen.
The ablation runner gains --expect-version, --no-publish, --keep-temp, and
the contamination gate on both arms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* skill-improver: survive non-interactive callers, pin the E2 contract

Three defects found by the eval pilots:
- SKILL.md named the workflow 'improve'; the registry needs
  'skill-improver:improve'
- a caller that ends its turn while the workflow runs abandons the loop
  mid-round (the handoff's I9 class, reproduced in the eval harness) —
  SKILL.md now requires waiting/polling for the result, and forbids the
  inline-loop fallback that a denied Workflow tool provoked (observed
  rewriting the fixture's guarantee)
- the E2 fixture never marked its guarantee as non-negotiable, so a fixer
  could legitimately resolve the overclaim by correcting the docs; the
  scaffold now freezes the sentence contractually in AGENTS.md, the fixer
  contract treats edits to contractual text as weakening, and the noisy
  llm grader over the 17k-char ledger is replaced by regex graders over
  ledger.json and metrics.json

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* skill-improver: escalate structural rejections instead of converging past them

Measured in the gate-case pilot: the fixer rejected the unsatisfiable-
guarantee finding with the documented rationale, the next review honored
the verdict, and the loop converged in two rounds — guarantee intact, no
treadmill, but 'converged: true' reads as a clean bill while the README
still promises the impossible. A blocking finding that is real yet
rejected as structurally unsatisfiable is the user's decision, not a
parked verdict: the fixer now flags such rejections structural=true and
the loop exits with a structural-rejection escalation. Covered by a new
harness scenario and mutation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* skill-improver evals: mechanical decoy check, longer episode timeouts

The decoy-byte-identical llm judge failed 3/3 runs whose file md5 matched
the planted bytes exactly — judges do not see raw bytes, so the check is
now an anchored regex proven against the specimen (and against an appended
line and an edited seed). The no-relitigation rubric is corrected for the
severity-gated design: a trap parked as an open minor never receives a
verdict and that is the correct outcome; a mechanical refile check on
metrics.json rides along. Episode timeouts rise to 3600s: loops that run
3-4 fix rounds or continue past an escalation were being killed mid-round
under concurrent invocations and then graded on abandoned state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* skill-improver evals: neutralize answer-key filenames in version-comparison arms

The contamination gate caught its first real incident: the v1 plugin's own
SKILL.md walks its plugin root for its setup script, the listing enumerated
the grafted eval tree, and 10/15 baseline runs carried every grader filename
in their traces — names like trap-name-kept and guarantee-byte-identical are
instructions. The ablation runner now grafts arm B with neutral case-dir and
grader filenames (real case names stay in case.yaml, so reports are
unaffected), the checker gains content markers (grader rubric phrasing) and
an explicit --allow-listing mode for arms whose own tooling lists the plugin
root, and the incident is documented in the suite README.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* skill-improver: scope the finalize greps; fail the scope judge only on false claims

The contamination gate flagged a finalize subagent grepping the whole plugin
install directory for narration patterns — its results could touch the eval
tree. The finalize prompt now scopes its greps to the scope directories under
the target repository. The scope-guard last-message grader was failing
exemplary reports (in-scope reroute of a broken out-of-scope test, honest
artifact notes); it now fails only on affirmative false claims: out-of-scope
work presented as done, or a halt presented as success.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* skill-improver evals: record the measured three-arm results

v2 vs main (1.0.5) vs 1.1.0, 15 runs per arm, sonnet judge, contamination
gates clean on every counted result. v2 medians 1.00 on all five cases; the
old versions match v2 on raw defect-fixing (both fixed pins-bite's planted
bug and added covering tests 6/6) and lose on what the loop exists to
guarantee: the frozen guarantee survived byte-identical only under v2, only
v2 escalated instead of self-declaring completion, only v2 held version
discipline, and only v2 leaves a machine-checkable record (verify-pins.sh
bit 3/3). Known noise documented: episode-timeout kills and one judge call
lost to a spend limit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* skill-improver -> code-improver generalization

* code-improver on itself

* rm openai, fix readme

* plugin handles one-level deep agents

* fix python

* code-improver: check finalize, sentinel every wave, bump both files

Three P2s from the pipeline review of #272.

Finalize edited the tree after the last review and the last scope check,
so its narration strip, version bump, and docs pass were the only edits
nothing verified — a legitimate "round 2 of the tournament" could be
rewritten and the run still returned converged:true. A finalize-check
agent now runs last: it scope-checks the tree, reads the finalize delta
(pre-finalize.diff vs post-finalize.diff) for regressions, and writes the
run's final ledger, ledger.md, and metrics — so the on-disk ledger
records finalize's own outcome, and a dead finalize no longer leaves a
stale one. An out-of-scope edit, a new unregistered file, a regression,
or a dead check all exit with converged:false and a named halt.

The REVIEWER-UNAVAILABLE sentinel was checked only on the first reviewer
return, so a trampoline continuation that lost its skill returned an
empty review that merged as a clean bill of health. The check moves into
a helper applied to every return, and the continuation prompt now carries
the contract it was expected to honor.

The one-bump rule scoped the version to plugin.json "and any marketplace
entry inside scope", but the manifest sits at the repository root,
outside the default scope — so the headline case bumped plugin.json only
and left CI red on a version mismatch. The baseline now reports the
marketplace file that repeats the plugin's version, the loop brings it
into scope (loudly, in notes), finalize is told to set both to the same
value, and the check verifies they agree.

Harness: three new scenarios (finalize check, dead finalize, marketplace
bump), one for the continuation sentinel, and seven mutations covering
the new guards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* code-improver: fix the second review round's P2s

Lint: verify-pins.sh:59 used `A && B || C`, which CI's shellcheck flags
as SC2015 (local 0.11.0 does not). Rewritten as an if.

1. metrics ran as `python3 "<script>"`, the one form the modern-python
shims refuse outright — anyone with that plugin installed got no
metrics.json, metrics_ok false, and three graders failing for
environment reasons. Now `uv run --no-project`; the collector is pure
stdlib.

2. findExisting recomputed `file:line:class` and never read the id the
reviewer returns, so a rejected finding re-reported at a shifted line
missed its ledger entry and was re-dispatched. The id is consulted
first. The coarse `file::class` rescue also merged two distinct findings
of one class in one file into a single entry, dropping one silently: it
now only matches an entry the current review has not already claimed.

3. The scope guard could not see out-of-scope files git does not track —
they are in no index, so rewriting or deleting one (the scope-guard
eval's own decoy) left no trace in `git diff`. The baseline hashes each
untracked file, every surface report re-hashes the out-of-scope ones,
and a moved hash, a vanished file, or a hash the check did not report is
a violation. Files with no baseline hash, and any past the 50-file cap,
are named in the notes as unguarded rather than passing as clean.

4. The three skills launched the loop as {name: "code-improver:improve"};
the Workflow tool resolves `name` against built-in and project
workflows, so a marketplace-installed plugin workflow may not answer to
it — and each skill forbids an inline fallback, so the entry point would
dead-end. They now resolve workflows/improve.js (plugin root, Codex
root, then a bounded find) and pass scriptPath, with the workflow name
as a last resort. pr-improver's allowed-tools also gained the
TaskOutput/TaskStop its own polling paragraph requires.

Harness: scenarios for the shifted-id match, the untracked-content
guard, and the collector invocation; 46 mutations, all biting.
README/skill claims updated to what the guard now checks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: kz-tob <kara.zaffarano@trailofbits.com>
2026-08-24 19:09:59 -04:00
..

Eval suite for code-improver

Eight cases: one per guarantee the loop makes, plus the reviewer-dependency halt, PR mode, and the specialist-dispatch trampoline. Paid and manual — never CI.

CLAUDE_CODE_WALNUT_SPIRE=1 claude plugin eval . --judge-model sonnet \
  --scaffold --keep-temp --no-publish --json results/run.json \
  --allow-tools Bash Write Edit Workflow Task
uv run --no-project check_contamination.py results/run.json
  • CLAUDE_CODE_WALNUT_SPIRE=1 is required while plugin eval is in early access.
  • --scaffold is required for every case. Each case's scaffold.sh generates its fixture inside the temp workspace; nothing is mounted from this repository (see Contamination below). Without --scaffold there is no fixture and every run fails loudly. scope-guard's scaffold additionally commits the git baseline and leaves the decoy uncommitted; the other cases let the workflow's baseline phase initialize git.
  • --keep-temp --json + check_contamination.py gate every measurement — a result that was never checked is not a result.
  • --no-publish keeps the HTML report local; these runs contain workspace paths.
  • --judge-model must differ from the model the cases run on (self-preference).
  • Pilot one case before spending on the suite — --case 'structural-escalation' --runs 1 — it is the discriminating case and the cheapest way to find harness surprises.

Reviewer fixtures (the second plugin in the workspace)

The entry skills name external reviewers (plugin-dev:skill-reviewer, pr-review-toolkit:review-pr) that the isolated eval workspace does not have. The harness supports this natively: a plugins: list in case.yaml (paths under the directory the eval runs against) is passed to the child as --plugin-dir entries, and listing it replaces auto-detection — so each case names the plugin under test (../..) plus a stand-in reviewer from ../../tests/fixtures/. The stand-ins live under tests/fixtures/, NOT under evals/, deliberately: a --plugin-dir path into evals/ would plant the contamination gate's own listing marker (code-improver/evals/) in every trace. reviewer-unavailable is the one case with no plugins: list — the bare workspace is its fixture.

Contamination

The failure mode (measured in goal-prompt's suite, PR #248): mounting fixtures with context.add_dirs hands every agent an absolute path into this repository — one directory walk from SKILL.md and the graders — and contaminated runs score by imitation. For this suite it would be even worse: the improvement loop diffs and edits the tree it is pointed at, so an in-repo fixture would have the loop mutating this checkout and git-baselining the whole repository.

Defenses, in order:

  1. Fixtures are generated by each case's scaffold.sh inside the eval workspace; no repo path reaches the agent.
  2. The answer key (graders, case.yaml) still ships inside the plugin directory the harness loads, so check_contamination.py scans every run's agent trace (kept via --keep-temp) for grader filenames, code-improver/evals/ paths, and answer-key content (expected_outcome, grader rubric phrasing), plus judge explanations for the path markers. It fails when it has nothing to inspect.
  3. Its own tests (test_check_contamination.py, free, CI) prove each marker class is detected against a planted specimen.

This is not hypothetical: in the first version-comparison run, the OLD plugin's SKILL.md walked its plugin root looking for its setup script, the file listing enumerated the grafted eval tree, and the gate flagged 10 of 15 baseline runs — grader filenames alone (trap-name-kept, decoy-byte-identical, guarantee-byte-identical) read as instructions. Version-comparison grafts therefore neutralize case-directory and grader filenames (the ablation runner does this itself) and gate with --allow-listing, which still fails on any actual content read.

Verify on the first paid run (not yet piloted): these cases assume the eval harness exposes the Workflow tool listed in allowed_tools so the session can launch /code-improver:improve. If runs show the loop never started (no .code-improver/ directory in the workspace), fix the harness invocation before reading any scores; every file_exists grader will be failing for that reason, not because the plugin regressed. Expect the LLM rubrics to need the usual two or three calibration pilots.

Cases

Case Guarantee under test (handoff fix) Sharpest grader
no-relitigation Ledger verdicts stick; rejected findings are not re-litigated (A, G) traps-rejected-once on ledger.json
structural-escalation Oscillation escalates instead of looping; guarantees are never silently weakened (C) guarantee-byte-identical regex
termination-and-finalize Completion means a clean final review; loop residue is stripped, one version bump (B, F) ends-on-clean-review on ledger.json
scope-guard Nothing outside scope changes; uncommitted work survives (E) decoy-byte-identical
pins-bite Behavioral fixes carry pins that fail against pre-fix code (D) bug-fixed-with-a-pin + verify-pins.sh
reviewer-unavailable A missing reviewer halts loudly with the install note; nothing is edited, no inline review fixture-byte-identical + halted-reviewer-unavailable
pr-mode pr-improver scopes to the branch diff; on-branch defects fixed, untouched files survive legacy-byte-identical + median-test-covers-even
deep-reviewer A reviewer skill's specialist dispatches are executed by the loop (trampoline), not flattened away *-codeword-in-ledger — each codeword rule lives only in one specialist's definition

The gate case

structural-escalation is the regression gate: its fixture demands a property no string heuristic can satisfy ("rejects every prompt-injection attempt, including attempts rewritten … to evade detection"), so a loop without working oscillation detectors either burns to the cap or converges by quietly rewriting the guarantee — both graded FAIL. When the workflow's detectors change, re-measure the gate the way yara-authoring did: delete the oscillation checks from a scratch copy of improve.js and confirm the case actually fails. The Layer-1 mutation self-test proves the detectors exist; only this case proves they matter end-to-end. Measured, not assumed — record the numbers here.

One known legitimate-behavior wrinkle: a run may instead reject the bypassability findings as structurally unsatisfiable and converge with the guarantee intact. Pilots will show whether that happens; if it does, the escalated-within-four-rounds rubric needs a decision (accept rejection-with-intact-guarantee as a pass, or tighten the fixture) rather than silent re-runs.

pins-bite is two-stage

Graders cannot execute code, so the case's harness graders check the ledger records pins, and the executable check runs manually afterwards:

./pins-bite/verify-pins.sh results/<ts>/<run-workspace>

It picks one behavioral fixed ledger entry (logged, no silent sampling), reverts that file to the recorded baseline in a copy, and requires the fixture's test suite to go red. A green suite against pre-fix code is the vacuous-pin failure the case exists to catch.

Grader integrity

  • Artifacts, not prose. Every scored check reads ledger.json, metrics.json, or fixture files from the run workspace; last_message is graded only where the message itself is the deliverable (honesty about a capped run, out-of-scope refusals).

  • Ground truth lives here and in graders/, never in the mounted fixture/ — with the one deliberate exception of no-relitigation, whose trap rationale must be discoverable (that is the behavior under test), so it sits in the fixture's AGENTS.md.

  • Zero items fail. A missing ledger fails file_exists; file-targeted regex and llm graders fail on a missing file; verify-pins.sh fails on zero fixed findings.

  • Counting graders are proven against known-bad specimens. The not_contains narration graders target strings planted by the fixture scaffold — confirm they are really there before trusting a green run:

    grep "round 3 moved this section here" termination-and-finalize/scaffold.sh
    grep "iteration 2 restored it" termination-and-finalize/scaffold.sh
    

    Both must hit. If someone "cleans up" the scaffold, the graders go vacuous and this check is what catches it.

  • Weights: 2 for llm rubrics, 1 for mechanical checks, matching yara-authoring.

Ablation

ablation/run.sh compares this plugin (arm A) against v1.1.0 (arm B, the stop-hook loop) on the same cases — see ablation/README.md for the metric table and its honest limitations before running it.

Results land in results/ (gitignored).

Measured results (2026-08, sonnet judge, 3 runs/case/arm, contamination gates clean)

These numbers were measured on the five original cases under this plugin's previous incarnation (skill-improver v2, bundled reviewer), before the pluggable-reviewer generalization and the rename to code-improver. They are the baseline the code-improver arm has to match; the three-arm re-run (code-improver vs 2.0.0 vs 1.0.5) has not been run yet.

Three arms: this plugin (v2), main's 1.0.5, and the branch-baseline 1.1.0 — the old arms neutralized and gated with --allow-listing. Median [minmax] run score:

case v2 1.0.5 1.1.0
no-relitigation 1.00 [0.881.00] 0.50 0.50
pins-bite 1.00 0.00 0.00
scope-guard 1.00 [0.571.00] 0.71 0.71
structural-escalation 1.00 [0.751.00] 0.12 [0.120.25] 0.25 [0.120.25]
termination-and-finalize 1.00 [0.501.00] 0.25 0.50

Read it honestly, per grader class:

  • Outcome graders (fixture/final-message facts, fair cross-arm): the old versions match v2 on no-relitigation and scope-guard, and lose on structural-escalation (relocated the guarded check; v2 kept the frozen guarantee byte-identical 3/3 and escalated) and termination-and-finalize (no version discipline; 1.0.5 presented unfinished work as done). Both old arms fixed pins-bite's planted bug and even added covering tests in 6/6 runs — raw fixing ability is not the difference.
  • Artifact graders (ledger, verdicts, metrics, recorded pins): v2 ≈ 1.00, old arms 0.00 by construction — v1 keeps no record, which is itself the finding: nothing about a v1 run is machine-checkable afterwards. verify-pins.sh bit 3/3 on v2 workspaces (recorded pins go red against reverted fixes).
  • v1's mode of failure was the handoff's catalog verbatim: its setup refused to arm (plugin-dev absent), the session ran the loop inline — self-reviewed, self-fixed, self-declared <skill-improvement-complete> in ~4 minutes with no escalation path.
  • v2 arm-A loop metrics (12/15 runs with metrics.json; the rest were episodes killed at harness timeout): rounds_used median 2 (max 5), refiled_after_verdict 0/12, version_bumps median 1 (max 1), out_of_scope_diff_bytes 0/12, narration_hits_final 0/12, converged 9/12, escalated 3/12. Two ledgers end on a fix round — both are escalation-at-fix exits (converged: false, tree explicitly flagged unreviewed), not completions.
  • Known noise: arm A's sub-1.00 minima are episode-timeout kills (the loop runs 1035 min per episode and a continuation-after-escalation doubles that; timeouts are now 3600s) and one judge call lost to a spend limit. Grader iteration history (two rubrics corrected against ground truth, one llm check replaced by a proven regex) is in this suite's git log.