diff --git a/.github/smoke.sh b/.github/smoke.sh index 82a0ed3..b36597d 100755 --- a/.github/smoke.sh +++ b/.github/smoke.sh @@ -117,4 +117,63 @@ ln -s "$tmp/nowhere" "$tmp/dangling" || fail "ln -s to a missing targ if [ -e "$tmp/dangling" ]; then fail "-e followed a dangling link (guard would misfire)"; fi ok "ln -s / -L / -e guard" +# 10. context-rot guard's field extraction / threshold resolution (claudezero.sh's Stop hook). +# `grep -noE` feeds short per-field `LINE:"key":value` lines into the table-matcher awk — never +# the raw record — so this exercises: ENVIRON[] escapes surviving intact (a `-v` hand-off would +# expand `\[1m\]` into the character class `[1m]`, matching bare "1"/"m"); dynamic regex matching +# a version-suffixed family id while a word-suffix tier ("-mini") falls through to the default +# (split(s,a," ") whitespace-run parsing, blank table lines dropped, exercised via CZ_TABLE's own +# leading/trailing blank lines below); and match-based field extraction surviving a `tail -c` cut +# that lands mid-JSON, dropping `model` while the `usage` object (which trails it) survives. +CZT=' + \[1m\] 200000 + claude-fable-5(-[0-9]|[^A-Za-z0-9-]) 200000 +' +guard_verdict() { # $1 = raw bytes: a JSONL record, or a `tail -c`-cropped fragment of one + printf '%s' "$1" \ + | grep -noE '"model":"[^"]*"|"input_tokens":[0-9]+|"cache_read_input_tokens":[0-9]+|"cache_creation_input_tokens":[0-9]+|"output_tokens":[0-9]+' \ + | CZ_TABLE="$CZT" CZ_DEFAULT=160000 awk ' + BEGIN { + def = ENVIRON["CZ_DEFAULT"] + 0 + n = split(ENVIRON["CZ_TABLE"], tln, "\n") + for (i = 1; i <= n; i++) { + if (split(tln[i], f, " ") < 2) continue + tn++; pat[tn] = f[1]; thr[tn] = f[2] + 0 + } + } + { + if (!match($0, /^[0-9]+:/)) next + L = substr($0, 1, RLENGTH - 1); rest = substr($0, RLENGTH + 1) + if (!match(rest, /^"[a-zA-Z_]+":/)) next + key = substr(rest, 2, RLENGTH - 3); val = substr(rest, RLENGTH + 1) + if ((L, key) in seen) next + seen[L, key] = 1 + if (key == "model") { sub(/^"/, "", val); sub(/"$/, "", val); mdl[L] = val; next } + if (key == "output_tokens") { saw_out[L] = 1; next } + tot[L] += val + 0 + } + END { + best = "" + for (L in saw_out) { if ((tot[L]+0) > 0 && (best == "" || (L+0) > (best+0))) best = L } + if (best == "") { print 0; exit } + id = mdl[best] " " + th = def + for (i = 1; i <= tn; i++) { if (id ~ pat[i]) { th = thr[i]; break } } + print (((tot[best]+0) >= th) ? 1 : 0) + }' +} + +REC_MINI='{"model":"claude-fable-5-mini","input_tokens":9000,"cache_read_input_tokens":170000,"cache_creation_input_tokens":1000,"output_tokens":500}' +[ "$(guard_verdict "$REC_MINI")" = 1 ] || fail "claude-fable-5-mini at 180000: default (160000) should fire; a -v hand-off would corrupt \\[1m\\] into a class matching mini's 'm' and wrongly give 0" + +REC_VER='{"model":"claude-fable-5-20260115-v1:0","input_tokens":9000,"cache_read_input_tokens":170000,"cache_creation_input_tokens":1000,"output_tokens":500}' +[ "$(guard_verdict "$REC_VER")" = 0 ] || fail "claude-fable-5-20260115-v1:0 at 180000 should keep the family row (200000), not the default" + +REC_FULL='{"model":"claude-fable-5","content":"PADDING","input_tokens":9000,"cache_read_input_tokens":170000,"cache_creation_input_tokens":1000,"output_tokens":500}' +[ "$(guard_verdict "$REC_FULL")" = 0 ] || fail "uncropped record should resolve its family row" +cropped="$(printf '%s' "$REC_FULL" | tail -c 110)" +[ "$(guard_verdict "$cropped")" = 1 ] || fail "a tail -c cut mid-JSON that drops 'model' should degrade to the default, not stay unmatched" + +ok "context-rot guard: grep -n -o extraction, ENVIRON[] escapes, dynamic regex, tail -c mid-JSON cut" + echo "SMOKE PASS ($(uname -s), bash $BASH_VERSION)" diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ece436..d96cdc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,29 @@ All notable changes to ClaudeZero are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.0.17] - 2026-08-04 + +### Added + +- `CLAUDEZERO_DEPENDENCY_WAIT` — ceiling on the wait after a zero-mode session walks the whole + todo list and claims nothing because every unchecked task is dependency-blocked. Without it, a + fully dependency-blocked list looked identical to a genuinely stuck one: a fresh claude session + launched, found the same block, and ended its turn — one session burned per cycle for zero + possible progress. The session now marks the block on its way out, and the shell waits, + comparing a deterministic signature of the todo blob and the ids peers currently hold, instead + of relaunching blindly — stopping the instant a peer merges the blocking task or its holder + dies, or after this ceiling, whichever comes first. Default `10m`; same grammar as + `CLAUDEZERO_WATCHDOG`; `0` relaunches immediately every cycle, same as before this existed + (ISSUE-034). + +### Changed + +- The context-full restart signal is now computed in ClaudeZero's own Stop hook, from a + threshold table (`CONTEXT_THRESHOLDS`) at the top of `claudezero.sh`, instead of depending on + a third-party `suggest-compact` hook — one prerequisite instead of two. Existing installs need + no action: a still-installed `suggest-compact` hook keeps writing a file nothing reads, so it + is inert, not conflicting, and removing it is optional (ISSUE-035). + ## [0.0.16] — 2026-08-03 ### Added diff --git a/README.md b/README.md index 76c8bea..90e702b 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Runs [`claude`](https://claude.com/product/claude-code) on a predefined prompt i - [Quickstart](#quickstart) - [Install](#install-for-the-claude-coding-agent) - [Todo file format](#todo-file-format) +- [Context Rot](#context-rot) - [Loop engineering](#loop-engineering) - [Usage](#usage) - [Cleanup](#cleanup) @@ -33,7 +34,7 @@ Runs [`claude`](https://claude.com/product/claude-code) on a predefined prompt i ## What it does - **Guides Claude to zero a todo list unattended** — one task at a time until all are completed, committed, and checkmarked. -- **Beats context rot** — session Stop hook SIGTERMs `claude` at ~80% of the context window and restarts clean. Fresh context, no quality decay. +- **Beats context rot** — session Stop hook SIGTERMs `claude` once its context total crosses a threshold (see [Context rot](#context-rot)) and restarts clean. Fresh context, no quality decay. - **One task per session** — `claude` exits once it has zeroed a single todo and the script restarts it, so every task runs on a context isolated from the task before it, which cuts token spend (~20% on a working instance). An instance that has nothing to claim waits in the shell without launching `claude` at all, spending nothing. - **Parallel by default** — run many instances at once; they coordinate via git worktrees, each claiming todos the others haven't taken. - **Safe merges** — cross-instance merge-back serialized through `flock`; no races, no corrupted base. @@ -120,15 +121,9 @@ Supported on **macOS and Linux** (the script is bash-3.2-safe, so stock macOS `b brew install flock # macOS; Linux ships it in util-linux ``` -2. **suggest-compact hook** — the context-full restart signal. Install globally in `~/.claude/settings.json`. Requires `node`. - - Source: https://github.com/affaan-m/ECC — pin commit `7777656` (known-good with this release; later commits may change the state-file name or path). - - ClaudeZero only *reads* the hook's state file — no hook edits needed. +This prerequisite is guard-checked at startup; the script exits with a clear message if it is missing. - **State-file contract.** ClaudeZero couples to one artifact the hook produces: a per-session file at `$TMPDIR/claude-context-bucket-` (`` is the Claude session id; `$TMPDIR` matches node's `os.tmpdir()`). The hook must create this file once the context bucket crosses its threshold. Content is not parsed — **presence alone is the "context full, restart" signal.** Any hook that writes that file, at that path, on threshold works; the pinned commit is just the version verified to do so. - -Both prerequisites are guard-checked at startup; the script exits with a clear message if either is missing. - -**Transcript-schema contract.** The token figures in the execution-stats report are a second coupling to Claude Code internals, alongside the state file above. ClaudeZero's own Stop hook records each session's `transcript_path` (a field of the hook payload it already parses `session_id` from), and after `claude` exits the loop reads those session JSONL transcripts and sums the `message.usage` fields of every assistant line: `input_tokens`, `output_tokens`, `cache_creation_input_tokens`, `cache_read_input_tokens` — the four categories Anthropic bills separately. One API request is written as several transcript lines, one per content block, each repeating the same `usage` object verbatim, so records are **deduped by the line's `requestId`**, and only the first (parent) match of each field name on a line is taken: `usage.iterations[]` repeats all four names one level down, and `usage.cache_creation` carries the `ephemeral_5m`/`ephemeral_1h` leaves that already sum into the parent. Transcripts are only ever read, and no schema change can fail a run — any parse miss prints `Tokens: n/a` and the run continues. +**Transcript-schema contract.** ClaudeZero couples to one thing in Claude Code internals: the session transcript's `message.usage` schema. ClaudeZero's own Stop hook records each session's `transcript_path` (a field of the hook payload), and reads it twice, for two different readers. The **context-rot guard** (below) reads the newest usage record on every turn to decide whether to restart. The **token report**, after `claude` exits, reads the whole transcript and sums the `message.usage` fields of every assistant line: `input_tokens`, `output_tokens`, `cache_creation_input_tokens`, `cache_read_input_tokens` — the four categories Anthropic bills separately. One API request is written as several transcript lines, one per content block, each repeating the same `usage` object verbatim, so the token report **dedupes by the line's `requestId`** — a rule that belongs to the token report alone, since the context-rot guard keeps only the latest record regardless of request. Both readers take only the first (parent) match of each field name on a line: `usage.iterations[]` repeats all four names one level down, and `usage.cache_creation` carries the `ephemeral_5m`/`ephemeral_1h` leaves that already sum into the parent. Transcripts are only ever read, and no schema change can fail a run — any parse miss on the token report prints `Tokens: n/a`, and any parse miss on the context-rot guard leaves the session running; either way the run continues. Two limits: **subagent tokens are invisible** — a session that used the Agent tool writes no `isSidechain` usage lines, so anything the task prompt spawns is missing from the totals, and the size of the under-count is not measurable from inside; and the figures are **per instance run, for this repo only**, unlike whole-machine tools such as `ccusage`, whose denominator is every Claude Code session on the box. @@ -149,6 +144,22 @@ GitHub-style Markdown checkboxes, one task per line. Each line carries a **uniqu `[ ]` = still to do, `[x]` = done (skipped). Before zeroing, the LLM validates the whole file: a task missing an id, or a duplicate id, stops the loop with a report. Checkboxes inside fenced code blocks (```` ``` ````) are ignored. +## Context rot + +The session Stop hook computes its own restart signal. Models whose plain id means a 1M window restart at **200.000** tokens; everything else restarts at **160.000**, 80% of an assumed 200k window. Matching is first-match-wins against the model id, falling to the 160.000 default when nothing matches. + +Why a threshold below the context window limit at all: **context rot**. A long session accumulates tool output, dead ends and superseded reasoning that stay in the window and compete for attention, so quality decays well before the window fills — ClaudeZero restarts earlier. Which is possible due to amnesia by design, durable external state carrying what mattered forward (see [Closing the loop](#closing-the-loop)). Restarting early is also the cheaper direction, since every turn re-sends the whole context; without a restart the re-orientation to another task costs tokens. + +The two numbers rest on different evidence: + +- **200.000 for a 1M window**, anchored on Opus 4.8, the only high-confidence figure. Its [system card §8.9](https://www-cdn.anthropic.com/0b4915911bb0d19eca5b5ee635c80fef830a37ea.pdf) reports GraphWalks BFS 85.9 @256k → 68.1 @1M and Parents 99.3 → 83.3, its harness compacts at 200k, and [CodeRabbit](https://www.coderabbit.ai/blog/opus-4-8-release) independently sees it "degrade visibly once context crosses 200k" + - Opus 4.6 and Sonnet 4.6 bracket the same knee on MRCR v2 + - Opus 5 and Sonnet 5 publish no depth-resolved eval, yet still compact at 200k, so "holds throughout 1M" is a claim with nothing measuring it + - Fable 5 and Mythos 5 are absent from the evidence entirely — one measured curve, four families inheriting it +- **160.000 for the 200k default** — 80% of the assumed window, and **inference, not measurement**: no 200k model publishes a long-context eval. Haiku 4.5's [system card](https://assets.anthropic.com/m/99128ddd009bdcb/original/Claude-Haiku-4-5-System-Card.pdf) only notes it "frequently encounter[s] physical context-window limits", putting its knee nearer 80–100k — so 160000 is the permissive end, and Haiku 4.5 the standing candidate for its own row. + +The model-threshold table is defined as `CONTEXT_THRESHOLDS` in the claudezero script. + ## Loop engineering [Loop engineering](https://claude.com/blog/getting-started-with-loops) shapes an agent's iteration cycle so it gets *better* across turns, not just runs once. It is the outermost of three nested levels — each one only works because the one under it holds: @@ -232,6 +243,12 @@ CLAUDEZERO_MAX_LOOPS=3 claudezero todo.md CLAUDEZERO_WATCHDOG=45m claudezero todo.md ``` +**`CLAUDEZERO_DEPENDENCY_WAIT`** — ceiling on the wait after a claude session walks the whole todo list and claims nothing because every unchecked task is dependency-blocked (step 2.a's independence judgment). Default `10m`; same grammar as `CLAUDEZERO_WATCHDOG` (`900`, `90s`, `15m`, `1h`), and `0` relaunches claude immediately every cycle, same as before this existed. Without it, a fully dependency-blocked list looks identical to a genuinely stuck one: a session launches, finds every remaining task blocked, ends its turn, `RESTART_WAIT` ticks down, another launches — same judgment, same nothing-claimed outcome, one claude session burned per cycle for zero possible progress. The session marks the block on its way out (`.git/zero.sh no-claim-mark`); the shell then waits, comparing a deterministic signature (the todo blob's SHA plus the sorted set of ids peers currently hold) instead of relaunching, and stops waiting the instant a peer merges the blocking task or its holder dies — or after this ceiling, whichever comes first, so a session always gets a chance to re-judge the list fresh. + +```sh +CLAUDEZERO_DEPENDENCY_WAIT=20m claudezero todo.md +``` + **`CLAUDEZERO_LINK`** — comma-separated top-level names symlinked from the repo root into every task worktree. Unset by default. A worktree is a checkout of tracked files only, so anything gitignored is absent there: if your todo lines point at spec files you keep in another git repository — `issues/ISSUE-031.md` holding the acceptance criteria for `- [ ] ISSUE-031 …` — the session never sees them and works from the one-line title alone. Listing the directory here links it in, so the criteria are readable and a tick lands in the real file rather than in a copy the worktree removal deletes. Each linked name is added to `.git/info/exclude`, so it stays out of the session's `git add -A` and out of this repository. ```sh diff --git a/SECURITY.md b/SECURITY.md index eb7440f..c42e7f6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -25,8 +25,6 @@ do anything I could do at this terminal." - **CLAUDE.md steers every run.** The reflection loop lets the agent append to it; a poisoned CLAUDE.md redirects all future tasks. Review its diffs like any other code. -- **The suggest-compact hook is third-party** (affaan-m/ECC) and runs in your - Claude session. Audit and pin it; ClaudeZero only reads its state file. ### Blast radius, and how it's bounded diff --git a/TEST.md b/TEST.md index dc0ef3c..0683a72 100644 --- a/TEST.md +++ b/TEST.md @@ -53,6 +53,17 @@ the project's own working tree or history, and can run concurrently. - **O — `CLAUDEZERO_LINK` into task worktrees.** Deterministic. Symlinks a gitignored directory into a task worktree, write-through, invisible to git via `info/exclude`, validated at startup before any claude launch. +- **P — the dependency-blocked wait (`CLAUDEZERO_DEPENDENCY_WAIT`).** Deterministic. A session + that walks the whole list and claims nothing marks the block (`no-claim-mark`); the shell + waits on the deterministic signature instead of relaunching blindly, breaks the instant a + box flips or a peer's marker goes stale, `0` disables the wait, and an unchanged signature + past the ceiling forces a relaunch anyway. +- **Q — the context-rot guard.** No claude. The Stop hook's own threshold table resolves a + restart signal from a fixture transcript: `[1m]` and each bare family id at 200000, a + word-suffix tier and unlisted ids at the 160000 default, `>=` at the exact threshold, row + order deciding overlapping rows, the marker row's backslash escapes surviving the `ENVIRON[]` + hand-off, the 256 KiB read cap degrading to a row miss past it, and every degraded input + (missing/unreadable/usage-free transcript) leaving the session running. Parallelism (A, C) is enforced with a **file-lock barrier**, not `sleep`, so the proof is independent of claude startup/shutdown times. @@ -75,13 +86,12 @@ it did, refusing instead of silently proceeding on a miss. can run it autonomously and report the results. **Prerequisites:** `flock`, `uuidgen`, `timeout`, `git`, `date`, `find`, `stat`, `mv` -on PATH. A and C need real `claude` on PATH and the `suggest-compact` hook installed in -`~/.claude/settings.json` (claudezero.sh refuses to start without it — if A/C logs show -an immediate hook error, report "prerequisite missing — suggest-compact hook"). B, E, F and -G do **not** need real claude but still need `flock` and the `suggest-compact` hook (both -startup guards run before any claude launch; E/F/G supply a stub `claude` so claudezero -writes `zero.sh` and loops out at once). A missing hook makes them exit with the wrong -message and their assertions fail. +on PATH — `flock` is the one remaining external prerequisite claudezero.sh's own startup guard +checks. A and C need real `claude` on PATH. B, E, F and G do **not** invoke claude but still +need it present on PATH: `run_doctor` tests `command -v claude` before any startup guard runs, +so a missing `claude` fails them at the wrong step (E/F/G additionally supply a stub `claude` +so claudezero writes `zero.sh` and loops out at once; B needs none beyond the PATH check since +every case there refuses before a launch). Inform about progress during the test; at the end return a summary report of passes, fails, and causes. @@ -405,14 +415,48 @@ git -C "$TB/worktree" branch --list '*-task-*-task-*' | grep -q . && echo "B4 FA cd "$TB/untracked" # clean repo, but the todo is not in the base tree if out=$(timeout 20 bash "$SCRIPT" todo.md -t x 2>&1); then rc=0; else rc=$?; fi { [ "$rc" != 0 ] && echo "$out" | grep -qi 'not tracked'; } && echo "B5 untracked-guard PASS" || echo "B5 FAIL (rc=$rc): $out" + +# run_doctor coverage: no ~/.claude/settings.json is a prerequisite anymore, and --doctor is +# the same code path a normal startup runs first. `mkbin DIR tool…` symlinks only the named +# tools into an otherwise-empty dir, so PATH=DIR alone proves a tool's true absence — --doctor +# exits before main() reaches any of git/sed/awk/uuidgen/etc, so `basename`, `mktemp` and +# (conditionally) `claude`/`flock` are the whole surface it needs. +mkbin(){ local d="$1"; shift; mkdir -p "$d"; local t p; for t in "$@"; do p=$(command -v "$t" 2>/dev/null) || continue; ln -sf "$p" "$d/$t"; done; } +BASH_BIN="$(command -v bash)" # PATH=bin-no* below excludes bash itself; invoke it by absolute path +mkdir -p "$TB/emptyhome" # HOME with no ~/.claude directory at all + +if out=$(HOME="$TB/emptyhome" bash "$SCRIPT" --doctor 2>&1); then rc=0; else rc=$?; fi +{ [ "$rc" = 0 ] && echo "$out" | grep -qi 'all prerequisites OK'; } && echo "B6 doctor-no-settings PASS" || echo "B6 FAIL (rc=$rc): $out" + +cd "$TB/dirty" # same dirty tree as B1, now under the empty HOME +if out=$(HOME="$TB/emptyhome" timeout 20 bash "$SCRIPT" todo.md -t x 2>&1); then rc=0; else rc=$?; fi +{ [ "$rc" != 0 ] && echo "$out" | grep -qi dirty; } && echo "B7 startup-no-settings PASS" || echo "B7 FAIL (rc=$rc): $out" + +mkbin "$TB/bin-noclaude" basename mktemp flock +if out=$(HOME="$TB/emptyhome" PATH="$TB/bin-noclaude" "$BASH_BIN" "$SCRIPT" --doctor 2>&1); then rc=0; else rc=$?; fi +{ [ "$rc" != 0 ] && echo "$out" | grep -qi 'claude CLI not found'; } && echo "B8 doctor-no-claude PASS" || echo "B8 FAIL (rc=$rc): $out" + +mkbin "$TB/bin-noflock" basename mktemp claude +if out=$(HOME="$TB/emptyhome" PATH="$TB/bin-noflock" "$BASH_BIN" "$SCRIPT" --doctor 2>&1); then rc=0; else rc=$?; fi +{ [ "$rc" != 0 ] && echo "$out" | grep -qi 'flock not found'; } && echo "B9 doctor-no-flock PASS" || echo "B9 FAIL (rc=$rc): $out" + +mkbin "$TB/bin-badflock" basename mktemp claude +printf '#!/usr/bin/env bash\nexit 1\n' > "$TB/bin-badflock/flock"; chmod +x "$TB/bin-badflock/flock" +if out=$(HOME="$TB/emptyhome" PATH="$TB/bin-badflock" "$BASH_BIN" "$SCRIPT" --doctor 2>&1); then rc=0; else rc=$?; fi +{ [ "$rc" != 0 ] && echo "$out" | grep -qi 'flock present but not runnable'; } && echo "B10 doctor-flock-broken PASS" || echo "B10 FAIL (rc=$rc): $out" ``` -- **B PASS** — B1, B2, B3, B4, and B5 all report PASS (non-zero exit + the expected message, - before any claude launch), and no `*-task-*-task-*` branch exists. B3 proves the - worktree-path assumption is enforced: a subdir launch refuses rather than misfiring - `../ts-*` paths. B4 proves the same for a launch *inside* a leftover claim worktree, where - the base branch would otherwise be poisoned to a peer's claim and both instances would take - the same todo (BUG-014). B5 proves a todo the base branch does not track refuses up front - instead of letting every merge be refused for checking zero boxes (BUG-026). +- **B PASS** — B1 through B10 all report PASS (non-zero exit + the expected message, before + any claude launch — B6/B7 exit 0/nonzero respectively), and no `*-task-*-task-*` branch + exists. B3 proves the worktree-path assumption is enforced: a subdir launch refuses rather + than misfiring `../ts-*` paths. B4 proves the same for a launch *inside* a leftover claim + worktree, where the base branch would otherwise be poisoned to a peer's claim and both + instances would take the same todo (BUG-014). B5 proves a todo the base branch does not + track refuses up front instead of letting every merge be refused for checking zero boxes + (BUG-026). B6 proves `--doctor` no longer needs `~/.claude/settings.json`; B7 proves a + normal startup from the same `HOME` gets past that same prerequisite check and refuses on + the dirty-tree guard instead, so both paths run the same `run_doctor` code. B8, B9 and B10 + prove `--doctor` still refuses, with its existing message, when `claude` is absent, when + `flock` is absent, and when `flock` is present but not runnable. --- @@ -463,19 +507,27 @@ grep -Eril 'conflict|merge fail|resolve|stop' "$TC"/log_*.txt >/dev/null && echo --- -## Scenario D — foreign check-off refusal + self-heal `[$TESTROOT/D]` - -One agent, two tasks. The `-t` prompt **induces** the agent to tick a SECOND checkbox -(a task it does not own) and ignore step 2.d's touch-no-other-line rule, so the foreign tick -reaches the merge. `merge_task` enforces the one-box invariant on **every** merge (not -just conflicting ones), refuses with a `checkbox-merge: refused` pointer listing the -offending `file:line`s, and step 2.e self-heals: uncheck the foreign line, amend, retry — -then the merge lands. No parallelism, no gate; a single instance triggers it because the -branch carries two check-offs vs its fork point. +## Scenario D — foreign check-off refusal + self-heal `[$TESTROOT/D]` (scripted setup, real claude for merge+heal) + +The foreign double-tick is fabricated directly, not induced through a real agent: a +natural-language prompt asking an agent to deliberately violate the touch-no-other-line +rule trips this account's `--permission-mode auto` classifier (it silently denies the +very edit/commit the inducement needs, the CLI prints `Execution error`, and the session +hangs until the outer timeout kills it — the classifier ends up an accidental guard +against the exact bad edit this scenario needs to happen). So the violation is scripted +via `zero.sh claim` plus direct commits, reproducing exactly the state a misbehaving +agent would leave. Only the part actually worth testing with a real agent — noticing the +refusal and following its pointer to self-heal — runs through a real, narrowly-scoped +`claude -p` call under `--permission-mode bypassPermissions`: safe here (confined to a +throwaway repo under `$TESTROOT`), and that mode has no classifier to trip at all. +`merge_task` enforces the one-box invariant on **every** merge (not just conflicting +ones), refuses with a `checkbox-merge: refused` pointer listing the offending +`file:line`s, and step 2.e self-heals: uncheck the foreign line, amend, retry — then the +merge lands. ### Setup ```bash -H="$TESTROOT/D"; mkdir -p "$H/repo" +H="$TESTROOT/D"; mkdir -p "$H/repo" "$H/bin" cd "$H/repo" git init -q -b master; git config user.email t@t.t; git config user.name test U1=$(uuidgen); U2=$(uuidgen) @@ -483,36 +535,57 @@ U1=$(uuidgen); U2=$(uuidgen) echo "- [ ] T1 Create file markers/$U1.done with the single line \`agent=