Replaces Claude Code's compaction and goal handling with Codex's, then fixes the part Codex gets wrong.
Claude Code compacts by replacing the conversation with a summary. Everything you typed becomes a paraphrase of what you typed, and every tool call becomes a sentence about a tool call. Codex does it differently: it keeps user messages verbatim, summarizes each turn together with that turn's own tool calls and results, and spends a fixed token budget doing it.
Bandaid ports that behaviour onto Claude Code through its hook system.
Claude Code compaction Bandaid compaction
────────────────────── ──────────────────
[ summary of everything ] [ Codex handoff summary ]
[ every user message, verbatim ]
[ per-turn tool digests ]
The concrete failure this fixes: you say "migrate auth off JWT — do NOT touch the billing module, it ships Friday", the session compacts an hour later, and the constraint is now a clause in a paragraph the model skims. With Bandaid the sentence is still there, in your words, marked as still binding.
claude plugin marketplace add SSHdotCodes/bandaid
claude plugin install bandaid@bandaidRestart Claude Code. That is the whole setup — it starts working on the next
prompt. Verify with /bandaid:status.
Without the plugin system
git clone https://github.com/SSHdotCodes/bandaid.git
cd bandaid
node bin/bandaid.js install # --scope project|local also availableThis writes the six hooks into ~/.claude/settings.json, backing up the existing
file first. node bin/bandaid.js uninstall removes exactly what it added.
Requires Node 18+ and Claude Code 2.1.220 or newer (older builds lack the
PostToolBatch and PostCompact events). No dependencies.
Six hooks. Nothing is injected into your context until a compaction needs it, so the steady-state cost is zero tokens.
| Hook | What Bandaid does |
|---|---|
UserPromptSubmit |
Writes your prompt verbatim to a session ledger on disk. Silent. |
PostToolBatch |
Records each tool call's name, the arguments that mattered, and what came back. |
PreCompact |
Replaces Claude's summarization directive with Codex's CONTEXT CHECKPOINT COMPACTION prompt, plus rules that force tool params, results, and exact identifiers into the summary. |
SessionStart (source=compact) |
Re-injects your messages verbatim and the turn digests, ahead of Claude's summary, marked as the authoritative source. |
PostCompact |
Prints a receipt of what was preserved; archives the summary. |
Stop |
The goal system: runs the check command and the judge, then blocks the stop with the completion audit if the objective is not proven done. See below. |
Claude Code has no way to replace its compaction outright, but PreCompact can
rewrite the instructions the summarizer follows, and SessionStart fires
immediately afterwards with its stdout going to the model. Bandaid uses both, so
the post-compaction context ends up in Codex's shape.
Message selection is a direct port of Codex's build_compacted_history_with_limit:
walk newest-first, keep whole messages while they fit a 20,000-token budget
(Codex's COMPACT_USER_MESSAGE_MAX_TOKENS), middle-truncate the one that
straddles the boundary, drop the rest. Token estimation and truncation are ported
from codex-rs/utils/string/src/truncate.rs — ceil(bytes / 4), truncating the
middle so both the head and the tail of a message survive.
Anything that falls outside the budget is reported, not silently dropped.
/bandaid:preview shows exactly what would be restored if you compacted right now.
One departure from Codex: recency is not the only thing that buys a slot. Context engineering is filling the window with the right information for the next step, and pure newest-first is a proxy for that which fails in two specific places. A constraint you gave early — "never touch anything under vendor/" — ages out while recent chatter stays, even though this block promises standing constraints remain in force. And the record of what was already tried and failed, the best guard there is against re-running a dead end, ages out on the same rule.
So before the recency walk, Bandaid pins the prompt the goal was made from,
messages that read as corrections or constraints, and turns containing a failed
tool call. Pinned items claim at most half the budget, so relevance can never
starve recency, and truncation still happens exactly once where Codex put it.
Message numbering is by real position, so a gap between n="2" and n="9" tells
the model that older messages were dropped, right where they were dropped.
Claude Code ends a turn whenever the model decides it is finished, so a half-finished task and a finished one look identical. Codex keeps a thread goal alive across turns and re-injects a continuation prompt whose completion audit treats "done" as an unproven claim until it is checked against the current state of the files.
Bandaid reproduces that on the Stop hook, which can exit 2 to hand feedback back
to the model and keep the turn going. The continuation prompt is adapted from
Codex's goals/continuation.md, including the parts that matter most:
Do not substitute a narrower, safer, smaller, merely compatible, or easier-to-test solution because it is more likely to pass current tests.
Treat uncertain or indirect evidence as not achieved; gather stronger evidence or continue the work.
When the audit passes, the model closes the goal itself by running
bandaid goal complete, and Bandaid stops asking.
It is bounded in six independent ways, because a stop hook that can loop is worse than no stop hook:
stop_hook_activeis honoured — Claude Code's own loop guard always wins.- A goal may block at most
maxContinuationstimes, then gives up. - Turns that changed nothing (no
Edit/Write/Bash/Task) are never audited. - If Claude ended its turn asking you a question, the stop is allowed — always by
default, and unless it was asking permission to do work already in scope when
goals.autonomyis on (see below). - Two identical verification failures in a row end the loop early (see below).
- Work this environment cannot do stops being asked for (see below).
- A wall-clock budget, if you set one, wraps the goal up (see below).
Set goals.mode to "explicit" if you only want /bandaid:goal to arm it, or
"off" to disable it entirely.
The leash is earned, not fixed. A flat number is still wrong in both directions on a large task: a goal making verified progress hits its cap mid-refactor, while a goal spinning on the same failure burns the whole cap doing it. So a round that moved the work is refunded — it costs nothing — and a round that moved nothing twice running costs two.
That means the worst case is no longer "two extra rounds". It is 3 × the tier
value in total rounds, spent plus refunded, and it is bounded on four other sides as
well: the wall-clock budget, the token budget, the blocker limit, and
stop_hook_active. A goal going nowhere now ends sooner than it did before any of
this existed, because a stall costs double — which is what makes the mechanism safe
to have on by default.
What counts as progress is the whole difficulty, because a refund is a reward and any reward the model can pay itself gets paid. Ordered by how hard they are to fake:
| signal | can the model fake it? |
|---|---|
a criterion reached covered |
no — that needs a check, probe, judge or expectation record, and evidence.append forces anything the model says to unverified |
| the verification failure changed in substance | no — "3 tests failing" becoming "1 test failing" is a different state |
| a task was completed | yes, it writes its own task list — so this buys one round per goal and no more |
Deliberately absent: the worktree fingerprint. It moves on any tracked edit, which makes it exactly the signal not to reward. Editing a file is not progress.
The cap tracks how strong the verifier is. Karpathy's rule for agents is to slide autonomy up as the verifier proves out, so a flat number is wrong in both directions at once: two rounds is generous for a goal nothing can check, and miserly for one that a shell command closes the moment it exits 0.
| what is watching the work | continuations |
|---|---|
a check command |
8 |
| the judge, no check | 4 |
| neither | 2 |
bandaid status prints which tier you are in. A plain number in config still
overrides all three.
Bandaid could tell the model how many continuations it had left and roughly how many tokens it had spent. It could not tell it what time it was. Every record on disk carried a timestamp and nothing ever subtracted two of them, so a model four hours into an objective and one four minutes in read exactly the same prompt.
The continuation prompt now carries a clock:
Elapsed:
- Now: 16:42 (Thu 30 Jul)
- This goal: 3h 18m of 6h
- Since last progress: 11m
…and one line about what is actually scarce, in place of the four-line Budget:
block that used to say none and unbounded on every default configuration:
Capacity: continuation 2/4 · 3h 18m of 6h · ~50m left (5 tasks, 45m–1h)
Anything unbounded is absent rather than rendered, which is how this added a
wall-clock budget and an ETA and still took 15 words off every one of the ten
continuation prompts — 150 in total, with every ceiling in
test/prompts.snapshot.test.js coming down rather than up.
The four quantities are not of equal quality and the line says so. Elapsed is
measured, continuations are counted, and anything with a ~ is not: tokensUsed
is a floor (it sums digests already truncated to 900 tokens each), and the ETA is
an estimate shown with the spread it came from.
And wall-clock joins turns and tokens as a budget you can set:
bandaid goal set --time-budget 2h -- "Reconcile the two billing ledgers"which wraps the goal up through the same path a spent token budget uses — one
final turn to report, then the stop goes through. 90m, 2h, 1h30m and plain
milliseconds all parse; anything else is rejected rather than guessed, because
a budget read wrongly caps the work at a number nobody chose and does it quietly.
This is the third of the three budgets best-goal-report.md specified. Turns and
tokens shipped; wall-clock did not, which left Bandaid one step behind the thing
that report criticises Codex for — tracking elapsed time and never enforcing it.
Bandaid was not even tracking it.
Two deliberate limits. The clock renders only where Bandaid already spends
tokens — a continuation, a compaction restore, and a SessionStart that had
something to say anyway. A session with no goal and no compaction still injects
nothing at all, because the zero-steady-state property is worth more than a clock
on a turn where nothing is deciding anything. And elapsed time gates a budget,
never validity: whether recorded evidence still describes the worktree stays
content-hashed, for the reason src/lib/stamp.js gives — a TTL is wrong in both
directions at once, and a fingerprint is exact.
Of the six ways the goal loop bounded itself, one was a line of punctuation
counting: if the last non-blank line ended in ?, the stop went through. So a turn
ending "Should I proceed?" bypassed the completion audit, the fixed criteria, and
every verifier tier.
The reason that check exists is a good one — blocking a genuine question traps you in a loop where the question is never actually asked — so it is not removed, it is made to discriminate:
With it on, a request for permission to do work already in scope no longer buys a stop, and gets one 57-word paragraph telling it to decide and say what it assumed. A genuine question — a credential, a choice it cannot rank, a value it cannot derive — still ends the turn, and so does anything the classifier does not recognise.
The two errors are not symmetric, and the threshold comes from that, not from accuracy. Blocking a genuine question locks you out of a conversation you are needed in until the budget runs out. Allowing a permission-ask costs one early turn and a typed "continue" — it is the current behaviour. So the gate is that no genuine question is ever blocked, and uncertainty always resolves to the old behaviour, the same fail-open posture the judge takes when its verdict cannot be parsed.
$ npm run autonomy
corpus 26 cases (16 permission, 10 genuine)
GATE genuine questions still allowed 10/10 (100%)
recall permission-asks caught 15/16 (94%) — not the gate
unknown 3 case(s) matched nothing and fell through to allow
The one miss is "Should I do the tests first or the docs first?" — choice phrasing around work that is entirely in scope. It costs a turn, which is the acceptable direction. The corpus's two hardest rows are the mirror pair: an offer that contains a real question ("Would you like me to use the staging key, or do you have a production one?" — genuine) and a choice that contains none (the miss above).
It is off by default because it changes when Bandaid refuses to let a turn end, and every existing user would get that on upgrade.
An objective is prose, and "what would count as done" gets re-read out of that prose on every continuation — which is where scope quietly shrinks, and why the judge and the model can end up grading against two different bars.
So the bar is fixed once and stored with the goal:
/bandaid:goal Port the retry logic to the new client
records the objective and then, in the same turn, 2–5 checkable criteria:
$ bandaid goal show
criteria: 3 (independent)
1. src/client.js retries a failing call with exponential backoff
2. src/client.js no longer references retryLegacy
3. test/client.test.js asserts that successive retry delays increase
From then on they are re-injected verbatim on every continuation, handed to the
judge as its rubric, and carried through compaction alongside the objective. The
completion audit grades them one at a time instead of re-deriving requirements.
They cannot be quietly rewritten later — bandaid goal criteria refuses to move
a bar that is already fixed unless you pass --replace.
Who writes them matters, and it is not the model doing the work. Sharing one
fixed list stops the worker and the judge from grading against two different bars,
but for a while the list was still written by the model that would then be graded
on it — and a contract authored by the party it binds narrows in a way nothing
downstream can catch, because from the moment it is recorded the short list is
the bar for everyone. So goal criteria --derive runs a separate headless reader
over the objective and the repository, with no access to the conversation, and its
list wins. criteriaSource records which happened: independent, model if the
subprocess could not run and Bandaid fell back rather than pretending, or user.
/bandaid:goal then stops and shows you the criteria before any work starts.
That pause is the point: they are fixed once, and the cheapest moment to correct a
wrong bar is before anything has been built against it.
npm run criteria scores this. On three fixtures built as narrowing traps, an
independent author covers 89% of hand-written ground truth against a worker
baseline's 33% — with the caveat, recorded in the harness and in
brief 12, that the baseline is authored
rather than observed, so the independent arm is measured and the margin is not.
Everything above is scoped to one session. Close the terminal on a three-day refactor and the objective, its fixed criteria, its constraints and its blockers were all still on disk — under a session id nothing would ever look up again.
So a goal now has a second home, keyed by the project rather than the conversation:
~/.claude/bandaid/projects/<sha256(git toplevel)>/handoff.json
It is a projection, not the source of truth. The live goal stays in the session
directory and the hot path is unchanged; the project record is written whenever
the goal moves and read only at session start and by the CLI. Git decides what a
project is, so claude from src/ is the same project as claude from the
repository root — the old per-cwd hash said otherwise.
A new session is offered the objective, not given it. The default,
goals.carryOver: "offer", names the objective, its age, and its bar, states
plainly that nothing is armed, and gives the one command that takes it up
alongside the one that drops it:
bandaid goal adopt # take up this project's open objective
bandaid goal history # what it is, how old, which sessions have worked it
bandaid goal clear --project
Adopting carries the bar across unchanged — criteria, constraints, blockers and the commit the work started from — and the budget across fresh: a new day earns a new continuation allowance, re-resolved against today's verifier.
"auto" adopts without asking, which is the unattended overnight mode and will
occasionally pick up an unrelated task from the same repository. "off" is the
behaviour before any of this existed.
A --resume or --fork needs none of it: the goal now travels with the ledger,
as it always should have. And /clear drops the session's copy while leaving
the project record — clearing your scrollback is not the same as abandoning
three days of work.
The judge is handed a rendered digest of the current session's tool calls. On day three of a goal that is nothing at all from days one and two — so it grades from the repository alone, which is safe but not informed: it cannot know that yesterday's approach was tried and abandoned, or that criterion 3 was proven at 4pm by a check that has since gone red.
So verdicts accumulate in projects/<key>/evidence.jsonl as claims with
pointers. A tool digest says what happened; an evidence record says what is
claimed to be true and where to go and check.
criterion 1 measured supported check `npm test` exited 0 [2026-07-26 14:31]
cmd:npm test
criterion 2 engineer unverified the migration is idempotent [2026-07-26 14:02]
src/migrate.js:88
The asymmetry is the point. The runtime writes what it measured from an exit
status. The model may only ever append an unverified claim — bandaid evidence add forces the kind and the verdict no matter what it asks for. A claim is a
lead for the judge to follow, never a finding.
Each record carries a fingerprint of the worktree it was taken against, so a proof from Monday is shown to Thursday's judge as history rather than as current truth. Failures are kept for exactly the reason they are worth keeping: the record of what has already been tried is the best guard there is against re-running a dead end.
That arithmetic also produces one line in the continuation prompt, in place of several paragraphs asking the model to grade its own criteria:
Evidence by criterion: 1 measured · 2 asserted but not measured · 3 no evidence.
Where the criteria section states the bar, that line reports the score — and a criterion nothing measured is not a criterion that passed.
A check is one shell command, boolean, synchronous, and unable to say "not
applicable here". That is enough for npm test and not enough for the things
that decide whether multi-day work landed — a browser at four viewport widths, a
minute of sustained load, a scanner that wants a lockfile this project does not
have.
One rule shapes the whole feature: probes veto, they never prove. A browser
probe passing does not prove migrate auth off JWT; it proves the page renders.
So a probe can block a stop and feed the judge, and only check and judge can
close a goal. Three things fall out of that: a misconfigured probe can never
close a goal early, which is the expensive error; composition needs no weights,
because any-veto is the rule check already uses; and there is no new autonomy
tier, because a probe makes the loop safer rather than longer.
A project declares its own, in a committed file:
// .bandaid/probes.json
{ "probes": [
{ "id": "browser", "run": "node .bandaid/probes/browser.js",
"when": { "changed": ["src/**/*.tsx", "src/**/*.css"] },
"timeoutMs": 60000, "summons": "bandaid-browser-verify" }
] }Exit status is the verdict, and stdout is never read as one:
| exit | meaning |
|---|---|
0 |
pass |
78 |
abstain — no tooling, not applicable, nothing to say. Invisible: identical to the probe not existing. |
| anything else, a timeout, or a signal | fail — vetoes the stop |
The probe owns its own abstain decision; Bandaid never guesses it. A well-formed
probe opens with command -v k6 >/dev/null || exit 78. Silence from a probe that
started is still not evidence, so it fails. 78 rather than 2 because
grep returns 2 on error and plenty of CLIs use it for a usage mistake — a
probe must not decline by accident.
If the last line of stdout is JSON it becomes detail — a summary for the
continuation prompt, artifact paths for the ledger — but it can never change the
verdict. A probe printing {"ok":true} and exiting 1 has failed.
A committed manifest is arbitrary shell execution, so nothing in it runs until its exact contents are approved:
$ bandaid probes trust
Change one byte and it goes back to asking. A world-writable manifest, or one owned by another user, is refused whatever its approval state.
Probes run out of band. The Stop hook launches them detached and reads a
cached verdict keyed by a fingerprint of the worktree — HEAD, git status --porcelain -uall, and the size and mtime of everything dirty or untracked. A
time-to-live would be wrong in both directions at once; a content fingerprint is
exact. The continuation loop is already the scheduler, so a ninety-second probe
only has to survive one continuation.
A probe still measuring cannot veto — every first stop after an edit would otherwise block — but it can hold a close once, so its answer is not thrown away by a goal closing a second early.
Bandaid installs no tooling and takes no dependencies. Each of these is a skill that tells the model how to produce evidence, plus a grader that turns that evidence into an exit status.
| probe | what it measures | abstains when |
|---|---|---|
browser |
a real browser at 375/768/1440: console errors, failed requests, horizontal overflow, the journey completing, and 2–5 assertions you wrote for the change | no report, or one from an earlier worktree |
load |
sustained concurrency against a budget written beforehand: p95, p99, error rate, rps | no budgets file, or the service is not running |
secrets |
credentials this work introduced, in the diff and in new untracked files | there is no git to diff against |
sweep |
the same class of defect elsewhere, where every finding ships a command that fails now | nobody has swept, or there is no seed |
Three are built in and need nothing installed:
{ "probes": [
{ "id": "secrets", "builtin": "secrets", "when": { "changed": ["**"] } },
{ "id": "load", "builtin": "load", "when": { "changed": ["src/api/**"] } }
] }The browser probe is the interesting one, because "verify it at real viewport sizes like a user would" is not one assertion — it is four, and only the last needs judgement:
- the journey completes at every width;
- zero console errors and zero failed requests;
scrollWidth <= innerWidthat every width;- nothing visually broken that 1–3 miss.
The first three fail loudly, cheaply, and without a model. The fourth is why screenshots are still taken, and it is graded against a rubric that ends with "if you would say it would look better, that is not a finding" — because the failure mode of a model grading a screenshot is an unbounded list of taste notes, and a probe that returns one never passes.
The skill picks a driver in order — the project's own Playwright or Puppeteer,
then npx playwright only if browsers are already installed, then a browser
MCP server, then nothing. It never runs playwright install: downloading 300 MB
inside a verification is a side effect nobody asked for.
There is one gate worth naming on its own: every screenshot must exist and exceed 1 KB. The cheapest way to pass a browser probe is to write a clean report without opening a browser, and a real PNG per viewport is a crude, cheap barrier to exactly that.
Sweep is the one that turns "are there bugs" into an exit code. There isn't one, so it does not invent one — instead every finding must ship a command expected to fail right now, and the runtime runs it:
reproExit !== 0 → confirmed bug
reproExit === 0 → discarded as unreproducible
The agents propose and only the runtime touches a shell, which is what keeps a
fan-out of read-only searchers safe to trust. A finding cannot mark itself
confirmed, the same asymmetry that stops the model writing supported into the
evidence ledger. Dismissing one takes a reason, in a reviewable file.
bandaid goal expect --says "0" -- "grep -c retryLegacy src/client.js"
The model records these as it works, and the runtime runs all of them at every
stop. The value is entirely in the timing: an assertion recorded at the moment of
an edit is a prediction, while the same claim at the end of the turn is a
memory — and memory is exactly what this system distrusts. A model that
predicts 0, then measures 3, has caught itself with no second model in the
loop and no tokens spent.
It is the cheapest verifier here, and the only one that needs nothing installed.
bandaid goal scope "src/sync/**" "test/**"
extractConstraints is a regex over the objective's prose that both over- and
under-matches. Declared paths turn "do NOT touch the billing module" into
baseSha..HEAD minus a glob list — set membership, not a paragraph asking the
model to remember. Out-of-scope changes block the stop and are named.
src/lib/prompts.js spends 277 words asking the model to grade each criterion
honestly and to treat absence of contradiction as absence of proof. It is
the largest compensator in the codebase, and by the Bitter-Lesson test it is the
first thing that should stop being necessary.
bandaid self-check is the mechanical version — arithmetic over a ledger the
model can only append unverified claims to, so the answer is not up for
negotiation:
coverage: 2 of 4 criteria have measured, current evidence.
1. src/client.js retries with exponential backoff
covered — check `npm test` exited 0
2. src/client.js no longer references retryLegacy
claimed-only — you asserted this; nothing measured it.
Add a check, a probe, or an expectation that fails if it stops being true.
3. the checkout flow works at 375px
refuted — probe `browser`: horizontal overflow at 375px
4. the migration is idempotent
uncovered — nothing has been recorded for this criterion at all.
A fifth state matters more than it looks: contradicted, when two verifiers looking at the same worktree disagree — a green check beside a failing probe on one criterion. That is precisely where another blind attempt is worthless. Nothing is unfinished; two measurements cannot both be right; only finding out which resolves it.
The audit paragraphs stay for now, with a dated sunset note. They are probably
load-bearing today, and npm run eval -- --ablate completion-audit is how that
stops being a guess rather than something to act on early.
A loop that only knows unfinished treats "the tests don't pass yet" and "proving this needs a GPU that isn't in this machine" as the same state, and hands both back for another attempt. The second one is unwinnable at the moment it starts, and it stays unwinnable for every remaining turn in the budget.
That is not hypothetical. Across ~190 real Claude Code sessions from one project —
using the native /goal, not Bandaid — roughly a third of the goal-condition stop
blocks name a blocker no amount of model effort clears: hardware that is absent, a
service that isn't running, a browser interaction that can't be driven headlessly, a
data source that doesn't exist. (Roughly, because deciding what counts is a reading
of prose, not a crisp count.) They cluster at the end of the longest loops.
The one that is exact, because it is a single measurable session: a goal blocked seven times over 80 minutes, across 651 assistant messages and 589,414 output tokens, whose last three blocks were restatements of "I can't do this headlessly."
So the model can record it, once:
bandaid goal block "confirming the fix needs a GPU this session cannot reach"That does not close the goal — the rest of the objective is still worth working.
It does three things: the blocker is re-injected every turn marked accepted, do not
re-argue, it is handed to the judge as something not to count against completion,
and after blockerLimit of them (default 2) Bandaid stops continuing the goal and
lets you unblock it.
Recording a blocker is not an escape hatch from hard work. A failing check outranks it — while a check command is red, that is unfinished work with a blocker attached, not a blocked goal — and the continuation prompt says plainly that difficulty, length, and a failing test are not blockers.
The old prompt had this exit, and gated it behind "the same blocker has now repeated across turns": the model was told to loop at least twice before it was allowed to say it was stuck. That sentence is gone.
"Migrate auth off JWT — do NOT touch the billing module" carries two requirements and only one of them is a thing to build. A completion audit grades what was built, so the second one quietly stops being graded at all: every criterion can be satisfied by work that also broke the one thing you said to leave alone, and the audit reads that as success.
Bandaid pulls the negative clauses out of the objective when the goal is set, stores them beside the criteria, and gives them to the judge as vetoes — with an instruction to go looking for the state they forbid, since a deleted thing leaves no trace of itself except the manifests and imports still pointing at it.
A broken constraint gets its own verdict, because it needs the opposite response from an unmet criterion. Unmet means keep working. Broken means stop: the damage is in the worktree already, another attempt cannot undo it, and a model choosing its own remedy is how a bad delete becomes a bad delete plus an improvised restore. So Bandaid closes the goal, blocks exactly once, and spends that turn making the model tell you what it broke and what recovery would take.
The same corpus has this one too: a goal carrying "…without touching things that are used", a directory deleted that the user had explicitly said not to touch, and the judge correctly catching it — then blocking the stop four times in thirty seconds, asking for another attempt at something no attempt could fix.
Codex's audit is good prompt engineering, and prompt engineering cannot fix its
one structural problem: the model grading the work is the model that did the
work. A model already convinced it is finished reads its own evidence
charitably, and the audit becomes a formality it passes. Claude Code's own
/goal has the opposite half of the answer — an independent judge — but that
judge only ever sees the transcript, so it cannot check a claim the transcript
does not contain, and it goes blind the moment a compaction summarizes the
transcript away.
Bandaid puts two things outside the model in front of the stop.
A check command — ground truth. Attach a shell command to the goal and exit 0 becomes the definition of done:
/bandaid:goal Migrate auth off JWT --check "npm test"Exit 0 closes the goal automatically, whether or not the model got around to saying it was finished. Anything else vetoes the stop and the real output is handed back:
Verification result (external — not your own assessment, and not up for debate):
The command `npm test` was run against the current worktree and did not succeed.
<check-output>
FAIL src/auth.test.ts:41 expected 200, got 401
</check-output>
That is immune to everything prompts are not: self-assessment bias, drift over long sessions, and compaction. A check that cannot run — typo, missing binary, timeout — counts as not proven, never as proof.
A judge — independence with hands. Off by default; turn it on with
"goals": {"judge": true}. Before a goal closes, a separate headless Claude
(Haiku, read-only: Read, Grep, Glob) inspects the repository, not the
conversation, and answers complete or continue with one reason. Because it
reads the worktree rather than the transcript, compaction cannot blind it. It
runs with Bandaid disabled in its own environment, so a verification can never
recurse into another verification, and if it crashes, times out, or is not
installed it simply abstains — you get the old behaviour, not a wedged session.
Given a tool log claiming "Redis store implemented with pooling, all requirements satisfied" for a file that does not exist, the judge answers:
continue— src/lib/redis-store.js does not exist; the objective requires its implementation and it is not present in the repository.
Ground truth outranks the judge, which outranks the model. A failing check ends the argument — the judge is not even consulted. A passing check still gets judged when the judge is on, because green tests and a satisfied objective are not the same claim.
A seal — the check the model is never shown. Off by default; attach one with
--seal:
/bandaid:goal Make the composed query path work --check "npm test" --seal "npm run test:holdout"The problem it answers is that check is a signal the worker can see. Its
command is in the goal record and its output comes back as the reason to fix — on
purpose, because that is what makes it useful feedback. It is also what makes it a
target. SpecBench measures the gap between a
visible test suite and a held-out one at roughly 27 points per tenfold increase in
code size, reaching 100 points on the largest tasks, and the largest share of it is
work whose features pass individually and do not compose. METR finds reward hacking
in 30% of runs by default and 70–95% after the model is explicitly told not to,
which is roughly the end of the argument for solving this with another paragraph.
So the seal runs only on a round that is otherwise about to close the goal, and
nothing it produces reaches the model — not the command, not the output, not a
reason derived from either. On failure the goal blocks rather than continuing,
because continuing would mean saying why, and a seal the worker is steered by is
just a slow check. The finding goes to you:
$ bandaid goal show
held-out check refused the close at 2026-07-30T18:22:04.113Z
command: npm run test:holdout
composed filter+group ignores the predicate: group a still has 2 rows
It is not shown, not secret — goal.json is on disk and the model has a shell.
It answers drift toward the visible signal, not curiosity. That the seal exists is
stated to the model deliberately: a worker that knows an unseen check will run has
nothing to aim at, which is the whole asymmetry.
A plateau breaker. Both budgets — Codex's tokens and Bandaid's continuation count — measure how much has been spent, not whether anything is moving. When two verification runs in a row produce the byte-identical failure, the loop has stopped converging and Bandaid hands the problem back rather than spending the rest of the budget on it. Changing failures ("3 tests failing" → "1 test failing") are progress and reset the counter.
Run /bandaid:verify at any time to see the same verdict the Stop hook sees —
otherwise a failing check is visible only to the model, and "why does it keep
going?" has no answer.
Claude Code 2.1.220 ships its own /goal <condition>, which loops until a stated
condition is judged met. It is a different thing: opt-in per invocation, phrased
as a boolean condition, and unrelated to compaction. Bandaid's goals are a
persistent objective with Codex's evidence-based completion audit, applied
automatically. They coexist — both push in the same direction — and you can turn
Bandaid's off and use the native one if you prefer.
| Command | |
|---|---|
/bandaid:status |
Config, install state, what has been captured |
/bandaid:preview |
Exactly what would be restored if you compacted now |
/bandaid:goal <objective> [--check "<cmd>"] [--seal "<cmd>"] |
Set an explicit objective, optionally with a command that proves it done and a held-out one the model is never shown. --time-budget 2h caps its wall-clock |
/bandaid:goal-status |
Show the objective, its check, and its continuation budget |
/bandaid:goal-done |
Close the objective |
/bandaid:verify |
Run the check and the judge now, and show the verdict |
/bandaid:probe |
What each probe last said about the current worktree |
/bandaid:self-check |
Which criteria have measured evidence, and which are only asserted |
/bandaid:goal-resume |
Take up the objective this project left open |
The bandaid CLI has the same surface plus install, uninstall, doctor,
inspect, durations, tasks, sessions, sessions prune, prompt, goal criteria, goal block,
goal adopt, goal history, goal expect, goal scope, probes list|trust,
probe status|run|arm|disarm, self-check, evidence show|add, and on/off.
goal block <reason> records one thing this environment cannot do and keeps the
goal running; goal blocked gives up on the whole objective.
~/.claude/bandaid/config.json, merged over the defaults:
{
"enabled": true,
"compact": {
"userMessageMaxTokens": 20000, // Codex's verbatim budget
"digestBudgetTokens": 20000, // budget for turn digests
"turnDigestMaxTokens": 20000, // ceiling for one turn
"toolResultMaxTokens": 900, // ceiling for one tool result
"useCodexSummaryPrompt": true,
"recordTurns": true
},
"goals": {
"mode": "auto", // "auto" | "explicit" | "off"
// scales with the verifier; a plain number overrides all three tiers
"maxContinuations": { "verified": 8, "judged": 4, "unverified": 2 },
"tokenBudget": null,
"timeBudgetMs": null, // wall-clock ceiling per goal
"skipTrivialTurns": true,
"autonomy": false, // a permission-ask still ends the turn
"check": null, // shell command; exit 0 closes any goal
"seal": null, // held-out check; never shown to the model
"judge": false, // independent read-only verifier
"judgeModel": "haiku",
"judgeCli": "claude", // binary the judge runs as
"verifyTimeoutMs": 120000, // ceiling for one check or one judge run
"plateauLimit": 2, // identical failures before giving up
"blockerLimit": 2 // recorded blockers before giving up
},
"retention": {
"enabled": true,
"sessionMaxAgeDays": 30, // drop session dirs untouched this long
"sessionMaxCount": 200, // hard ceiling, newest kept
"sweepIntervalHours": 24 // how often SessionStart may sweep
}
}A session whose goal is still active is never pruned, whatever its age.
That is the multi-day case, and deleting it is the failure the goal system
exists to prevent.
A check command is the cheapest large win here. With one attached, looping is safe — the loop cannot end on a false positive — which is why attaching one raises the continuation cap on its own.
Env overrides for one-off runs: BANDAID_ENABLED, BANDAID_COMPACT,
BANDAID_GOALS, BANDAID_GOAL_MODE, BANDAID_MAX_CONTINUATIONS,
BANDAID_TIME_BUDGET,
BANDAID_USER_MESSAGE_MAX_TOKENS, BANDAID_DIGEST_BUDGET_TOKENS,
BANDAID_HOME, BANDAID_DEBUG.
bandaid off is the kill switch; it disables every hook without uninstalling.
~/.claude/bandaid/sessions/<session-id>/ — prompts.jsonl (verbatim),
turns.jsonl (tool digests), goal.json, summaries.jsonl, meta.json, plus
~/.claude/bandaid/projects/<key>/handoff.json for the objective a project left
open. All
local, never transmitted. Delete the directory at any time; Bandaid rebuilds what
it can from Claude Code's own transcript.
Because it can backfill from the transcript, installing mid-session still works — the first compaction after install replays prompts from before Bandaid existed.
-
Claude's summary is not removed. Claude Code compacts internally and no hook can prevent that. Bandaid changes the instructions that produce the summary and restores the primary material alongside it. The summary is still there.
-
Restoration costs tokens. Up to ~40k on a long session — that is the point (you are buying back context), but it is not free.
/bandaid:previewshows the bill before you pay it. -
Assistant reasoning is not preserved verbatim, only your messages and the tool record. That matches Codex, which also summarizes the model's own turns.
-
Digests are lossy by design. Tool results are capped (900 tokens each by default) and middle-truncated. A 50k-line log becomes its head and tail.
-
Without a check command, the goal system depends on the model cooperating to run the completion command. When it does not, the continuation cap ends the loop and the turn stops normally. A check command removes that dependency entirely — it is the single most useful thing you can configure.
-
A check command is only as good as the command.
npm testproves the tests pass, not that the objective was met; that gap is what the judge is for, and the judge is a model too. Neither tier turns a vague objective into a verifiable one. -
The seal is not shown to the model; it is not hidden from it.
goal.jsonis on disk, the worker has a shell, and nothing stops it reading either. What the seal actually buys is that the loop is never steered by it: the command and its output are absent from every prompt, every reason, and the judge's copy of the ledger, so the work cannot drift toward satisfying it the way it drifts toward satisfyingcheck. That is a defence against Goodhart, not against curiosity, and the two are worth keeping apart. If you need the latter, the seal belongs in CI where the worker has no filesystem, not in a goal record. -
A seal only runs if something else was already closing the goal. It gates the paths that return "proven", so a goal with a seal and no check and no judge will never run it — nothing ever reaches the gate.
bandaid goal setwarns when you configure that combination, but the warning is the only thing standing between it and silently doing nothing. -
npm run criteriameasures one arm properly and one arm not at all. The independent list is nine stochastic samples scored against ground truth written beforehand. The worker baseline it is compared to is three lists the fixture author wrote, knowing what would be scored — so all three landing on exactly 33% is a fact about the author. The supported claim is independent derivation reaches the awkward half of an objective; the unsupported one is by 56 points more than a real worker would. Settling it needs criteria captured from live goal-setting, which is the same shape of gap as the loop harness's scripted worker. -
An unsatisfiable goal is bounded but never diagnosed. A goal whose criteria reference something that cannot exist will burn its full leash — the round ceiling caps that at
3 ×the tier, and the plateau breaker ends it early when the failure text repeats. Neither one notices that the bar is impossible, and a model that rephrases its complaint each round defeats the comparison. This is the failure Claude Code's own/goalis on record looping ~30 times on; Bandaid stops sooner and explains no better. -
The plateau breaker fires, and until recently it fired far too eagerly. This entry used to say it "almost never fires" — replayed against two real stuck loops it would have fired zero times, because a judge writes fresh prose each round and token overlap between consecutive reasons sat at 0.2–0.7 with no threshold separating "stuck" from "progressing". That replay is still the right result for judge-graded loops, and no similarity metric has been added for them.
What it got wrong was the check-command case. The failure reason it compares contained only the command, not the output — a constant for a given goal — so it fired after any two consecutive failing check rounds regardless of progress.
npm run loopdemonstrates the damage: a goal landing one of four pipeline stages per round, reportingonly 1 of 4,only 2 of 4,only 3 of 4, was terminated at round 3 before it could go green at round 4. The reason now carries the first line of the output, which is what the paragraph always claimed it compared. On the loop fixtures it ends 3 of 4 stuck loops and no converging ones. -
The stall rule never ends a loop.
npm run loopreports itDEAD: the plateau breaker is checked first (src/hooks/stop.js:242, beforeprogress.settleat:262) and reaches its limit a round earlier on every stuck fixture. The stall's double-cost still accelerates the budget; it simply never wins the race. Whether it reaches anything plateau cannot is unverified — that needs a judge-graded fixture, and the judge needsclaudeonPATH. -
A check with nondeterministic output buys refunds it did not earn. Because "the verdict changed" counts as progress, a suite that prints timings or randomises order looks like it is advancing. On the
stalling-variedfixture that is 5 refunds across 6 rounds of pure churn. It stays bounded by the3 ×round ceiling and the wall-clock budget, but "the output changed" is a weaker proxy for progress than it reads as. -
An expectation can never make a criterion
covered, thoughexpectis listed as a measured kind.bandaid goal expecttakes no--criterion, andverify.assesswrites anexpectrecord only when one fails (verdict: 'refuted'). So a passing expectation is invisible toevidence.coverage, andbandaid self-checkcan only ever reachcoveredvia a check, a probe, or the judge. Six passing expectations and five criteria still read0 of 5 measured, which is confusing rather than wrong — the expectations are real and do block a stop when they stop holding. Closing the gap means adding--criterionand recording the passing path, which is a decision about ledger volume, not a typo. -
The earned leash is not measured yet, only bounded. The refund is asserted by unit and end-to-end tests — a changed check output refunds, an identical one costs double, the ceiling holds at
3 ×the tier — but whether it reduces rounds-to-completion needs a harness that runs the loop rather than the grader, and that does not exist yet. Until it does, this is a mechanism with a safety argument and no efficacy number. -
A goal with no verifier earns almost nothing. Two of the three progress signals need a check, a probe, a judge or an expectation, so the refund helps
verifiedandjudgedgoals and barely touchesunverifiedones. That is the autonomy slider working as intended, and it means "work on larger tasks" is conditional on attaching a check command. -
The permission-ask classifier is patterns, not comprehension. It catches 15 of 16 permission-asks in a 26-case corpus and blocks none of the 10 genuine questions. A permission-ask phrased in a way the corpus does not contain falls through to
allow, which is the old behaviour; recall is reported and is not 100%. The corpus is this repository's own phrasing plus hand-written adversarial pairs — one model, one codebase. -
A blocked permission-ask spends a continuation. On a goal with two rounds left, two permission-asks exhaust the budget and the turn ends anyway.
-
The ETA is uncalibrated on real work, and says so.
npm run etabacktests it against recorded sessions; today it reports nothing scoreable, because a session needs a finished task list to have a horizon and only one local session had a task list at all. The numbers that exist come from a synthetic fixture and establish that the harness scores, not that the estimate is accurate. The backtest already earned its place twice: it caught a methodology bug that produced a MAPE of 837,734%, and it deleted the estimator's trimmed median for measuring worse than a plain one. -
Most sessions have no task list to count. Bandaid asks the model to keep one and now reads it back — from
TaskCreate/TaskUpdate, which carry stable ids, or fromTodoWrite, which does not. Across 15 local sessions, 1 used a task tool at all. So anything derived from task counts is absent far more often than it is present, and has to degrade to something else rather than to a guess. -
A task list is the model's own account of its plan, not ground truth. Nothing closes a goal because a task said
completed; the criteria and the evidence ledger remain the only bar. A vanished task is recorded as dropped, never as done — inferring completion from absence would make every mid-work restructure look like a burst of productivity. -
Matching a reworded
TodoWriteentry is a guess. A word-boundary prefix test catches the common case exactly; past that it is token overlap at 0.6, and durations resting on it are flaggedfuzzyand excluded from any headline number. Two identically-worded tasks are told apart only by their position in the list. -
A recorded tool duration is the duration of the call, not of the work. An asynchronous tool that launches something and returns looks like 20ms. A tool that waits for you to answer measures how long you took. Both are true readings of what was asked and neither is what a reader assumes, so the profile reports per-tool percentiles rather than one number for "a tool call".
-
hooktiming has never been observed. No realPostToolBatchpayload inspected carries a duration, so every sample comes from the transcript, wheretool_result.timestamp − tool_use.timestampis exact. Thegapfallback — the interval between two batches completing, which contains the model's own thinking time — is implemented and tested but unused in practice, and its inflation on real work is unmeasured. -
The clock is not always on. It renders in the continuation prompt, in the compaction restore, and on a
SessionStartthat was already emitting something. A session with no goal and no compaction sees no time at all — that is the price of injecting nothing until something needs it, and it was chosen over a per-turn block costing roughly 35 tokens every turn forever. -
"Since last progress" currently means "since last edit". It advances on any turn that ran
Edit/Write/Bash/Task, so a turn that changed files while achieving nothing counts as progress. A signal worth the name has to read the evidence ledger; until it does, that line is weaker than it sounds. -
An adopted goal's clock restarts.
startedAtresets on adoption so a wall-clock budget matches the fresh continuation allowance a new day earns. The objective's true age iscreatedAt, which is whatbandaid goal historyshows — so a 2h budget means 2h per session, not 2h across three days. -
Constraint extraction is a regex over the objective's clauses. It finds
do not,never,avoid,must not,without touching, and friends. Phrase a constraint some other way and it is not extracted, and nothing tells you so —bandaid goal showlists what was found, which is the only way to check. -
The
violatedverdict is the least reliable tier. Over 8 runs against theconstraint-violatedfixture with Haiku it landed 6 times; the misses were the judge asserting the constraint was intact without going to look. A missed violation degrades to ordinary "continue", so the cost is the old behaviour, not a wrong stop. A strongerjudgeModelis the lever if this matters to you. -
The judge costs a subprocess and 12–16s (measured over three Haiku runs against
eval/fixtures/done: 12.4s, 13.8s, 15.7s) on stops that would otherwise be blocked, and needsclaudeonPATH. It is off by default for that reason. If it cannot run it abstains silently rather than blocking. -
Two sessions in one directory used to confuse the CLI. Each session now drops its own pointer and any command that writes a goal refuses rather than guessing, listing both ids and asking for
--session. Reads still resolve to whichever prompted last. -
A fresh session is offered the project's objective, never given it. That is deliberate — a session must not replay another conversation's instructions — but it means unattended multi-day work needs
goals.carryOver: "auto", and in that mode a second, unrelated task started in the same repository will pick up the first one's objective.bandaid goal historyis how you find out, andgoal clear --projectis how you stop it. -
The project key is the git toplevel. Two worktrees of one repository are two projects, which is usually what you want and occasionally is not. Outside git it falls back to the directory, so moving a non-git project loses its record.
-
The shipped probes measure a delta, not a codebase.
secretsfails on what this work introduced and reports the rest;sweepneeds a seed. That is deliberate — an absolute gate fails every real repository on day one and is switched off on day two — but it means neither is an audit. -
A sweep reproduction runs in the working directory, with a 60-second cap and Bandaid disarmed, not in a sandbox. One that reaches the network or installs something escapes that; the skill says not to write one and nothing enforces it.
-
The built-in load generator is not a good load generator. Fixed concurrency, one machine,
fetch. It catches a fall from 2000 rps to 40. It will not tell 1900 from 2000, and it measures a regression rather than a production ceiling. -
A probe that abstains looks like verification and provides none. Four armed probes that all exit 78 leave a goal nobody is watching.
bandaid probe statusandbandaid verifyboth say so; nothing stops you ignoring them. -
when.changedis globs and nothing more. No conditions, no expressions. A project that needs logic writes it in the probe script, where it is testable and where the probe can exit 78 for itself. -
A detached probe can leak. A crashed runner leaves a lock and no result, which reads as still-in-flight until the lock ages out.
bandaid probe statusshows it andbandaid probe clearremoves it. -
The ledger's staleness rule is coarse. Any tracked edit moves the worktree fingerprint, so on a busy day most records show as history rather than as current proof. That errs in the safe direction — the judge re-reads the files anyway — but it costs tokens, and content-hashing only the changed set is the upgrade if it bites.
-
Nothing was ever deleted before now. Retention is on by default and sweeps at most once a day from
SessionStart;bandaid sessions prune --dry-runshows what it would take first. -
Tested against Claude Code 2.1.220. Hook input field names are product internals and could change;
bandaid doctorand the end-to-end tests are how you find out.
npm test # 520 tests, no dependencies, no network
npm run eval # measures the judge against fixtures; needs `claude` on PATH
npm run eta # backtests the ETA against recorded sessions; skips if none
npm run autonomy # scores the permission-ask classifier against its corpus
npm run loop # runs the Stop loop against 8 fixtures; offline, ~20s
npm run criteria # scores independent vs worker-written criteria; needs `claude`
node bin/bandaid.js doctortest/hooks.e2e.test.js runs the real hook scripts the way Claude Code runs them
— JSON on stdin, meaning carried by the exit code — against a throwaway state
directory. It is the suite that catches an integration break.
test/prompts.snapshot.test.js holds every injected prompt as a golden file in
eval/snapshots/. Roughly a thousand words of instruction reach the model, and
without these a prompt edit broke no test and was invisible in review. Refresh
with UPDATE_SNAPSHOTS=1 npm test and read the diff.
It also records a word-count ceiling per prompt. A golden makes an edit visible; it does nothing to make growth expensive. The ceiling does: exceeding one fails the suite, so lengthening a prompt means raising a number somebody reviews. They are not targets — every one of them should be going down.
Bandaid's case rests on a verifier that outranks the model's own opinion, which
only helps if the verifier is right. eval/fixtures/ is built around the failure
that matters — work that looks finished:
| fixture | expected |
|---|---|
done |
complete |
stubbed-test |
continue — the test exists, its assertion is vacuous |
not-implemented |
continue — the symbol exists, the body throws |
missing-test |
continue — two of three criteria met |
check-fails |
continue — code looks right, the check exits non-zero |
blocked-by-environment |
complete — one criterion needs absent hardware and is recorded as blocked |
constraint-violated |
violated — the cleanup is correct, but a protected directory was emptied |
flattering-claim |
continue — the ledger is full of confident engineer claims; every body throws |
stale-evidence |
continue — every criterion genuinely passed, against a worktree since reverted |
coverage-gap |
continue — two criteria measured, the third only asserted |
The last three exist because the evidence ledger gave the judge a new way to be wrong: believing it. Each seeds a ledger that says the work is done over a repository where it is not.
$ npm run eval
accuracy 10/10 (100%)
confusion complete-when-complete 2 complete-when-not 0
continue-when-not 8 continue-when-complete 0
precision 100% (of the goals it closed, how many were really done)
recall 100% (of the goals really done, how many it closed)
On flattering-claim the judge answered, unprompted: "src/client.js contains
only unimplemented stubs; exponential backoff logic is missing, and
test/client.test.js does not exist." Three confident claims with pointers, and
it went and looked at what they pointed at. That is the ledger working as
designed — a lead to follow, never a finding to accept.
npm run eval -- --ablate ledger # withhold the evidence block from the judge
npm run eval -- --ablate criteria # withhold the fixed rubric
npm run eval -- --ablate constraints
npm run eval -- --ablate blockers
Each run withholds one block and reports the same matrix. A mechanism whose ablation moves no number is a mechanism to delete, and saying so in advance is what makes deleting it a result rather than a defeat.
The first number this produced is a null result, and it is about the ledger:
$ npm run eval accuracy 10/10 precision 100%
$ npm run eval -- --ablate ledger accuracy 10/10 precision 100%
Withholding the evidence ledger from the judge changed nothing. On this suite it does not earn its tokens.
Two things are true about that and both are worth stating:
- It is a real result. The ledger was added on the reasoning that a judge which knows what was already tried grades better. These ten fixtures say it does not — and the ledger costs up to 3000 tokens on every judged stop.
- The suite cannot measure what the ledger is for. Every fixture is a single-shot judgement over a fresh repository that already contains the ground truth, so a judge that reads the files needs no history. The ledger exists for the case none of these cover: day three, where the dead end was walked on day one and the repository has no memory of it. Both runs also sit at 100%, so the suite has no headroom to show a difference in either direction.
The honest reading is therefore unmeasured, not useless — and the fix is a
fixture the harness cannot express yet: two sequential judgements over a
repository that changes between them. Until that exists, the ledger is an
unmeasured bet, which is exactly what karpathy-report.md warns against, now
labelled as one instead of assumed to be fine.
The harness that runs the loop rather than the grader now exists:
npm run loop # 8 fixtures, offline, ~20s
npm run loop -- --ablate completion-audit
npm run loop -- --ablate ledger --judge
npm run loop -- --ablate seal
npm run loop -- --worker claude # a model reads the prompt: slow, paidIt runs several Stop rounds against a fixture repository that changes between
them, with a scripted worker standing in for the model — deterministic, free, and
reviewable. It reports which mechanism ended each loop, and separates "a fixture aims
at this and it never fires" from "no fixture reaches this".
It found a real bug on its first run. The failure reason Bandaid compared across
rounds contained only the check command, not its output — a constant — so the
plateau breaker fired after any two consecutive failing rounds regardless of progress,
and the "the verdict changed" progress signal could never fire at all. A fixture
landing one of four pipeline stages per round, reporting only 1 of 4, only 2 of 4,
only 3 of 4, was terminated at round 3 before it could go green at round 4.
Nothing in the repository could see that, because nothing ran the loop.
The scripted worker cannot measure prose. A script does not read the prompt, so every prompt-block ablation against it comes back byte-identical to the baseline — and that is the only possible outcome, not a finding.
--worker claude is the tier that can. It hands the model the objective on round
1 and the previous stop's continuation prompt — verbatim — on every round after, so
withholding a block genuinely changes what was read. It is opt-in twice like the judge
(the fixture declares "worker": true, the run passes the flag), nondeterministic, and
paid, which is what --samples is for:
npm run loop -- --worker claude --samples 5 --filter blocked
npm run loop -- --worker claude --samples 5 --filter blocked --ablate completion-auditExpect minutes and real spend per arm, so it is never in npm test and never in the
default npm run loop. One run of it is an anecdote; docs/plans/13-model-worker.md
states what may be claimed from it and what may not.
It found a false close on its second run, and a defect underneath it. Given an
objective with a criterion this environment cannot satisfy, the model ran
bandaid goal complete and the loop released. decideOnStop returns allow on any
terminal status, and the Stop hook returns before verify.assess — so a goal the
model has declared complete is never verified, even with a check or a seal
configured. Reproduced deterministically both ways. No scripted fixture could reach
it, because no scripted worker in the suite ever calls goal complete. Not yet fixed;
it is named here rather than left for someone to hit.
The seal's ablation is the one that came back non-null. Every other mechanism
measured by this harness either could not be measured by it (prose) or moved no
number (the ledger). The sealed fixture reproduces SpecBench's feature-isolation
shape — a query() whose filter and groupBy each pass alone and do not compose,
with a visible check that tests each in isolation and goes green on round 1:
npm run loop 8/8 sealed → blocked at round 2, ended by seal
npm run loop -- --ablate seal 7/8 sealed → complete at round 1, ended by check
Withheld, the tier lets a goal close as done on an implementation that does not do what the objective asked. That is what earning a place looks like on this harness, and it is worth stating plainly next to the two that did not.
The evidence ledger's excuse is spent, and its answer is unchanged. The fixture
karpathy-report.md asked for — two sequential judgements over a repository that
changes between them — now exists: round 1 lands a correct implementation, round 2
adds a test and reverts the implementation, so round 1's ledger entry describes a
worktree that is gone. With the judge on, the goal correctly stays open with and
without the ledger. Two independent harnesses now agree the ablation moves no
number. It is kept, because the judge is right in both arms and so there was no
headroom for the ledger to improve — and what would settle it is a trap invisible from
the files alone. If no such fixture can be built, that is the argument for deletion,
and it should be made in those terms rather than by another flat ablation.
That is ten fixtures on one theme with Haiku and criteria supplied — a floor,
not a general claim about the judge. What it buys is a regression detector: the
number moves when a prompt or a tier changes, which nothing here could tell you
before. constraint-violated is the one that is genuinely flaky (6 of 8 across
runs).
"The others have not missed" is no longer true, and the correction is worth more
than the sentence it replaces. During the seal work a single run came back
9/10 with recall 50% — a complete-expected fixture judged continue. The next
run was 10/10 and --repeat 3 was 30/30, so it is one miss in ~41 judgements
rather than a regression: the judge prompt is byte-identical (no judge-* golden
moved) and the only judge-path change filters seal records out of the rendered
ledger, which is provably a no-op on a ledger that has none. The lesson is about the
harness, not the change — a single 10/10 is a weak signal from a stochastic
grader, and a one-shot run should not be quoted as a gate without saying how many
samples produced it.
The last two fixtures exist because the mechanisms they cover are prompt-shaped,
and a test that only asserts the prompt contains the right paragraph proves the
paragraph, not the behaviour. blocked-by-environment is the one that showed the
blocker path works end to end: the judge closed the goal and said, unprompted,
that the printer confirmation "is a recorded blocker not counted toward
completion."
The design, the prompts, and the budgeting algorithm are Codex's; Bandaid is a
port. Derived from openai/codex (Apache-2.0):
compact/prompt.md, compact/summary_prefix.md, goals/continuation.md,
goals/budget_limit.md, core/src/compact.rs, and
utils/string/src/truncate.rs. See NOTICE for the file-by-file
attribution.
Apache-2.0. Not affiliated with OpenAI or Anthropic.