From 71e1f270aadbe321c8c3893d5f6451cec8a1a545 Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Sun, 2 Aug 2026 15:58:39 +0200 Subject: [PATCH 01/22] Update readme. --- README.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 90cdd65..078ac16 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,10 @@

Todo-list sensei for Claude Code. Zeros your list.

-Loops claude until every todo is done, committed, and checked off.
-Restarts it on a fresh context before rot.
-Spawn many instances to parallelize. +Loops Claude until every todo is done, committed, and checked off.
+Restarts each one on a fresh context before rot.
+You can spawn multiple instances to parallelize.
+It's for practical Loop engineering.

@@ -40,13 +41,19 @@ Runs [`claude`](https://claude.com/product/claude-code) on a predefined prompt i ## Quickstart -Change to your repo root with a todo-list file, and make sure the working tree is in a clean state (commit or stash any changes) and the todo file itself is committed on that branch. Then run `claudezero` pointing to your todo-list: +``` +brew install IvanRublev/tap/claudezero +``` + +Change to your repo root with a todo-list file, and make sure the working tree is in a clean state (commit or stash any changes) and the todo file itself is committed on that branch. + +Then run `claudezero` pointing to your todo-list: ``` claudezero todo.md ``` -Run that command in multiple parallel terminals to work through the todos faster. +You can run that command in multiple parallel terminals to work through the todos faster. > ⚠️ ClaudeZero runs `claude` **unattended with permissions auto-approved** and **commits on its own** to the branch you launch it on. Only ever point it at a todo file you wrote or reviewed, on a branch with a clean, committed tree — git is your only undo. From 2ff3f8ee1cca4603dec3896f46f8dd15d7a5d25c Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Sun, 2 Aug 2026 15:58:39 +0200 Subject: [PATCH 02/22] test: close the bash 3.2 and fd 4 coverage gaps (BUG 024, BUG 025) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both fixes shipped in 0.0.15 without the test scaffolding their issues specified, so the constructs that caused them had no executable guard. TEST.md Scenario S: split S3 into S3a and S3b. The old single step gated on `/bin/bash` being 3.2, so it SKIPped on every non-macOS host — silent exactly where the bug is easiest to reintroduce. S3a is a text scan for a here-doc opened inside `$( … )`: it tracks quote state, skips comments and here-doc bodies, ignores `$(( ))` and `<<<`, and never SKIPs. A self-test rebuilds the pre-fix construct and asserts the scan rejects it, so the check is proven to catch the bug rather than merely to pass beside it. S3b runs a real bash 3.x parse over claudezero.sh plus the three emitted scripts, reports the version, SKIPs rather than FAILs with no bash 3.x, and degrades to a note when S2 never emitted. TEST.md Scenario G2: automated with script(1), which supplies the pty. A helper picks the BSD or util-linux argument order so no one has to choose. The split is asserted in both directions — claude's TUI frames reach the terminal and never the capture file; ClaudeZero's own report reaches the capture file and, on the redirect form, never the terminal. The `tee` form cannot carry that second assertion, since echoing to the terminal is what tee is for. G3 keeps the real-claude kqueue check manual: no stub reproduces a Node runtime rejecting the descriptor. CONTRIBUTING.md: state that claudezero.sh must parse under bash 3.2, and name the here-doc-inside-command-substitution construct as forbidden beside the `read -r -d ''` form that replaces it. claudezero.sh is unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- CONTRIBUTING.md | 17 +++++ TEST.md | 166 +++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 166 insertions(+), 17 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5c885d7..e223fd0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,6 +7,23 @@ Thanks for helping to improve ClaudeZero. Even snow leopards sharpen their claws 2. **Make your change.** Keep the diff surgical — match the existing style in `claudezero.sh`. Update the README and `TEST.md` if behavior changes. + **`claudezero.sh` must parse under bash 3.2** — macOS ships it as `/bin/bash`, + and a parse error there kills the script before line one runs. Avoid: + + ```sh + prompt="$(cat <<'EOF' # here-doc inside $( … ) — bash 3.2 cannot parse it + ... + EOF + )" + + IFS= read -r -d '' prompt <<'EOF' || : # use this instead + ... + EOF + ``` + + `TEST.md` Scenario S guards it: S3a scans for the construct on any host, + S3b parses with a real bash 3.x. Locally: `/bin/bash -n claudezero.sh`. + 3. **Keep the CI scripts in sync with `claudezero.sh`.** Two scripts under `.github/` mirror details of `claudezero.sh` and drift silently if you don't update them: diff --git a/TEST.md b/TEST.md index ed95469..2d1dadc 100644 --- a/TEST.md +++ b/TEST.md @@ -89,8 +89,9 @@ chmod +x "$1"; } ## Scenario S — static checks (shellcheck + bash 3.2 syntax) (no claude) -Lints claudezero.sh (S1) **and the scripts it emits at runtime** (S2), then parses the -script with stock macOS bash (S3). The emitted +Lints claudezero.sh (S1) **and the scripts it emits at runtime** (S2), then checks the +bash 3.2 constraint — structurally on any host (S3a) and by a real bash 3.x parse where one +exists (S3b). The emitted scripts live in single-quoted heredocs, invisible to a lint of claudezero.sh — that blind spot once shipped an unbalanced quote in `zero.sh`. `CLAUDEZERO_TEST_EMIT=1` runs init for real in a throwaway repo (writes the scripts, exits before claude), then @@ -120,17 +121,100 @@ fi S3 — bash 3.2 syntax. macOS ships bash 3.2 as `/bin/bash` and that is what a Homebrew install runs, but bash 3.2 has parse-time limits bash 5 does not (a heredoc inside `$(…)` -is one; it once made the whole script unparsable). Parse-only, no execution. +is one; it once made the whole script unparsable). Parse-only, no execution. Two steps: +S3a catches the construct on **any** host, S3b runs a real bash 3.x parse where one exists. + +S3a — the construct, structurally. A Linux-only CI leg has no bash 3.2 to parse with, so a +`/bin/bash -n` gate alone goes silent exactly where the bug is easiest to reintroduce. This +scan is pure text: it tracks quote state, skips comments and here-doc bodies, ignores `$(( ))` +and `<<<`, and reports any here-doc opened inside a command substitution, by line and +delimiter. It never SKIPs. + +```sh +mkdir -p "$TESTROOT/S" +cat > "$TESTROOT/S/heredoc-scan.awk" <<'SCAN_EOF' +{ + if (await != "") { # inside a here-doc body: only the delimiter ends it + t = $0; sub(/^[ \t]+/, "", t) + if ($0 == await || t == await) await = "" + next + } + n = length($0) + for (i = 1; i <= n; i++) { + c = substr($0, i, 1) + if (sq) { if (c == "'") sq = 0; continue } + if (c == "\\") { i++; continue } + if (substr($0, i, 3) == "$((") { arith++; i += 2; continue } + if (substr($0, i, 2) == "$(") { stack[++subst] = dq; dq = 0; i += 1; continue } + if (c == ")") { + if (arith > 0 && substr($0, i+1, 1) == ")") { arith--; i++ } + else if (subst > 0) { dq = stack[subst--] } + continue + } + if (dq) { if (c == "\"") dq = 0; continue } + if (c == "'") { sq = 1; continue } + if (c == "\"") { dq = 1; continue } + if (c == "#" && (i == 1 || substr($0, i-1, 1) ~ /[ \t;&|(]/)) break + if (substr($0, i, 3) == "<<<") { i += 2; continue } + if (substr($0, i, 2) == "<<") { + j = i + 2 + if (substr($0, j, 1) == "-") j++ + while (substr($0, j, 1) == " ") j++ + d = ""; q = substr($0, j, 1) + if (q == "'" || q == "\"") { j++; while (j <= n && substr($0, j, 1) != q) { d = d substr($0, j, 1); j++ } } + else { while (j <= n && substr($0, j, 1) ~ /[A-Za-z0-9_]/) { d = d substr($0, j, 1); j++ } } + if (subst > 0) { printf "%s:%d: here-doc <<%s opened inside $( … )\n", FILENAME, FNR, d; bad = 1 } + if (d != "") await = d + i = j - 1 + continue + } + } +} +END { exit (bad ? 1 : 0) } +SCAN_EOF +SCAN="awk -f $TESTROOT/S/heredoc-scan.awk" +$SCAN "$SCRIPT" && echo "S3a PASS — no here-doc inside \$( … )" || echo "S3a FAIL — findings above" + +# self-test: the scan must FAIL on the pre-fix form, or it is only passing beside the bug. +# The fix predates this repo's history, so `git show HEAD~1:claudezero.sh` has nothing to +# catch — rebuild the offending construct from the current script instead. +sed "s#^ IFS= read -r -d '' prompt <<'PROMPT_EOF' || :# local prompt=\"\$(cat <<'PROMPT_EOF'#" \ + "$SCRIPT" > "$TESTROOT/S/unfixed.sh" +$SCAN "$TESTROOT/S/unfixed.sh" >/dev/null \ + && echo "S3a SELFTEST FAIL — scan passed the unfixed script" \ + || echo "S3a SELFTEST PASS — $($SCAN "$TESTROOT/S/unfixed.sh" | head -1)" +``` + +S3b — a real bash 3.x parse, where the host has one. Covers the emitted scripts too: they +are written at runtime, so a bash-3.2 parse error inside `zero.sh` never shows up in a parse +of `claudezero.sh`. Needs S2 to have emitted them; a no-op if it did not. ```sh -if /bin/bash --version 2>/dev/null | head -1 | grep -q 'version 3\.2'; then - /bin/bash -n "$SCRIPT" && echo "S3 PASS — parses under bash 3.2" || echo "S3 FAIL — errors above" +B3="" +for c in /bin/bash /usr/local/bin/bash-3.2 /opt/homebrew/bin/bash-3.2; do + [ -x "$c" ] && "$c" --version 2>/dev/null | head -1 | grep -q 'version 3\.' && { B3="$c"; break; } +done +if [ -z "$B3" ]; then + echo "S3b SKIP — no bash 3.x on this host (S3a already covered the construct)" else - echo "S3 SKIP — /bin/bash is not 3.2 (not macOS)" + ver="$("$B3" --version | head -1 | sed -n 's/.*version \([0-9.]*\).*/\1/p')" + rc=0; files="$SCRIPT" + TS="$TESTROOT/S/repo" + if [ -d "$TS/.git" ]; then + for f in compact-exit-hook.sh zero.sh checkbox-merge.sh; do + [ -f "$TS/.git/$f" ] && files="$files $TS/.git/$f" + done + else + echo "S3b NOTE — S2 did not emit (no shellcheck); parsing claudezero.sh only" + fi + for f in $files; do "$B3" -n "$f" || rc=1; done + n=$(echo $files | wc -w | tr -d ' ') + [ "$rc" = 0 ] && echo "S3b PASS — parses under bash $ver ($n file$([ "$n" = 1 ] || echo s))" \ + || echo "S3b FAIL — errors above (bash $ver)" fi ``` - **S PASS** — S1 and S2 both PASS (claudezero.sh clean, all three emitted scripts clean), - and S3 PASS or SKIP. + S3a PASS with its SELFTEST PASS, and S3b PASS or SKIP. --- @@ -601,21 +685,69 @@ echo "G1 own output kept : $(grep -c '❄ ClaudeZero' "$TG/run.log") (want >=1 Redirecting stdin explicitly matters — run from a terminal without `< /dev/null`, the probe succeeds and the stub's bytes go to the terminal by design, which is the whole point of the split. -### G2 — the split itself (manual, needs a terminal) +### G2 — the split itself (automated, `script(1)` supplies the pty) + +The operator's situation is stdin on a terminal, stdout on a pipe. `script(1)` allocates a pty +and logs everything crossing it, so running the documented pipe form under it captures **both** +sides at once: fd 4 lands in the typescript, ClaudeZero's own stdout in the pipe capture. The +two `script` argument orders (BSD positional, util-linux `-c`) are picked by a helper, so no +one has to choose. + +```bash +TG="$TESTROOT/G2"; mkdir -p "$TG/repo" "$TG/bin" +cat > "$TG/bin/claude" <<'EOF' +#!/usr/bin/env bash +if [ -t 0 ]; then a=yes; else a=no; fi +if [ -t 1 ]; then b=yes; else b=no; fi +echo "STUB TTY0=$a TTY1=$b" +printf 'TUI-FRAME \033[1mbold\033[0m\n' +exit 0 +EOF +chmod +x "$TG/bin/claude" +cd "$TG/repo" +git init -q -b main; git config user.email t@t.t; git config user.name test +printf -- '- [ ] G2 x\n' > todo.md; git add -A; git commit -qm init +# BSD `script -q FILE CMD...` vs util-linux `script -q -c CMD FILE` — detect, don't choose. +pty() { if script --version 2>&1 | grep -qi util-linux + then script -qec "$1" "$2"; else script -q "$2" bash -c "$1"; fi; } +export PATH="$TG/bin:$PATH" # inherited by the pty child — keeps the command string quote-free +pty "CLAUDEZERO_MAX_LOOPS=1 bash '$SCRIPT' todo.md -t x 2>&1 | { trap '' INT; tee '$TG/pipe.log'; }" \ + "$TG/typescript" >/dev/null 2>&1 +tr -d '\r' < "$TG/typescript" > "$TG/tty.log" # pty writes CRLF; strip before matching +echo "G2 stub sees ttys : $(grep -o 'STUB TTY0=[a-z]* TTY1=[a-z]*' "$TG/tty.log" | head -1) (want both yes)" +echo "G2 TUI on tty : $(grep -c 'TUI-FRAME' "$TG/tty.log") (want >=1)" +echo "G2 TUI not in pipe: $(grep -c 'TUI-FRAME' "$TG/pipe.log") (want 0)" +echo "G2 report in pipe : $(grep -c 'execution stats' "$TG/pipe.log") (want >=1)" +echo "G2 no escapes : $(grep -c $'\033' "$TG/pipe.log") (want 0)" + +# The other direction. Under `tee` the report reaches BOTH the file and the terminal — that is +# what tee is for — so "own output stays off the tty" can only be asserted on the redirect form, +# where stdout is the file alone. fd 4 still splits the TUI to the terminal. +pty "CLAUDEZERO_MAX_LOOPS=1 bash '$SCRIPT' todo.md -t x > '$TG/redir.log' 2>&1" \ + "$TG/typescript2" >/dev/null 2>&1 +tr -d '\r' < "$TG/typescript2" > "$TG/tty2.log" +echo "G2 report in file : $(grep -c 'execution stats' "$TG/redir.log") (want >=1)" +echo "G2 report off tty : $(grep -c 'execution stats' "$TG/tty2.log") (want 0)" +echo "G2 TUI still tty : $(grep -c 'TUI-FRAME' "$TG/tty2.log") (want >=1)" +``` +- **G2 PASS** — all eight as stated. `stub sees ttys = yes yes` proves fd 4 is a real terminal + descriptor (a dup of stdin), not a pipe. The split is asserted in both directions: claude's + `TUI-FRAME` reaches the terminal and never the capture file, and ClaudeZero's own `❄` report + reaches the capture file and — on the redirect form, where `tee` is not echoing it back — + never the terminal. No escape byte reaches the file. + +### G3 — the kqueue regression (manual, needs a real `claude`) -Not scripted: it needs a real terminal and a real `claude`. Since fd 4 is a dup of stdin, no -`script(1)` wrapper is needed — run the documented pipe form by hand in a scratch repo: +G2 proves the descriptor is a tty and the split holds, but a stub cannot reproduce what this +descriptor choice exists for: claude's Node runtime rejecting a `/dev/tty`-derived fd with +`EINVAL … kqueue`. Only the real binary can. Run the documented form by hand in a scratch repo: ```bash ./claudezero.sh issues/todo.md 2>&1 | { trap '' INT; tee run.log; } ``` -Stdin is the terminal (so `[ -t 0 ]` holds) and stdout is the pipe (so fd 1 is not a tty) — -exactly the operator's situation. This is also the macOS regression check this descriptor choice -exists for: a real `claude` must reach its prompt instead of dying with `EINVAL … kqueue`, which -is what a fresh open of `/dev/tty` on fd 4 caused. -- **G2 PASS** — `run.log` holds the `❄` banner, reports, and loop notices and no TUI frames - (`grep -c $'\033' run.log` is `0`), the terminal shows both streams, and pressing Ctrl+C in - the between-runs gap still lands the closing report in `run.log`. +- **G3 PASS** — a real `claude` reaches its prompt instead of dying at startup, the terminal + shows the TUI, and pressing Ctrl+C in the between-runs gap lands the closing report in + `run.log`. --- From be0409fd877bc5104651bb8c0b0631bcdc1cead4 Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Sun, 2 Aug 2026 15:58:39 +0200 Subject: [PATCH 03/22] fix: make an issue's acceptance criteria the definition of done in the zero prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step c told the agent to implement task_id and to scope its edits, but never said what "done" means when the task line points at an issue file. Nothing instructed it to satisfy the acceptance criteria, and nothing instructed it to tick them. Issues consequently landed marked complete with criteria unmet, and in two cases fully ticked with no implementation behind them at all. Step c now reads the spec first and constrains edits second. The order matters: "scope every edit to task_id only" is a constraint on a boundary the criteria are what define, and criteria routinely reach files the task title never names — BUG 024's script fix landed while its TEST.md and CONTRIBUTING.md criteria did not. Reading the constraint first invites exactly that under-scoping. Ticking is bounded by "never ahead of verifying it": an instruction that only says "tick as you go" reproduces the failure it is meant to prevent. Applies only when the task line names an issue/spec file; a plain todo is unaffected. The merge gate is unaffected too — checkbox-merge diffs the todo file alone, so boxes ticked in an issue file are invisible to it. Co-Authored-By: Claude Opus 5 (1M context) --- claudezero.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/claudezero.sh b/claudezero.sh index 5ef49c0..e0e3854 100755 --- a/claudezero.sh +++ b/claudezero.sh @@ -1028,7 +1028,10 @@ Keep these facts in mind for every iteration: → skip to the next task_id. The reason is printed on stderr. - exit 0 → you OWN task_id; its git worktree is at $wt. Continue to step c. Do NOT trust the box in `$wt/@@TODO@@` — the claim already re-checked @@BASE_BRANCH@@. - c. IMPLEMENT task_id. Scope every edit to THIS task only before you skip to the next one; never touch + c. IMPLEMENT task_id. If task_id's line names an issue/spec file, its acceptance criteria define + done for this task — they may span files the task title never mentions: satisfy each and tick + it there (`[ ]`→`[x]`) as you land it, never ahead of verifying it. + Scope every edit to task_id only before you skip to the next one; never touch another task, even one you will process later this same pass (you reach the next task_id at step e — this is per-task, not per-session). @@LOOPPROMPT@@ From adf0bba00ce1d9ed0dfefad50bb34da381911204 Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Sun, 2 Aug 2026 15:58:39 +0200 Subject: [PATCH 04/22] Bump version, add tasks --- claudezero.sh | 2 +- todo.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/claudezero.sh b/claudezero.sh index e0e3854..0b01a3f 100755 --- a/claudezero.sh +++ b/claudezero.sh @@ -13,7 +13,7 @@ # Run -h for usage. set -euo pipefail -VERSION="0.0.15" +VERSION="0.0.16" PROG="$(basename "$0")" # name shown in usage/errors, from how the script was invoked LOOP_INTERVAL="3m" # cadence claude reschedules its zeroing pass at (baked into the /loop prompt) diff --git a/todo.md b/todo.md index ae3320f..a101975 100644 --- a/todo.md +++ b/todo.md @@ -15,3 +15,5 @@ - [x] BUG-026 Refuse a zero-mode launch when the todo is not tracked on the base branch — untracked today means every merge is refused and nothing can ever land - [x] ISSUE-027 Print the instance nickname next to its id in the execution stats header - [x] ISSUE-028 Show the script version on its own line below the usage heading in `--help` +- [ ] BUG-029 Background claude and trap TERM so a SIGTERM while claude hangs still lands at the closing report and exits 143 +- [ ] ISSUE-030 Surface claude's own exit code on the restart/stop lines and add an opt-in `CLAUDEZERO_DEBUG` `--debug-file` capture From 487419e98e19b491f4e23dbde50fbdcea93aa939 Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Sun, 2 Aug 2026 17:40:45 +0200 Subject: [PATCH 05/22] fix: background claude and trap TERM so a hang still lands at the closer (BUG-029) Bash defers every trap while waiting on a foreground child, so a SIGTERM arriving while claude hung could not be handled until the follow-up SIGKILL ended the run mid-wait: no closing report, no fleet TOTAL, and a leaked instance marker. Launch claude backgrounded and reap it with a re-entrant `wait` so a trapped signal interrupts the wait instead of being deferred. The TERM handler sets STOP (loop breaks to the existing closer), sets TERMED, and forwards TERM to claude so a hung child dies with us. The closer exits 143 when TERMED, as an `if` so a normal run does not leak status 1 through `set -e`. TEST.md Scenario K covers the hang (exit 143, report present, no orphan) and the two paths that must not change: a clean exit still ends 0, and claude's own 143 still restarts. --- TEST.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ claudezero.sh | 27 +++++++++++++++++++++++++-- todo.md | 2 +- 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/TEST.md b/TEST.md index 2d1dadc..8a794cf 100644 --- a/TEST.md +++ b/TEST.md @@ -1048,6 +1048,55 @@ echo "J2 loop mode : $(PATH="$TJ/bin:$PATH" timeout 40 env CLAUDEZERO_MAX_LO --- +## Scenario K — SIGTERM while claude hangs `[$TESTROOT/K]` (stub claude, deterministic) + +A supervisor / `timeout` / `kill` stopping the run must still land at the closer. The stub +hangs forever, so the wrapper only survives the TERM if claude is backgrounded and reaped by +an interruptible `wait` — a foreground child would defer the trap until the KILL. The same +stub set proves the two paths this must not change: a clean exit still ends 0, and a stub +exiting 143 (the Stop hook's restart signal) still restarts with `TERMED` unset. + +### Setup +```bash +TK="$TESTROOT/K"; mkdir -p "$TK/repo" "$TK/bin" +cd "$TK/repo" +git init -q -b main; git config user.email t@t.t; git config user.name test +printf -- '- [ ] K1 x\n' > todo.md; git add -A; git commit -qm init +``` + +### K1 — the hang: closer still runs, exit 143, no orphan child +```bash +cd "$TK/repo" +printf '#!/usr/bin/env bash\necho "Execution error"\nsleep 1000\n' > "$TK/bin/claude"; chmod +x "$TK/bin/claude" +PATH="$TK/bin:$PATH" timeout --preserve-status -k 20 8 env CLAUDEZERO_MAX_LOOPS=1 bash "$SCRIPT" todo.md -t x > "$TK/hang.log" 2>&1 +echo "K1 exit : $? (want 143 = 128+15; plain \`timeout\` would report its own 124)" +echo "K1 stats : $(grep -c 'execution stats' "$TK/hang.log") (want 1 — the report the TERM used to eat)" +echo "K1 stopped : $(grep -c 'run loop stopped' "$TK/hang.log") (want 1)" +echo "K1 orphans : $(pgrep -f "$TK/bin/claude" | wc -l | tr -d ' ') (want 0 — the TERM was forwarded to claude)" +echo "K1 one TERM trap : $(grep -c '^trap .*TERM$' "$SCRIPT") (want 1 — a second handler would silently replace this one)" +echo "K1 backgrounded : $(grep -c 'name "\$SESSION_NAME" "\$PROMPT" >&4 2>&4 &$' "$SCRIPT") (want 1 — no foreground launch, no \`|| true\` swallow)" +``` +- **K1 PASS** — `exit = 143`, `stats = 1`, `stopped = 1`, `orphans = 0`, + `one TERM trap = 1`, `backgrounded = 1`. + +### K2 — the two paths that must not change +```bash +cd "$TK/repo" +printf '#!/usr/bin/env bash\necho "stub ran"\nexit 0\n' > "$TK/bin/claude"; chmod +x "$TK/bin/claude" +PATH="$TK/bin:$PATH" timeout 40 env CLAUDEZERO_MAX_LOOPS=1 bash "$SCRIPT" todo.md -t x > "$TK/ok.log" 2>&1 +echo "K2 normal exit : $? (want 0 — TERMED=0 must not leak a status through set -e)" +echo "K2 normal stats : $(grep -c 'execution stats' "$TK/ok.log") (want 1)" +printf '#!/usr/bin/env bash\necho "stub run"\nexit 143\n' > "$TK/bin/claude"; chmod +x "$TK/bin/claude" +PATH="$TK/bin:$PATH" timeout 60 env CLAUDEZERO_MAX_LOOPS=2 bash "$SCRIPT" todo.md -t x > "$TK/restart.log" 2>&1 +echo "K2 restart exit : $? (want 0 — claude's own 143 is the Stop hook path, not ours)" +echo "K2 restart runs : $(grep -c 'stub run' "$TK/restart.log") (want 2 — the wait's re-check found it dead and looped)" +echo "K2 restart line : $(grep -c 'restarting in' "$TK/restart.log") (want 1)" +``` +- **K2 PASS** — `normal exit = 0` with `normal stats = 1`, and + `restart exit = 0` with `restart runs = 2`, `restart line = 1`. + +--- + ## Run all in parallel (optional) After Section 0 and each Setup, launch the Run blocks together: put A's and C's run diff --git a/claudezero.sh b/claudezero.sh index 0b01a3f..0870085 100755 --- a/claudezero.sh +++ b/claudezero.sh @@ -114,7 +114,8 @@ MAX_LOOPS="${CLAUDEZERO_MAX_LOOPS:-0}" # shell was handed is a real terminal fd, opened read-write, so it takes writes and kqueue both. if [ ! -t 1 ] && [ -t 0 ]; then exec 4>&0; else exec 4>&1; fi LOOP_COUNT=0 -STOP=0 # set by the INT trap; the loop breaks to the closer below +STOP=0 # set by the INT/TERM traps; the loop breaks to the closer below +TERMED=0 # set by the TERM trap; makes the closer exit 143 instead of 0 TODOS_BASE=$(read_counter "${TODOS_TIME_FILE:-}") # snapshot: report only THIS run's slice of the shared aggregates TODOS_DONE_BASE=$(read_counter "${TODOS_DONE_FILE:-}") LOOP_START=$(date +%s) # script loop (outer while loop) starts here @@ -124,12 +125,28 @@ LOOP_START=$(date +%s) # script loop (outer while loop) starts here # the timing vars so the report can read them. trap 'STOP=1' INT +# SIGTERM (supervisor shutdown, `timeout`, `kill`) requests the same clean stop, and additionally +# forwards the signal to claude — a hung child must not outlive us — and records TERMED so the +# closer can exit 143. Bash keeps one handler per signal, so further TERM work chains into here. +on_term() { + STOP=1 + TERMED=1 + [ -n "${CLAUDE_PID:-}" ] && kill -TERM "$CLAUDE_PID" 2>/dev/null + return 0 +} +trap on_term TERM + reap_dead_sessions # startup: clear markers left by crashed prior runs before the first claude while true; do # first prompt submitted straight from the CLI arg. The session Stop hook SIGTERMs claude # when context fills; exit 143 is the normal restart path, so swallow it. CLAUDEZERO_INSTANCE="$INSTANCE_ID" CLAUDEZERO_TRANSCRIPTS="$TRANSCRIPTS_FILE" \ - claude --settings "$STOP_SETTINGS" --permission-mode auto --name "$SESSION_NAME" "$PROMPT" >&4 2>&4 || true + claude --settings "$STOP_SETTINGS" --permission-mode auto --name "$SESSION_NAME" "$PROMPT" >&4 2>&4 & + CLAUDE_PID=$! + # backgrounded on purpose: bash defers every trap until a FOREGROUND child exits, so a TERM + # arriving while claude hangs could never be handled. `wait` IS interruptible — it returns + # 128+N when a trapped signal fires — so re-enter it until claude is actually gone. + until wait "$CLAUDE_PID"; do kill -0 "$CLAUDE_PID" 2>/dev/null || break; done # claude killed mid-run (Ctrl+C/SIGTERM) can leave the tty in raw mode with ISIG off; then every # later Ctrl+C arrives as a 0x03 byte, not a SIGINT, so the INT trap never fires and the loop # spins forever restarting claude on a wedged terminal. Restore cooked mode so Ctrl+C signals again. @@ -137,6 +154,9 @@ while true; do NOW=$(date +%s) LOOP_COUNT=$((LOOP_COUNT+1)) printf '\n\n' + # a stop requested WHILE claude ran (TERM, or INT that reached us) breaks here, not after the + # restart sleep — the closer below still prints the report and the fleet TOTAL. + if [ "$STOP" = 1 ]; then printf '\n❄ run loop stopped\n'; break; fi if [ "$MAX_LOOPS" -gt 0 ] && [ "$LOOP_COUNT" -ge "$MAX_LOOPS" ]; then printf '\n❄ claude exited after %s runs · reached CLAUDEZERO_MAX_LOOPS=%s · stopping\n' "$LOOP_COUNT" "$MAX_LOOPS" break @@ -157,6 +177,9 @@ print_report "$(date +%s)" print_fleet_total if all_todos_done; then dojo_proud; fi reap_dead_sessions # no future acquire will reap this session's marker +# 128+15: a supervisor can tell "terminated" from "finished". As an `if` — `[ … ] && exit 143` +# would leak the test's own status 1 through `set -e` on every normal run. +if [ "$TERMED" = 1 ]; then exit 143; fi } # entrypoint. Resolves the prompt (zero or loop mode), installs the session Stop hook, diff --git a/todo.md b/todo.md index a101975..94bc8ba 100644 --- a/todo.md +++ b/todo.md @@ -15,5 +15,5 @@ - [x] BUG-026 Refuse a zero-mode launch when the todo is not tracked on the base branch — untracked today means every merge is refused and nothing can ever land - [x] ISSUE-027 Print the instance nickname next to its id in the execution stats header - [x] ISSUE-028 Show the script version on its own line below the usage heading in `--help` -- [ ] BUG-029 Background claude and trap TERM so a SIGTERM while claude hangs still lands at the closing report and exits 143 +- [x] BUG-029 Background claude and trap TERM so a SIGTERM while claude hangs still lands at the closing report and exits 143 - [ ] ISSUE-030 Surface claude's own exit code on the restart/stop lines and add an opt-in `CLAUDEZERO_DEBUG` `--debug-file` capture From a7b3bf705081a22b39b3ae2848ebda6886d9356f Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Sun, 2 Aug 2026 17:46:16 +0200 Subject: [PATCH 06/22] feat: report claude's exit code and add an opt-in --debug-file capture (ISSUE-030) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A restart or MAX_LOOPS-stop line said only "claude exited after N runs", so a hang the outer timeout had to kill was indistinguishable from a clean exit without re-deriving it from timing. Capture the reaping wait's status (inside the loop body — `$?` after it is `break`'s own 0) and fold it into both existing lines as "claude exited with code %s after %s runs · …": one line per event, not a second diagnostic one. Build claude's argv as CLAUDE_ARGS so CLAUDEZERO_DEBUG can append --debug-file .git/debug---.log; the loop number keeps a restart from truncating the previous run's trace. Off by default: unset, the argv is unchanged. TEST.md Scenario L covers both. Scenario K's launch-line grep follows the argv into CLAUDE_ARGS. --- README.md | 2 +- TEST.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++- claudezero.sh | 22 ++++++++++++++++++---- todo.md | 2 +- 4 files changed, 71 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 078ac16..914206e 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ $ claudezero todo.md … claude works a task: forks a worktree, implements, commits, merges, ticks its box … -❄ claude exited after 1 runs · restarting in 5s · press Ctrl+C to stop +❄ claude exited with code 0 after 1 runs · restarting in 5s · press Ctrl+C to stop … fresh context, next task … diff --git a/TEST.md b/TEST.md index 8a794cf..6108196 100644 --- a/TEST.md +++ b/TEST.md @@ -1074,7 +1074,7 @@ echo "K1 stats : $(grep -c 'execution stats' "$TK/hang.log") (want 1 echo "K1 stopped : $(grep -c 'run loop stopped' "$TK/hang.log") (want 1)" echo "K1 orphans : $(pgrep -f "$TK/bin/claude" | wc -l | tr -d ' ') (want 0 — the TERM was forwarded to claude)" echo "K1 one TERM trap : $(grep -c '^trap .*TERM$' "$SCRIPT") (want 1 — a second handler would silently replace this one)" -echo "K1 backgrounded : $(grep -c 'name "\$SESSION_NAME" "\$PROMPT" >&4 2>&4 &$' "$SCRIPT") (want 1 — no foreground launch, no \`|| true\` swallow)" +echo "K1 backgrounded : $(grep -c 'claude "\${CLAUDE_ARGS\[@\]}" "\$PROMPT" >&4 2>&4 &$' "$SCRIPT") (want 1 — no foreground launch, no \`|| true\` swallow)" ``` - **K1 PASS** — `exit = 143`, `stats = 1`, `stopped = 1`, `orphans = 0`, `one TERM trap = 1`, `backgrounded = 1`. @@ -1097,6 +1097,56 @@ echo "K2 restart line : $(grep -c 'restarting in' "$TK/restart.log") (want 1)" --- +## Scenario L — claude's exit code + `--debug-file` `[$TESTROOT/L]` (stub claude, deterministic) + +The restart / `MAX_LOOPS`-stop lines carry claude's real exit status, so a hang and a clean +exit are told apart without re-deriving them from timing. `CLAUDEZERO_DEBUG` is the opt-in: +set, every claude invocation gets its own `--debug-file`; unset, the argv is byte-identical +to before. The stub echoes its own argv, which is how the argv claim is checked. + +### Setup +```bash +TL="$TESTROOT/L"; mkdir -p "$TL/repo" "$TL/bin" +cat > "$TL/bin/claude" <<'EOF' +#!/usr/bin/env bash +printf 'ARGV: %s\n' "$*" +exit "${STUB_EXIT:-0}" +EOF +chmod +x "$TL/bin/claude" +cd "$TL/repo" +git init -q -b main; git config user.email t@t.t; git config user.name test +printf -- '- [ ] L1 x\n' > todo.md; git add -A; git commit -qm init +``` + +### L1 — the code is on both lines, and it is claude's own +```bash +cd "$TL/repo" +PATH="$TL/bin:$PATH" STUB_EXIT=7 timeout 60 env CLAUDEZERO_MAX_LOOPS=2 bash "$SCRIPT" todo.md -t x > "$TL/code.log" 2>&1 +echo "L1 exit : $? (want 0 — claude's status is reported, not adopted)" +echo "L1 restart line : $(grep -c 'claude exited with code 7 after 1 runs · restarting in' "$TL/code.log") (want 1)" +echo "L1 stop line : $(grep -c 'claude exited with code 7 after 2 runs · reached CLAUDEZERO_MAX_LOOPS' "$TL/code.log") (want 1)" +echo "L1 no old line : $(grep -c 'claude exited after' "$TL/code.log") (want 0 — merged into the same line, not a second one)" +PATH="$TL/bin:$PATH" timeout 40 env CLAUDEZERO_MAX_LOOPS=1 bash "$SCRIPT" todo.md -t x > "$TL/zero.log" 2>&1 +echo "L1 clean code : $(grep -c 'claude exited with code 0 after 1 runs' "$TL/zero.log") (want 1 — an exit-0 stub reads 0, not a stale status)" +``` +- **L1 PASS** — `exit = 0`, `restart line = 1`, `stop line = 1`, `no old line = 0`, + `clean code = 1`. + +### L2 — `--debug-file` is opt-in, one file per invocation +```bash +cd "$TL/repo" +echo "L2 argv default : $(grep -c -- '--debug-file' "$TL/zero.log") (want 0 — unset is the default and changes nothing)" +echo "L2 argv shape : $(grep -c 'ARGV: --settings .* --permission-mode auto --name .* /loop ' "$TL/zero.log") (want 1 — the pre-existing argv: flags, then the prompt, nothing added)" +PATH="$TL/bin:$PATH" CLAUDEZERO_DEBUG=1 timeout 60 env CLAUDEZERO_MAX_LOOPS=2 bash "$SCRIPT" todo.md -t x > "$TL/debug.log" 2>&1 +echo "L2 debug exit : $? (want 0)" +echo "L2 argv debug : $(grep -c -- '--debug-file .*/\.git/debug-main-[0-9A-F]*-[12]\.log' "$TL/debug.log") (want 2 — ---, one per invocation)" +echo "L2 distinct paths: $(grep -o -- '--debug-file [^ ]*' "$TL/debug.log" | sort -u | wc -l | tr -d ' ') (want 2 — the loop number keeps a restart from truncating run 1's trace)" +``` +- **L2 PASS** — `argv default = 0` with `argv shape = 1`, and + `debug exit = 0`, `argv debug = 2`, `distinct paths = 2`. + +--- + ## Run all in parallel (optional) After Section 0 and each Setup, launch the Run blocks together: put A's and C's run diff --git a/claudezero.sh b/claudezero.sh index 0870085..25c2754 100755 --- a/claudezero.sh +++ b/claudezero.sh @@ -140,13 +140,24 @@ reap_dead_sessions # startup: clear markers left by crashed prior runs before while true; do # first prompt submitted straight from the CLI arg. The session Stop hook SIGTERMs claude # when context fills; exit 143 is the normal restart path, so swallow it. + CLAUDE_ARGS=(--settings "$STOP_SETTINGS" --permission-mode auto --name "$SESSION_NAME") + # diagnostic opt-in: a --debug-file on every real run is a standing cost for nobody. One file + # per claude invocation, so a restart leaves the hung run's trace intact. + if [ -n "${CLAUDEZERO_DEBUG:-}" ]; then + CLAUDE_ARGS+=(--debug-file "$DEBUG_FILE_BASE-$((LOOP_COUNT+1)).log") + fi CLAUDEZERO_INSTANCE="$INSTANCE_ID" CLAUDEZERO_TRANSCRIPTS="$TRANSCRIPTS_FILE" \ - claude --settings "$STOP_SETTINGS" --permission-mode auto --name "$SESSION_NAME" "$PROMPT" >&4 2>&4 & + claude "${CLAUDE_ARGS[@]}" "$PROMPT" >&4 2>&4 & CLAUDE_PID=$! # backgrounded on purpose: bash defers every trap until a FOREGROUND child exits, so a TERM # arriving while claude hangs could never be handled. `wait` IS interruptible — it returns # 128+N when a trapped signal fires — so re-enter it until claude is actually gone. - until wait "$CLAUDE_PID"; do kill -0 "$CLAUDE_PID" 2>/dev/null || break; done + # `$?` after the loop is `break`'s own 0, so claude's status is captured inside the body. + CLAUDE_EXIT=0 + until wait "$CLAUDE_PID"; do + CLAUDE_EXIT=$? + kill -0 "$CLAUDE_PID" 2>/dev/null || break + done # claude killed mid-run (Ctrl+C/SIGTERM) can leave the tty in raw mode with ISIG off; then every # later Ctrl+C arrives as a 0x03 byte, not a SIGINT, so the INT trap never fires and the loop # spins forever restarting claude on a wedged terminal. Restore cooked mode so Ctrl+C signals again. @@ -158,12 +169,12 @@ while true; do # restart sleep — the closer below still prints the report and the fleet TOTAL. if [ "$STOP" = 1 ]; then printf '\n❄ run loop stopped\n'; break; fi if [ "$MAX_LOOPS" -gt 0 ] && [ "$LOOP_COUNT" -ge "$MAX_LOOPS" ]; then - printf '\n❄ claude exited after %s runs · reached CLAUDEZERO_MAX_LOOPS=%s · stopping\n' "$LOOP_COUNT" "$MAX_LOOPS" + printf '\n❄ claude exited with code %s after %s runs · reached CLAUDEZERO_MAX_LOOPS=%s · stopping\n' "$CLAUDE_EXIT" "$LOOP_COUNT" "$MAX_LOOPS" break fi print_report "$NOW" dojo_wisdom - printf '\n❄ claude exited after %s runs · restarting in %ss · press Ctrl+C to stop\n' "$LOOP_COUNT" "$RESTART_WAIT" + printf '\n❄ claude exited with code %s after %s runs · restarting in %ss · press Ctrl+C to stop\n' "$CLAUDE_EXIT" "$LOOP_COUNT" "$RESTART_WAIT" sleep "$RESTART_WAIT" || true # SIGINT interrupts sleep and fires the INT trap if [ "$STOP" = 1 ]; then printf '\n❄ run loop stopped\n'; break; fi done @@ -267,6 +278,9 @@ TODOS_DONE_FILE="$(cd "$(git rev-parse --git-common-dir)" && pwd)/todos-done-${B # this instance's list of claude session transcripts (one path per line, appended by the Stop hook). # Namespaced like the time-file so parallel instances never read each other's token figures. TRANSCRIPTS_FILE="$(cd "$(git rev-parse --git-common-dir)" && pwd)/transcripts-${BASE_BRANCH//\//-}-$INSTANCE_ID" +# stem for the opt-in `claude --debug-file` capture (CLAUDEZERO_DEBUG). Same -- +# naming as the files above; run_loop appends the loop number so a restart keeps the earlier trace. +DEBUG_FILE_BASE="$(cd "$(git rev-parse --git-common-dir)" && pwd)/debug-${BASE_BRANCH//\//-}-$INSTANCE_ID" ZERO_SH="$GITDIR_ABS/zero.sh" # where build_zero_prompt wrote the helper (zero mode only) INSTANCE_DIR="$(cd "$(git rev-parse --git-common-dir)" && pwd)/instance" diff --git a/todo.md b/todo.md index 94bc8ba..8f58101 100644 --- a/todo.md +++ b/todo.md @@ -16,4 +16,4 @@ - [x] ISSUE-027 Print the instance nickname next to its id in the execution stats header - [x] ISSUE-028 Show the script version on its own line below the usage heading in `--help` - [x] BUG-029 Background claude and trap TERM so a SIGTERM while claude hangs still lands at the closing report and exits 143 -- [ ] ISSUE-030 Surface claude's own exit code on the restart/stop lines and add an opt-in `CLAUDEZERO_DEBUG` `--debug-file` capture +- [x] ISSUE-030 Surface claude's own exit code on the restart/stop lines and add an opt-in `CLAUDEZERO_DEBUG` `--debug-file` capture From ebbcf68a1a53721eb6c3a2b420370c984daefb98 Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Sun, 2 Aug 2026 21:52:36 +0200 Subject: [PATCH 07/22] Add ISSUE-031 and ISSUE-032 tasks ISSUE-031: zero one task per claude session, move the wait for a claimable task into the shell, drop the /loop instruction. Keeps the context-full restart as the context-rot guard. ISSUE-032: watchdog timer for a claude hung mid-turn, CLAUDEZERO_WATCHDOG (default 900s, 0 disables), with its own console line on a kill. Co-Authored-By: Claude Opus 5 (1M context) --- todo.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/todo.md b/todo.md index 8f58101..56deb79 100644 --- a/todo.md +++ b/todo.md @@ -17,3 +17,5 @@ - [x] ISSUE-028 Show the script version on its own line below the usage heading in `--help` - [x] BUG-029 Background claude and trap TERM so a SIGTERM while claude hangs still lands at the closing report and exits 143 - [x] ISSUE-030 Surface claude's own exit code on the restart/stop lines and add an opt-in `CLAUDEZERO_DEBUG` `--debug-file` capture +- [ ] ISSUE-031 Zero one task per claude session and wait for the next claimable task in the shell — drop `/loop`, keep the context-full restart +- [ ] ISSUE-032 Kill a hung claude with a `CLAUDEZERO_WATCHDOG` timer (default 15m) and name the watchdog on its own console line From c8472d6a52bda363cc1b7d7ff86c0b0ca74903c8 Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Sun, 2 Aug 2026 22:29:39 +0200 Subject: [PATCH 08/22] update subtitles --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 914206e..31a5cd1 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@

Todo-list sensei for Claude Code. Zeros your list.

Loops Claude until every todo is done, committed, and checked off.
-Restarts each one on a fresh context before rot.
-You can spawn multiple instances to parallelize.
-It's for practical Loop engineering.
+Restarts the coding session on a fresh context before rot.
+You can spawn multiple instances to parallelize.

+It's for Specification-Driven Development and practical Loop engineering.

From ac51f7fb322948b21f58f429a754d0dcfd7ffad1 Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Sun, 2 Aug 2026 22:45:26 +0200 Subject: [PATCH 09/22] update readme --- README.md | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 31a5cd1..414a4cb 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Loops Claude until every todo is done, committed, and checked off.
Restarts the coding session on a fresh context before rot.
You can spawn multiple instances to parallelize.

-It's for Specification-Driven Development and practical Loop engineering.
+It's for practical Loop engineering.

@@ -149,7 +149,15 @@ GitHub-style Markdown checkboxes, one task per line. Each line carries a **uniqu ## 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. Two halves: +[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: + +1. **Spec** — what to build. Expected outputs, constraints, acceptance criteria, done-conditions. The contract everything downstream enforces. Without it the layers above have nothing to check against. Here: the todo file, one task per line, each referencing a separate issue file with the details of the specification. +2. **Harness** — how to keep the agent on the spec, in two directions. *Feedforward* guides steer before it acts (`CLAUDE.md`, conventions, templates); *feedback* sensors catch after (tests, linters, type checks, review). Feedback alone repeats the same mistakes; feedforward alone never proves it worked. Here: the per-iteration algorithm below, plus whatever guides and checks your repo already has. +3. **Loop** — who does the prompting. The harness on a timer: self-triggering runs, isolated worktrees, subagents that verify and feed back. You stop prompting turn by turn and start designing the thing that prompts itself. Here: ClaudeZero with `--taskprompt` instruction on how to learn by prompting itself. + +Levels 1 and 2 are yours; ClaudeZero supports level 3. Together they steer: when a mistake recurs, you don't only fix the code, you sharpen the spec and/or the harness, and the loop needs you less each pass due to the learning instruction. + +Loop engineering has two halves: 1. Mechanics — a durable loop over external state; disposable runs that restart before context rots. 2. Learning — each turn carries a lesson forward, so the agent stops repeating mistakes. @@ -174,10 +182,11 @@ ClaudeZero never writes `CLAUDE.md` — the harness stays learning-agnostic, so Bake a reflection step into the task prompt; the lesson lands in a committed `CLAUDE.md`, survives the restart, and reaches peers after their next merge: ```sh -claudezero todo.md -t 'Implement the task following your setup. +claudezero todo.md --taskprompt 'Implement the task following your setup. When done, if you learned something that will help future tasks — a gotcha, a project convention, a command that worked — append one concise bullet under a -"## Learnings" heading in CLAUDE.md, and include that edit in the task commit.' +"## Learnings" heading in the project CLAUDE.md, and include that edit +in the task commit.' ``` Now "the leopard remembers what the last winter taught him." From ae0aa3d608bb12fa7fd2e540079e2129fba595f0 Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Mon, 3 Aug 2026 00:43:48 +0200 Subject: [PATCH 10/22] feat: zero one task per claude session and wait for the next claimable task in the shell (ISSUE-031) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shell, not claude, now decides whether a session is worth starting: nothing unchecked left goes straight to the closer, and everything unchecked already held by a live peer waits in `wait_for_claimable`, launching no claude and spending no tokens. The zero prompt drops `/loop` and ends its turn after one task; the Stop hook ends a zero-mode session at every turn end, so each task starts on a context isolated from the task before it. The context-full restart is unchanged and still takes precedence. `CLAUDE_PID` is initialized before the loop: Claude Code exports it (its own pid) into every Bash-tool env, and the new wait is the first window where a TERM can arrive before claude has launched — without the init, `on_term` SIGTERMs the session that ran claudezero. Tests: new scenario M (one task per session, the shell waits, the Stop hook's two exit paths), including an assert that the TERM trap leaves an inherited CLAUDE_PID alone. Scenario G's report counts and scenario A's `todos counted` pattern are corrected for the closing report and fleet TOTAL added in ISSUE-017/BUG-022. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 5 +- TEST.md | 106 +++++++++++++++++++++++++++++++++++---- claudezero.sh | 134 +++++++++++++++++++++++++++++++++++++++----------- todo.md | 2 +- 4 files changed, 206 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 914206e..f2de665 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Runs [`claude`](https://claude.com/product/claude-code) on a predefined prompt i - **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. +- **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. - **Crash resilient** — if `claude` crashes or is killed mid-task, its half-done work isn't lost. The next instance to come by — a peer switching to its next task, or the same loop restarted — reclaims the branch, finishes it, and merges it back. Work only counts as done once it lands on the base branch and its box is checked there. @@ -156,14 +157,14 @@ GitHub-style Markdown checkboxes, one task per line. Each line carries a **uniqu ClaudeZero owns the mechanics and leaves the learning to you. It drills the *form* precisely — how to claim, zero, commit, and check off a todo without collision or rot. You bring the *material* — what this codebase's tasks should teach. Sensei drills the kata; you bring the fight. -The kata is a strict per-iteration algorithm every instance runs: +The kata is a strict algorithm every instance runs, one task per `claude` session: 1. Find & validate — collect tasks; a missing or duplicate id stops the loop. 2. Judge independence by evidence — blocked only if the body quotably consumes an *unchecked* task's output; adjacency is not a dependency. 3. Claim & re-check — one task per git worktree (branch = claim), then guard against a peer who already landed it. 4. Implement & check off — scoped to that task, tick only its box, commit. 5. Merge serially — on a conflict or over-check, self-heal once, else stop and hand off to the user rather than corrupt the base. -6. Repeat — next id; when every box is checked, announce done and stop. +6. End the session — the shell starts a fresh one for the next task, or waits without spending a token while peers hold the rest; when every box is checked, announce done and stop. ### Closing the loop diff --git a/TEST.md b/TEST.md index 6108196..d431e99 100644 --- a/TEST.md +++ b/TEST.md @@ -265,7 +265,7 @@ echo "restarts (A/B/C): $(grep -c restarting "$T/log_AGENT_A.txt") $(grep -c res echo "instance ids : $(grep -h -oE 'instance [^)]+' "$T"/log_AGENT_*.txt | sort -u | wc -l | tr -d ' ')" echo "report labels : $(grep -h -cE ' (Todos|ClaudeZero run loop):' "$T"/log_AGENT_A.txt | tr -d ' ')" echo "stale loop line : $(grep -h -c 'Claude loops:' "$T"/log_AGENT_A.txt | tr -d ' ') (want 0)" -echo "todos counted : $(awk '/❄ TOTAL/{exit} /Todos:.*· [0-9]+ completed/{l=$0} END{print l}' "$T"/log_AGENT_A.txt)" +echo "todos counted : $(awk '/❄ TOTAL/{exit} /Todos:.*·[[:space:]]+[0-9]+ completed/{l=$0} END{print l}' "$T"/log_AGENT_A.txt)" echo "TOTAL blocks : $(grep -h -c '❄ TOTAL' "$T"/log_AGENT_*.txt | paste -sd' ' -) (want 1 each — exit path)" echo "zero.sh wrote files: $(ls "$T"/repo/.git 2>/dev/null | grep -c '^todos-seconds-')" echo "zero.sh wrote counts: $(ls "$T"/repo/.git 2>/dev/null | grep -c '^todos-done-')" @@ -626,12 +626,12 @@ cd "$TG/repo" grun ok "$TG/ok.log" GC="$(cd "$(git rev-parse --git-common-dir)" && pwd)" inst="$(sed -n 's/.*execution stats (instance \([A-Za-z0-9]*\) · .*/\1/p' "$TG/ok.log" | head -1)" -echo "G1 heading : $(grep -c 'execution stats' "$TG/ok.log") (want 2 — renamed from 'execution time')" +echo "G1 heading : $(grep -c 'execution stats' "$TG/ok.log") (want 3 — a report between runs 1|2 and 2|3, plus the closing one)" echo "G1 report 1 : $(grep -A1 'Tokens:' "$TG/ok.log" | sed -n '1,2p' | tr '\n' '|')" echo "G1 report 2 : $(grep -A1 'Tokens:' "$TG/ok.log" | sed -n '4,5p' | tr '\n' '|')" echo "G1 per-instance : $([ -f "$GC/transcripts-main-$inst" ] && echo yes || echo NO) (instance $inst)" ``` -- **G1 PASS** — `heading = 2`, and the two reports read exactly: +- **G1 PASS** — `heading = 3`, and the first two reports read exactly: - report 1: ` Tokens: 1.7k Total| in 15 · out 27 · cache write 248 · cache read 1.5k|` — `req_A`'s three identical lines counted **once** (10+5 in, 20+7 out), `iterations[]` not added on top, and the cache write is `248`, not `496` (leaves not added to parent). @@ -645,13 +645,13 @@ echo "G1 per-instance : $([ -f "$GC/transcripts-main-$inst" ] && echo yes || ec ```bash cd "$TG/repo" grun missing "$TG/missing.log"; grun bad "$TG/bad.log" -echo "G2 missing file : $(grep -c 'Tokens: n/a' "$TG/missing.log") (want 2)" -echo "G2 invalid json : $(grep -c 'Tokens: n/a' "$TG/bad.log") (want 2)" -echo "G2 timing kept : $(grep -c 'ClaudeZero run loop:' "$TG/bad.log") (want 2)" +echo "G2 missing file : $(grep -c 'Tokens: n/a' "$TG/missing.log") (want 4 — one per report, plus the fleet TOTAL)" +echo "G2 invalid json : $(grep -c 'Tokens: n/a' "$TG/bad.log") (want 4 — one per report, plus the fleet TOTAL)" +echo "G2 timing kept : $(grep -c 'ClaudeZero run loop:' "$TG/bad.log") (want 3 — one per report; TOTAL omits the row, summed wall times are not a duration)" ``` -- **G2 PASS** — both runs print `Tokens: n/a` in every report and still print the timing - rows: a deleted transcript or a truncated/invalid line degrades to `n/a` and the run - completes normally instead of printing a wrong number. +- **G2 PASS** — both runs print `Tokens: n/a` in every report and in the fleet TOTAL, and + still print the timing rows: a deleted transcript or a truncated/invalid line degrades to + `n/a` and the run completes normally instead of printing a wrong number. --- @@ -1136,7 +1136,7 @@ echo "L1 clean code : $(grep -c 'claude exited with code 0 after 1 runs' "$TL ```bash cd "$TL/repo" echo "L2 argv default : $(grep -c -- '--debug-file' "$TL/zero.log") (want 0 — unset is the default and changes nothing)" -echo "L2 argv shape : $(grep -c 'ARGV: --settings .* --permission-mode auto --name .* /loop ' "$TL/zero.log") (want 1 — the pre-existing argv: flags, then the prompt, nothing added)" +echo "L2 argv shape : $(grep -c 'ARGV: --settings .* --permission-mode auto --name .* You are ONE of ' "$TL/zero.log") (want 1 — the pre-existing argv: flags, then the prompt, nothing added)" PATH="$TL/bin:$PATH" CLAUDEZERO_DEBUG=1 timeout 60 env CLAUDEZERO_MAX_LOOPS=2 bash "$SCRIPT" todo.md -t x > "$TL/debug.log" 2>&1 echo "L2 debug exit : $? (want 0)" echo "L2 argv debug : $(grep -c -- '--debug-file .*/\.git/debug-main-[0-9A-F]*-[12]\.log' "$TL/debug.log") (want 2 — ---, one per invocation)" @@ -1147,6 +1147,92 @@ echo "L2 distinct paths: $(grep -o -- '--debug-file [^ ]*' "$TL/debug.log" | sor --- +## Scenario M — one task per session, the shell waits `[$TESTROOT/M]` (stub claude, deterministic) + +The claimable probe moved out of claude and into the shell: nothing left → the closer; +everything unchecked held by a *live* peer → wait, launching no claude at all; anything free +(including a crashed peer's branch, which only claude can rescue) → launch. The Stop hook is +the other half — in zero mode it ends the session at every turn end, so one session zeroes one +task; in `-l` loop mode only the context-bucket file still ends it. + +### Setup +```bash +TM="$TESTROOT/M"; mkdir -p "$TM/repo" "$TM/bin" +cat > "$TM/bin/claude" <<'EOF' +#!/usr/bin/env bash +echo launched >> "$STUB_LAUNCHED" +exit 0 +EOF +chmod +x "$TM/bin/claude" +mkdir -p "$TM/owner"; cp "$(command -v bash)" "$TM/owner/claude" # M4 needs a process `ps -o comm=` reports as 'claude' +cd "$TM/repo" +git init -q -b main; git config user.email t@t.t; git config user.name test +printf -- '- [ ] M1 x\n' > todo.md; git add -A; git commit -qm init +# a LIVE peer holding M1: claim branch + worktree `.owner` + session marker, exactly what +# zero.sh's acquire writes and what held_todos reads back. +sleep 600 & PEER=$! +git worktree add -q -b main-task-M1 "$TM/wt1" main +printf '%s\n%s\n%s\n%s\n' "$PEER" "$(ps -o lstart= -p "$PEER" | awk '{$1=$1;print}')" "$(date +%s)" "PEERINST" > "$TM/wt1/.owner" +mkdir -p "$TM/repo/.git/session" +printf '%s\n%s\n' "$(ps -o lstart= -p "$PEER" | awk '{$1=$1;print}')" "M1" > "$TM/repo/.git/session/$PEER" +``` + +### M1 — every unchecked task peer-held: no claude, plain waiting lines when stdout is not a tty +```bash +cd "$TM/repo" +export STUB_LAUNCHED="$TM/launched"; : > "$STUB_LAUNCHED" +sleep 600 & DECOY=$! # stands in for the caller's own session: Claude Code exports CLAUDE_PID +PATH="$TM/bin:$PATH" CLAUDE_PID=$DECOY timeout 13 env CLAUDEZERO_MAX_LOOPS=1 bash "$SCRIPT" todo.md -t x > "$TM/wait.log" 2>&1 +echo "M1 exit : $? (want 124 — still waiting when the timeout fired, never launched)" +echo "M1 decoy alive : $(kill -0 $DECOY 2>/dev/null && echo yes || echo no) (want yes — the TERM trap must never kill an INHERITED CLAUDE_PID; nothing was launched yet)" +kill "$DECOY" 2>/dev/null; wait "$DECOY" 2>/dev/null || true +echo "M1 launches : $(wc -l < "$STUB_LAUNCHED" | tr -d ' ') (want 0 — no claude process, no transcript, no tokens)" +echo "M1 waiting line : $(grep -c 'waiting for a claimable task · 1 held by peers' "$TM/wait.log") (want >=2 — one plain line per probe)" +echo "M1 no escapes : $(LC_ALL=C grep -c $'\r\|\033\[' "$TM/wait.log") (want 0 — a pipe gets no \r and no cursor moves)" +``` +- **M1 PASS** — `exit = 124`, `decoy alive = yes`, `launches = 0`, `waiting line >= 2`, `no escapes = 0`. + +### M2 — a dead peer's branch is claimable: claude IS launched to rescue it +```bash +cd "$TM/repo" +kill "$PEER" 2>/dev/null; wait "$PEER" 2>/dev/null || true +: > "$STUB_LAUNCHED" +PATH="$TM/bin:$PATH" timeout 40 env CLAUDEZERO_MAX_LOOPS=1 bash "$SCRIPT" todo.md -t x > "$TM/rescue.log" 2>&1 +echo "M2 exit : $? (want 0)" +echo "M2 launches : $(wc -l < "$STUB_LAUNCHED" | tr -d ' ') (want 1 — held_todos counts only LIVE owners, else a crash parks the fleet forever)" +echo "M2 no wait line : $(grep -c 'waiting for a claimable task' "$TM/rescue.log") (want 0)" +``` +- **M2 PASS** — `exit = 0`, `launches = 1`, `no wait line = 0`. + +### M3 — every box checked: the closer runs, claude never starts +```bash +cd "$TM/repo" +git worktree remove --force "$TM/wt1"; git branch -qD main-task-M1 +printf -- '- [x] M1 x\n' > todo.md; git add -A; git commit -qm done +: > "$STUB_LAUNCHED" +PATH="$TM/bin:$PATH" timeout 40 env CLAUDEZERO_MAX_LOOPS=1 bash "$SCRIPT" todo.md -t x > "$TM/done.log" 2>&1 +echo "M3 exit : $? (want 0)" +echo "M3 launches : $(wc -l < "$STUB_LAUNCHED" | tr -d ' ') (want 0 — nothing to zero, so nothing to launch)" +echo "M3 closing report: $(grep -c 'execution stats' "$TM/done.log") (want >=1 — broke straight to the closer)" +``` +- **M3 PASS** — `exit = 0`, `launches = 0`, `closing report >= 1`. + +### M4 — the Stop hook: ends every zero-mode turn, only context-full in loop mode +```bash +cd "$TM/repo" +CLAUDEZERO_TEST_EMIT=1 bash "$SCRIPT" todo.md -t x > /dev/null 2>&1 +HOOK="$TM/repo/.git/compact-exit-hook.sh" +run_as_claude(){ CLAUDEZERO_MODE="$1" "$TM/owner/claude" -c 'printf "%s" "$2" | bash "$1" >/dev/null 2>&1; sleep 3' _ "$HOOK" "$2"; echo $?; } +echo "M4 zero mode : $(run_as_claude zero '{}') (want 143 — ordinary turn end SIGTERMs the owning claude)" +echo "M4 loop mode : $(run_as_claude loop '{}') (want 0 — -l has no task boundary, so it is left running)" +B="${TMPDIR:-/tmp}"; B="${B%/}/claude-context-bucket-czM4"; : > "$B" +echo "M4 bucket branch : $(run_as_claude loop '{"session_id":"czM4"}') (want 143 — the context-rot guard is unchanged and still first)" +rm -f "$B" +``` +- **M4 PASS** — `zero mode = 143`, `loop mode = 0`, `bucket branch = 143`. + +--- + ## Run all in parallel (optional) After Section 0 and each Setup, launch the Run blocks together: put A's and C's run diff --git a/claudezero.sh b/claudezero.sh index 25c2754..3aff637 100755 --- a/claudezero.sh +++ b/claudezero.sh @@ -16,8 +16,8 @@ set -euo pipefail VERSION="0.0.16" PROG="$(basename "$0")" # name shown in usage/errors, from how the script was invoked -LOOP_INTERVAL="3m" # cadence claude reschedules its zeroing pass at (baked into the /loop prompt) RESTART_WAIT=5 # seconds between claude restarts — the window to press Ctrl+C +WAIT_TICK=5 # seconds between claimable-task probes while every unchecked task is peer-held # The braces around the pipe reader in the logging example are load-bearing — do NOT tidy them # into a bare pipe. Ctrl+C signals the whole foreground group; the reader's default SIGINT action @@ -114,6 +114,10 @@ MAX_LOOPS="${CLAUDEZERO_MAX_LOOPS:-0}" # shell was handed is a real terminal fd, opened read-write, so it takes writes and kqueue both. if [ ! -t 1 ] && [ -t 0 ]; then exec 4>&0; else exec 4>&1; fi LOOP_COUNT=0 +# claude's pid, set per launch below. Initialized here because Claude Code exports CLAUDE_PID (its +# own pid) into every Bash-tool env: without this, a TERM arriving BEFORE the first launch — the +# zero-mode wait, or a Ctrl+C at startup — makes on_term SIGTERM the session that ran us. +CLAUDE_PID="" STOP=0 # set by the INT/TERM traps; the loop breaks to the closer below TERMED=0 # set by the TERM trap; makes the closer exit 143 instead of 0 TODOS_BASE=$(read_counter "${TODOS_TIME_FILE:-}") # snapshot: report only THIS run's slice of the shared aggregates @@ -138,6 +142,13 @@ trap on_term TERM reap_dead_sessions # startup: clear markers left by crashed prior runs before the first claude while true; do + # zero mode: the SHELL decides whether a claude session is worth starting. Nothing left → the + # closer; everything unchecked already held by a live peer → wait here, spending no tokens + # (a claude parked at the prompt re-reads its whole context just to say "still peer-owned"). + if [ "${MODE:-}" = zero ]; then + if all_todos_done; then break; fi + if ! wait_for_claimable; then break; fi + fi # first prompt submitted straight from the CLI arg. The session Stop hook SIGTERMs claude # when context fills; exit 143 is the normal restart path, so swallow it. CLAUDE_ARGS=(--settings "$STOP_SETTINGS" --permission-mode auto --name "$SESSION_NAME") @@ -146,7 +157,7 @@ while true; do if [ -n "${CLAUDEZERO_DEBUG:-}" ]; then CLAUDE_ARGS+=(--debug-file "$DEBUG_FILE_BASE-$((LOOP_COUNT+1)).log") fi - CLAUDEZERO_INSTANCE="$INSTANCE_ID" CLAUDEZERO_TRANSCRIPTS="$TRANSCRIPTS_FILE" \ + CLAUDEZERO_INSTANCE="$INSTANCE_ID" CLAUDEZERO_TRANSCRIPTS="$TRANSCRIPTS_FILE" CLAUDEZERO_MODE="$MODE" \ claude "${CLAUDE_ARGS[@]}" "$PROMPT" >&4 2>&4 & CLAUDE_PID=$! # backgrounded on purpose: bash defers every trap until a FOREGROUND child exits, so a TERM @@ -314,19 +325,28 @@ if [ -n "$tf" ]; then tp="$(printf '%s' "$input" | sed -n 's/.*"transcript_path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')" [ -n "$tp" ] && ! grep -qxF "$tp" "$tf" 2>/dev/null && printf '%s\n' "$tp" >> "$tf" fi -sid="$(printf '%s' "$input" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | tr -cd 'A-Za-z0-9_-')" -[ -n "$sid" ] || exit 0 -dir="${TMPDIR:-${TMP:-${TEMP:-/tmp}}}"; dir="${dir%/}" # match node os.tmpdir() -[ -f "$dir/claude-context-bucket-$sid" ] || exit 0 # threshold not crossed → keep going # nearest ancestor named 'claude' → SIGTERM (graceful: reaps bash tree, runs SessionEnd hooks, # exits 143). Turn already saved, so the kill loses nothing. -pid=$PPID; depth=0 -while [ -n "$pid" ] && [ "$pid" -gt 1 ] && [ "$depth" -lt 8 ]; do - comm="$(ps -o comm= -p "$pid" 2>/dev/null)" || exit 0 - [ "${comm##*/}" = claude ] && { kill -TERM "$pid"; exit 0; } - pid="$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d '[:space:]')" - depth=$((depth+1)) -done +term_owner() { + pid=$PPID; depth=0 + while [ -n "$pid" ] && [ "$pid" -gt 1 ] && [ "$depth" -lt 8 ]; do + comm="$(ps -o comm= -p "$pid" 2>/dev/null)" || return 0 + [ "${comm##*/}" = claude ] && { kill -TERM "$pid"; return 0; } + pid="$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d '[:space:]')" + depth=$((depth+1)) + done +} +sid="$(printf '%s' "$input" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | tr -cd 'A-Za-z0-9_-')" +dir="${TMPDIR:-${TMP:-${TEMP:-/tmp}}}"; dir="${dir%/}" # match node os.tmpdir() +# context-rot guard: suggest-compact's bucket file = threshold crossed, restart on fresh context. +[ -n "$sid" ] && [ -f "$dir/claude-context-bucket-$sid" ] && { term_owner; exit 0; } +# ordinary turn end (task merged, or nothing claimable): end the session too, so the next task +# starts on a context isolated from this one and an idle instance costs nothing. A turn end is +# claude sitting idle at the prompt — the transcript is already recorded above, and any half-done +# worktree is reclaimed by the next instance through the existing rescue path. +# Zero mode only: -l/--loopprompt has no task boundary, so context-full stays its only cycle. +[ "${CLAUDEZERO_MODE:-}" = zero ] || exit 0 +term_owner exit 0 HOOK_EOF chmod +x "$STOP_HOOK" @@ -430,6 +450,64 @@ all_todos_done() { END { exit (any && !unchecked) ? 0 : 1 }' } +# count unchecked tasks on the base branch's todo (fenced example boxes excluded, as everywhere). +unchecked_todos() { + git show "$BASE_BRANCH:$TODO_PATH" 2>/dev/null | awk ' + /^[ \t]*```/ { fence = !fence; next } + fence { next } + /^[ \t]*- \[ \]/ { n++ } + END { print n+0 }' +} + +# how many tasks live peers are holding right now — a read-only mirror of zero.sh's acquire test: +# a claim is a `$BASE_BRANCH-task-` branch whose worktree `.owner` names a session that is both +# alive and still on that task. Pure filesystem + git, so it costs no tokens and needs no claude +# ancestor (zero.sh's ensure_owner cannot run from the shell). A branch whose owner DIED is not +# counted: acquire_task steals such a worktree, so claude must be launched to do the rescue — +# counting it as held would park the whole fleet forever on a crashed peer's leftovers. +held_todos() { + local path branch id pid st cur n=0 + while IFS=$'\t' read -r path branch; do + case "$branch" in "$BASE_BRANCH-task-"*) id=${branch#"$BASE_BRANCH"-task-} ;; *) continue ;; esac + [ -f "$path/.owner" ] || continue + { read -r pid; read -r st; } < "$path/.owner" 2>/dev/null || continue + kill -0 "$pid" 2>/dev/null || continue + [ "$(proc_start "$pid")" = "$st" ] || continue + cur="" + if [ -f "$SESSION_DIR/$pid" ]; then { read -r _; read -r cur; } < "$SESSION_DIR/$pid" 2>/dev/null || cur=""; fi + if [ "$cur" = "$id" ]; then n=$((n+1)); fi + done < <(git worktree list --porcelain | awk ' + /^worktree / { p = substr($0, 10) } + /^branch refs\/heads\// { printf "%s\t%s\n", p, substr($0, 19) }') + printf '%s' "$n" +} + +# block until at least one unchecked task is free to claim. 0 = launch claude; 1 = break to the +# closer (Ctrl+C/SIGTERM, or nothing unchecked left). No claude runs while we wait. +wait_for_claimable() { + local start=0 spin=0 u h frames="|/-\\" + while true; do + if [ "$STOP" = 1 ]; then return 1; fi + u=$(unchecked_todos); h=$(held_todos) + if [ "$u" -le 0 ]; then return 1; fi + if [ "$u" -gt "$h" ]; then + if [ "$start" != 0 ] && [ -t 1 ]; then printf '\r\033[K'; fi # clear the repainting line + return 0 + fi + if [ "$start" = 0 ]; then start=$(date +%s); fi + # repaint in place on a terminal; one plain line per probe when stdout is a log or a pipe. + if [ -t 1 ]; then + printf '\r❄ %s waiting for a claimable task · %s held by peers · %s\033[K' \ + "${frames:$((spin%4)):1}" "$h" "$(fmt_dur $(( $(date +%s) - start )))" + else + printf '❄ waiting for a claimable task · %s held by peers · %s\n' \ + "$h" "$(fmt_dur $(( $(date +%s) - start )))" + fi + spin=$((spin+1)) + sleep "$WAIT_TICK" || true # SIGINT interrupts sleep and fires the INT trap + done +} + # multiline execution-stats report. $1 = now epoch. # Todos = per-task ownership time and count of todos merged, this run's delta of zero.sh's # aggregates @@ -721,8 +799,9 @@ ensure_owner() { } # --- ownership as a per-session LEASE, not a per-worktree pid probe ----------------------- -# A claude SESSION outlives any single task (zeros many, idles between /loop fires), so "is the pid -# alive?" is too coarse — it says the session is up, not that it still holds THIS task. Record, per +# A claude SESSION can hold a task, release it, and reclaim another (a merge failure, a rescued +# worktree), so "is the pid alive?" is too coarse — it says the session is up, not that it still +# holds THIS task. Record, per # session, the ONE task it is currently on: # $GITDIR/session/ line1= line2= # zero.sh is the sole writer: set on acquire, cleared to `none` on merge/release. A task is owned @@ -1022,10 +1101,9 @@ DRIVER_EOF # command substitution. It exits 1 at EOF, hence `|| :`. local prompt IFS= read -r -d '' prompt <<'PROMPT_EOF' || : -/loop @@LOOP_INTERVAL@@ - You are ONE of many independent Claude instances zeroing tasks from @@TODO@@ in PARALLEL. -Keep these facts in mind for every iteration: +This session zeroes at most ONE task and then ends; the shell starts the next session. +Keep these facts in mind: • Coordination is via git alone: a branch = a claim, an flock = the rescue mutex. • Peers may hold other tasks at the same time — that is expected. Never assume you are alone. @@ -1033,7 +1111,7 @@ Keep these facts in mind for every iteration: path `.git/zero.sh` always resolves — call it exactly that, and do all worktree work as `cd "$wt" && …` in a SINGLE command. -=== PER-ITERATION ALGORITHM === +=== ALGORITHM (one task, then end your turn) === 1. FIND candidate tasks yourself in @@TODO@@. Tasks are GitHub-style Markdown checkboxes, one per line, each carrying an id as the FIRST whitespace-delimited token after the checkbox: - [ ] SMTH-855 some task not done yet ← UNCHECKED = still to do @@ -1042,7 +1120,7 @@ Keep these facts in mind for every iteration: VALIDATION GUARDRAIL (once, before zeroing): collect the ids of ALL tasks in @@TODO@@ (checked and unchecked). If any task is missing an id, or the same id appears on more than one line, - STOP THE LOOP IMMEDIATELY — report the offending ids and do not schedule the next iteration. + STOP IMMEDIATELY — report the offending ids and end your turn without claiming anything. 2. For each candidate task_id, in order: a. INDEPENDENCE — decide by EVIDENCE from the task body, never from its title, id, or position in the list: @@ -1068,14 +1146,13 @@ Keep these facts in mind for every iteration: c. IMPLEMENT task_id. If task_id's line names an issue/spec file, its acceptance criteria define done for this task — they may span files the task title never mentions: satisfy each and tick it there (`[ ]`→`[x]`) as you land it, never ahead of verifying it. - Scope every edit to task_id only before you skip to the next one; never touch - another task, even one you will process later this same pass (you reach the next task_id - at step e — this is per-task, not per-session). + Scope every edit to task_id only; never touch another task. @@LOOPPROMPT@@ d. CHECK OFF only your task_id's line in `$wt/@@TODO@@` (`[ ]`→`[x]`); touch no other line. Then commit in `$wt` on the worktree's branch. e. MERGE: .git/zero.sh merge task_id "$wt" - - exit 0 → merged to @@BASE_BRANCH@@, worktree + branch cleaned → continue to the next task_id. + - exit 0 → merged to @@BASE_BRANCH@@, worktree + branch cleaned → your one task is zeroed: + report it and END YOUR TURN. Do not claim a second task. - exit ≠ 0 with output starting `checkbox-merge: refused` → you checked off more than your own task. The output lists each offending line as `: `. In `$wt/@@TODO@@` uncheck every listed line EXCEPT task_id's (`[x]`→`[ ]`), keep yours checked, run @@ -1084,16 +1161,17 @@ Keep these facts in mind for every iteration: its worktree $wt and its branch (`git -C "$wt" symbolic-ref --short HEAD`), and ask the human to clear the unrelated checkboxes, then merge by hand. - any other exit ≠ 0 → a merge conflict. Resolve it yourself, iterating until you merge or you see - no better merge option. If you cannot, MERGE FAILED: STOP THE LOOP IMMEDIATELY — report task_id, + no better merge option. If you cannot, MERGE FAILED: STOP IMMEDIATELY — report task_id, its worktree $wt and its branch (`git -C "$wt" symbolic-ref --short HEAD`), and ask the human to "resolve the conflict on that branch, then merge by hand". -3. If every task in @@TODO@@ is now checked → announce "ALL TASKS DONE" and stop the loop: end this - pass WITHOUT scheduling the next iteration. Otherwise end this pass and let the scheduled loop re-fire. +3. End your turn — after zeroing one task, or after walking the whole list without claiming one + (say which happened). If every task in @@TODO@@ is now checked, announce "ALL TASKS DONE" first. + What runs next is the shell's call: it starts a fresh session for the next task, waits while + peers hold everything, or prints the closing report. PROMPT_EOF prompt=${prompt%$'\n'} # read keeps the final newline; $(cat) stripped it prompt=${prompt//@@TODO@@/$todo} prompt=${prompt//@@BASE_BRANCH@@/$BASE_BRANCH} - prompt=${prompt//@@LOOP_INTERVAL@@/$LOOP_INTERVAL} prompt=${prompt//@@LOOPPROMPT@@/$loopprompt} printf '%s\n' "$prompt" } diff --git a/todo.md b/todo.md index 56deb79..9205589 100644 --- a/todo.md +++ b/todo.md @@ -17,5 +17,5 @@ - [x] ISSUE-028 Show the script version on its own line below the usage heading in `--help` - [x] BUG-029 Background claude and trap TERM so a SIGTERM while claude hangs still lands at the closing report and exits 143 - [x] ISSUE-030 Surface claude's own exit code on the restart/stop lines and add an opt-in `CLAUDEZERO_DEBUG` `--debug-file` capture -- [ ] ISSUE-031 Zero one task per claude session and wait for the next claimable task in the shell — drop `/loop`, keep the context-full restart +- [x] ISSUE-031 Zero one task per claude session and wait for the next claimable task in the shell — drop `/loop`, keep the context-full restart - [ ] ISSUE-032 Kill a hung claude with a `CLAUDEZERO_WATCHDOG` timer (default 15m) and name the watchdog on its own console line From ff49af9c5175607481f79b42a68793e5829b7e1d Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Mon, 3 Aug 2026 01:17:24 +0200 Subject: [PATCH 11/22] feat: kill a hung claude with a CLAUDEZERO_WATCHDOG timer (ISSUE-032) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A claude that stops making progress never exits, so the run loop parked on it forever — no restart, no report, nothing to stop but the whole run. Each launch now arms a timer (CLAUDEZERO_WATCHDOG, default 15m; plain seconds or an s/m/h suffix, 0 disables). On expiry it names itself on its own console line, SIGTERMs claude onto the existing restart path, and escalates to SIGKILL 10s later. It counts down in short naps and is disarmed the moment claude is reaped, so no sleeper outlives its claude and fires at a recycled pid. A mistyped value warns and falls back to the default rather than to no watchdog. Scenario N in TEST.md covers the kill, the SIGKILL escalation, the disabled and healthy paths, and the bad-value fallback. --- README.md | 6 +++++ TEST.md | 59 ++++++++++++++++++++++++++++++++++++++++++++++++ claudezero.sh | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++ todo.md | 2 +- 4 files changed, 128 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c69fd13..0fc41ae 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,12 @@ claudezero -h CLAUDEZERO_MAX_LOOPS=3 claudezero todo.md ``` +**`CLAUDEZERO_WATCHDOG`** — how long one `claude` may run before it is killed. Default `15m`; accepts plain seconds or an `s`/`m`/`h` suffix (`900`, `90s`, `15m`, `1h`), and `0` disables it. A `claude` that stops making progress never exits, so without the timer the loop parks on it forever — no restart, no report, and nothing left to stop but the whole run. When the timer fires it says so on its own console line, then `SIGTERM`s `claude` (the same signal the Stop hook uses, so the restart path is the usual one) and escalates to `SIGKILL` 10s later. The task worktree survives the kill: the next session reclaims it through the crash-recovery path. + +```sh +CLAUDEZERO_WATCHDOG=45m claudezero todo.md +``` + ### Logging a run `claude`'s TUI is written to fd 4, which stays on the terminal, so a pipe captures only ClaudeZero's own `❄` reports instead of every TUI redraw: diff --git a/TEST.md b/TEST.md index d431e99..66ea223 100644 --- a/TEST.md +++ b/TEST.md @@ -1233,6 +1233,65 @@ rm -f "$B" --- +## Scenario N — the `CLAUDEZERO_WATCHDOG` timer `[$TESTROOT/N]` (stub claude, deterministic) + +A claude that stops making progress never exits, so the loop parks on it forever. The watchdog +is the cutoff: it names itself on its own console line, SIGTERMs claude onto the ordinary +restart path, and escalates to SIGKILL for a claude that ignores TERM. A claude that exits on +its own must never see it, and `0` must switch it off. + +### Setup +```bash +TN="$TESTROOT/N"; mkdir -p "$TN/repo" "$TN/bin" +cd "$TN/repo" +git init -q -b main; git config user.email t@t.t; git config user.name test +printf -- '- [ ] N1 x\n' > todo.md; git add -A; git commit -qm init +``` + +### N1 — a hung claude is killed, named, and restarted +```bash +cd "$TN/repo" +printf '#!/usr/bin/env bash\necho "stub hung"\nsleep 1000\n' > "$TN/bin/claude"; chmod +x "$TN/bin/claude" +PATH="$TN/bin:$PATH" CLAUDEZERO_WATCHDOG=5 timeout 90 env CLAUDEZERO_MAX_LOOPS=2 bash "$SCRIPT" todo.md -t x > "$TN/hang.log" 2>&1 +echo "N1 exit : $? (want 0 — the watchdog's kill is a restart, not a failure of the run)" +echo "N1 watchdog line : $(grep -c '❄ watchdog · claude ran 5s without exiting · killing it (CLAUDEZERO_WATCHDOG=5)' "$TN/hang.log") (want 2 — its own line, once per hung launch)" +echo "N1 restart code : $(grep -c 'claude exited with code 143 after 1 runs · restarting in' "$TN/hang.log") (want 1 — SIGTERM, so the tested restart path)" +echo "N1 runs : $(grep -c 'stub hung' "$TN/hang.log") (want 2 — the loop went on instead of parking on run 1)" +echo "N1 orphans : $(pgrep -f "$TN/bin/claude" | wc -l | tr -d ' ') (want 0)" +``` +- **N1 PASS** — `exit = 0`, `watchdog line = 2`, `restart code = 1`, `runs = 2`, `orphans = 0`. + +### N2 — a claude that ignores SIGTERM is SIGKILLed +```bash +cd "$TN/repo" +printf '#!/usr/bin/env bash\ntrap "" TERM\necho "stub deaf"\nsleep 1000\n' > "$TN/bin/claude"; chmod +x "$TN/bin/claude" +PATH="$TN/bin:$PATH" CLAUDEZERO_WATCHDOG=5 timeout 90 env CLAUDEZERO_MAX_LOOPS=1 bash "$SCRIPT" todo.md -t x > "$TN/deaf.log" 2>&1 +echo "N2 exit : $? (want 0 — not 124: the timer, not the harness, ended it)" +echo "N2 kill code : $(grep -c 'claude exited with code 137 after 1 runs' "$TN/deaf.log") (want 1 — 128+9, the escalation ~10s after the ignored TERM)" +echo "N2 orphans : $(pgrep -f "$TN/bin/claude" | wc -l | tr -d ' ') (want 0)" +``` +- **N2 PASS** — `exit = 0`, `kill code = 1`, `orphans = 0`. + +### N3 — off by request, silent on a healthy claude, default on a mistyped value +```bash +cd "$TN/repo" +printf '#!/usr/bin/env bash\necho "stub hung"\nsleep 1000\n' > "$TN/bin/claude"; chmod +x "$TN/bin/claude" +PATH="$TN/bin:$PATH" CLAUDEZERO_WATCHDOG=0 timeout 20 env CLAUDEZERO_MAX_LOOPS=1 bash "$SCRIPT" todo.md -t x > "$TN/off.log" 2>&1 +echo "N3 off exit : $? (want 124 — 0 disables the timer, so the hang is left alone)" +echo "N3 off line : $(grep -c '❄ watchdog' "$TN/off.log") (want 0)" +printf '#!/usr/bin/env bash\necho "stub ran"\nexit 0\n' > "$TN/bin/claude"; chmod +x "$TN/bin/claude" +PATH="$TN/bin:$PATH" CLAUDEZERO_WATCHDOG=5 timeout 40 env CLAUDEZERO_MAX_LOOPS=1 bash "$SCRIPT" todo.md -t x > "$TN/ok.log" 2>&1 +echo "N3 healthy exit : $? (want 0)" +echo "N3 healthy line : $(grep -c '❄ watchdog' "$TN/ok.log") (want 0 — a claude that exits on its own retires its own timer)" +PATH="$TN/bin:$PATH" CLAUDEZERO_WATCHDOG=15min timeout 40 env CLAUDEZERO_MAX_LOOPS=1 bash "$SCRIPT" todo.md -t x > "$TN/bad.log" 2>&1 +echo "N3 bad exit : $? (want 0)" +echo "N3 bad warning : $(grep -c 'ignoring CLAUDEZERO_WATCHDOG=15min' "$TN/bad.log") (want 1 — a typo falls back to the default, never to no watchdog)" +``` +- **N3 PASS** — `off exit = 124` with `off line = 0`, `healthy exit = 0` with `healthy line = 0`, + and `bad exit = 0` with `bad warning = 1`. + +--- + ## Run all in parallel (optional) After Section 0 and each Setup, launch the Run blocks together: put A's and C's run diff --git a/claudezero.sh b/claudezero.sh index 3aff637..8be93db 100755 --- a/claudezero.sh +++ b/claudezero.sh @@ -18,6 +18,8 @@ PROG="$(basename "$0")" # name shown in usage/errors, from how the script was RESTART_WAIT=5 # seconds between claude restarts — the window to press Ctrl+C WAIT_TICK=5 # seconds between claimable-task probes while every unchecked task is peer-held +WATCHDOG_DEFAULT=15m # CLAUDEZERO_WATCHDOG default: how long ONE claude may run before it is killed +WATCHDOG_GRACE=10 # seconds the watchdog waits after its SIGTERM before escalating to SIGKILL # The braces around the pipe reader in the logging example are load-bearing — do NOT tidy them # into a bare pipe. Ctrl+C signals the whole foreground group; the reader's default SIGINT action @@ -140,6 +142,14 @@ on_term() { } trap on_term TERM +WATCHDOG_SECS="$(parse_dur "${CLAUDEZERO_WATCHDOG:-$WATCHDOG_DEFAULT}" || true)" +WATCHDOG_RAW="${CLAUDEZERO_WATCHDOG:-$WATCHDOG_DEFAULT}" +if [ -z "$WATCHDOG_SECS" ]; then + echo "$PROG: ignoring CLAUDEZERO_WATCHDOG=$WATCHDOG_RAW (want 900, 90s, 15m, 1h, or 0 to disable) — using $WATCHDOG_DEFAULT" >&2 + WATCHDOG_RAW="$WATCHDOG_DEFAULT"; WATCHDOG_SECS="$(parse_dur "$WATCHDOG_DEFAULT")" +fi +WATCHDOG_PID="" # set per launch by arm_watchdog, cleared by disarm_watchdog + reap_dead_sessions # startup: clear markers left by crashed prior runs before the first claude while true; do # zero mode: the SHELL decides whether a claude session is worth starting. Nothing left → the @@ -160,6 +170,7 @@ while true; do CLAUDEZERO_INSTANCE="$INSTANCE_ID" CLAUDEZERO_TRANSCRIPTS="$TRANSCRIPTS_FILE" CLAUDEZERO_MODE="$MODE" \ claude "${CLAUDE_ARGS[@]}" "$PROMPT" >&4 2>&4 & CLAUDE_PID=$! + arm_watchdog "$CLAUDE_PID" # backgrounded on purpose: bash defers every trap until a FOREGROUND child exits, so a TERM # arriving while claude hangs could never be handled. `wait` IS interruptible — it returns # 128+N when a trapped signal fires — so re-enter it until claude is actually gone. @@ -169,6 +180,7 @@ while true; do CLAUDE_EXIT=$? kill -0 "$CLAUDE_PID" 2>/dev/null || break done + disarm_watchdog # claude is gone: retire its timer before the pid can be recycled # claude killed mid-run (Ctrl+C/SIGTERM) can leave the tty in raw mode with ISIG off; then every # later Ctrl+C arrives as a 0x03 byte, not a SIGINT, so the INT trap never fires and the loop # spins forever restarting claude on a wedged terminal. Restore cooked mode so Ctrl+C signals again. @@ -373,6 +385,56 @@ fmt_dur() { else printf '%ds' "$s"; fi } +# parse a CLAUDEZERO_WATCHDOG duration — `900`, `90s`, `15m`, `1h`, or `0` to disable — into +# seconds on stdout. Garbage prints nothing and returns 1: a mistyped timer must fall back to the +# default, never silently mean "no watchdog" (0) or "kill at once". +parse_dur() { + local v=$1 n mult=1 + case "$v" in + *s) n=${v%s} ;; + *m) n=${v%m}; mult=60 ;; + *h) n=${v%h}; mult=3600 ;; + *) n=$v ;; + esac + case "$n" in ''|*[!0-9]*) return 1;; esac + printf '%s' $((n * mult)) +} + +# start the timer for the claude at $1 (no-op when the watchdog is disabled). A claude that stops +# making progress never exits, so the loop would park on it forever — no restart, no report, and +# nothing to Ctrl+C but the whole run. SIGTERM is what the Stop hook already uses, so the kill lands +# on the tested restart path; SIGKILL follows for a claude that ignores it. The watchdog names +# itself on its own console line, so the exit-code line under it is not read as claude's own choice. +# It counts down in WAIT_TICK naps instead of one long sleep so a claude that exits on its own +# retires its timer within a tick — an armed sleeper outliving its claude would eventually fire at +# a pid the OS has since handed to somebody else. +arm_watchdog() { + WATCHDOG_PID="" + [ "$WATCHDOG_SECS" -gt 0 ] || return 0 + local pid=$1 + { + local left=$WATCHDOG_SECS + while [ "$left" -gt 0 ] && kill -0 "$pid" 2>/dev/null; do + sleep "$WAIT_TICK"; left=$((left - WAIT_TICK)) + done + if kill -0 "$pid" 2>/dev/null; then + printf '\n❄ watchdog · claude ran %s without exiting · killing it (CLAUDEZERO_WATCHDOG=%s)\n' \ + "$(fmt_dur "$WATCHDOG_SECS")" "$WATCHDOG_RAW" + kill -TERM "$pid" 2>/dev/null || true + sleep "$WATCHDOG_GRACE" + kill -KILL "$pid" 2>/dev/null || true + fi + } & + WATCHDOG_PID=$! +} + +# retire the timer the moment claude is reaped, so nothing is left counting down against a dead pid. +disarm_watchdog() { + [ -n "$WATCHDOG_PID" ] || return 0 + kill "$WATCHDOG_PID" 2>/dev/null || true + WATCHDOG_PID="" +} + # format a token count as 5.8M / 84.3k / 312 — integer arithmetic only (bash 3.2 has no floats). fmt_tok() { local n=$1 diff --git a/todo.md b/todo.md index 9205589..5ad4d30 100644 --- a/todo.md +++ b/todo.md @@ -18,4 +18,4 @@ - [x] BUG-029 Background claude and trap TERM so a SIGTERM while claude hangs still lands at the closing report and exits 143 - [x] ISSUE-030 Surface claude's own exit code on the restart/stop lines and add an opt-in `CLAUDEZERO_DEBUG` `--debug-file` capture - [x] ISSUE-031 Zero one task per claude session and wait for the next claimable task in the shell — drop `/loop`, keep the context-full restart -- [ ] ISSUE-032 Kill a hung claude with a `CLAUDEZERO_WATCHDOG` timer (default 15m) and name the watchdog on its own console line +- [x] ISSUE-032 Kill a hung claude with a `CLAUDEZERO_WATCHDOG` timer (default 15m) and name the watchdog on its own console line From b9baecf4e926b2daf9f5a91ac449a4ed523b14a1 Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Mon, 3 Aug 2026 09:43:15 +0200 Subject: [PATCH 12/22] feat: link gitignored spec dirs into task worktrees and pace the waiting line (ISSUE-033, ISSUE-031) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A task worktree is a checkout of tracked files only, so a gitignored spec directory a todo line points at is simply absent there. A session claiming `ISSUE-031` opened no spec, fell back to the one-line title, and reported done — which is how ISSUE-032 shipped without the `--help` Environment: block its own acceptance criteria required, with none of those criteria ticked. Nothing in the zero prompt was at fault; step 2.c already says the criteria define done. New CLAUDEZERO_LINK, comma-separated top-level names, unset by default, symlinks each named entry from the repo root into every fresh task worktree. Linked, not copied, so a tick lands in the real file instead of in a copy the worktree removal deletes. Each name is added to info/exclude, which a .gitignore rule cannot cover: a `name/` pattern matches a DIRECTORY, and git records a symlink as a blob, so without the exclude the link shows as untracked and rides along in the session's `git add -A` — the exact publishing the variable exists to avoid. The whole list is validated once at startup and refuses the run, naming the offending entry, if one is empty, contains a `/`, or is missing at the root. Skipping a bad entry per claim would be silent: sessions would keep launching, each unable to read its spec, each reporting done. `link_ignored` therefore carries no validation, only the occupied-name guard — `[ ! -e ] && [ ! -L ]`, both halves needed because -e follows a link, so a dangling one reads as absent and the ln -s dies "File exists". The plain two-argument POSIX ln -s form is used throughout; -r/-f/-n diverge between BSD and GNU. smoke.sh grows a case for the form and the guard pair, including the dangling case. The waiting line is no longer paced by the claimable probe, which made it read as a hung terminal and made a piped run four times longer than it needs to be. On a terminal the spinner steps once a second (WAIT_FRAME) with the elapsed clock floored to 5-second steps (WAIT_STEP); on a log or a pipe one plain line every 20 seconds (LOG_TICK). All three are named constants beside WAIT_TICK, so the probe interval can move without silently moving the display with it. Tests: new TEST.md Scenario O covers the link, write-through, an empty git status and `git add -A`, the unset and unlisted cases, a dangling link, a repeat claim, the untouched merge gate, and the startup refusals. Scenario M grows M5, which measures the frame rate and clock step under a pty; M1 moves to a 45s window for the 20s log spacing. Two harness bugs fixed along the way: Scenario I's I4 fault injection seds the acquire_task line this change rewrites, and would have become a silent no-op; and M1's `no escapes` check used GNU-only `\|` alternation with an unbalanced bracket, so it has never run on the macOS leg. Co-Authored-By: Claude Opus 5 (1M context) --- .github/smoke.sh | 13 +++++ README.md | 6 ++ TEST.md | 145 +++++++++++++++++++++++++++++++++++++++++++++-- claudezero.sh | 73 +++++++++++++++++++++--- todo.md | 5 +- 5 files changed, 226 insertions(+), 16 deletions(-) diff --git a/.github/smoke.sh b/.github/smoke.sh index 1685508..82a0ed3 100755 --- a/.github/smoke.sh +++ b/.github/smoke.sh @@ -104,4 +104,17 @@ sums="$(awk ' [ "$sums" = "10 20 248 1000 1278" ] || fail "usage awk got '$sums' (want '10 20 248 1000 1278')" ok "sed transcript_path / awk usage dedupe" +# 9. CLAUDEZERO_LINK symlink — plain POSIX `ln -s target link` (claudezero.sh link_ignored). +# BSD and GNU ln agree on the two-argument form and diverge on -r/-f/-n, which is why none are +# used. The guard is `[ ! -e ] && [ ! -L ]`: -e FOLLOWS the link, so a dangling link reads as +# absent and an unguarded ln would die "File exists". +ldir="$tmp/linksrc"; mkdir -p "$ldir"; echo "criterion" > "$ldir/spec.md" +ln -s "$ldir" "$tmp/link" || fail "ln -s

failed" +[ -L "$tmp/link" ] || fail "-L did not see the symlink" +[ "$(cat "$tmp/link/spec.md")" = criterion ] || fail "read through symlink failed" +ln -s "$tmp/nowhere" "$tmp/dangling" || fail "ln -s to a missing target failed" +[ -L "$tmp/dangling" ] || fail "-L did not see the dangling link" +if [ -e "$tmp/dangling" ]; then fail "-e followed a dangling link (guard would misfire)"; fi +ok "ln -s / -L / -e guard" + echo "SMOKE PASS ($(uname -s), bash $BASH_VERSION)" diff --git a/README.md b/README.md index 0fc41ae..f2c9c29 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,12 @@ CLAUDEZERO_MAX_LOOPS=3 claudezero todo.md CLAUDEZERO_WATCHDOG=45m 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 +CLAUDEZERO_LINK=issues claudezero todo.md +``` + ### Logging a run `claude`'s TUI is written to fd 4, which stays on the terminal, so a pipe captures only ClaudeZero's own `❄` reports instead of every TUI redraw: diff --git a/TEST.md b/TEST.md index 66ea223..4cc5203 100644 --- a/TEST.md +++ b/TEST.md @@ -927,7 +927,7 @@ echo "I3 branch gone : $(git branch --list 'main-task-I3' | wc -l | tr -d ' # I4 — validation failure, FAULT-INJECTED: acquire hands back a detached worktree. Unreachable in # normal operation (acquire always lands on the task branch), so inject rather than fabricate. -sed 's|^ setup_exclude "$wt"; claim_owner "$wt"; set_current "$n"; printf| setup_exclude "$wt"; claim_owner "$wt"; set_current "$n"; git -C "$wt" checkout -q --detach; printf|' \ +sed 's|^ setup_exclude "$wt"; link_ignored "$wt"; claim_owner "$wt"; set_current "$n"; printf| setup_exclude "$wt"; link_ignored "$wt"; claim_owner "$wt"; set_current "$n"; git -C "$wt" checkout -q --detach; printf|' \ "$ZERO" > "$TI/zero-bad.sh"; chmod +x "$TI/zero-bad.sh" out=$("$TI/zero-bad.sh" claim I4 2>&1 >/dev/null); rc=$? echo "I4 exit : $rc (want 3)" @@ -1182,15 +1182,17 @@ printf '%s\n%s\n' "$(ps -o lstart= -p "$PEER" | awk '{$1=$1;print}')" "M1" > "$T cd "$TM/repo" export STUB_LAUNCHED="$TM/launched"; : > "$STUB_LAUNCHED" sleep 600 & DECOY=$! # stands in for the caller's own session: Claude Code exports CLAUDE_PID -PATH="$TM/bin:$PATH" CLAUDE_PID=$DECOY timeout 13 env CLAUDEZERO_MAX_LOOPS=1 bash "$SCRIPT" todo.md -t x > "$TM/wait.log" 2>&1 +PATH="$TM/bin:$PATH" CLAUDE_PID=$DECOY timeout 45 env CLAUDEZERO_MAX_LOOPS=1 bash "$SCRIPT" todo.md -t x > "$TM/wait.log" 2>&1 echo "M1 exit : $? (want 124 — still waiting when the timeout fired, never launched)" echo "M1 decoy alive : $(kill -0 $DECOY 2>/dev/null && echo yes || echo no) (want yes — the TERM trap must never kill an INHERITED CLAUDE_PID; nothing was launched yet)" kill "$DECOY" 2>/dev/null; wait "$DECOY" 2>/dev/null || true echo "M1 launches : $(wc -l < "$STUB_LAUNCHED" | tr -d ' ') (want 0 — no claude process, no transcript, no tokens)" -echo "M1 waiting line : $(grep -c 'waiting for a claimable task · 1 held by peers' "$TM/wait.log") (want >=2 — one plain line per probe)" -echo "M1 no escapes : $(LC_ALL=C grep -c $'\r\|\033\[' "$TM/wait.log") (want 0 — a pipe gets no \r and no cursor moves)" +echo "M1 waiting line : $(grep -c 'waiting for a claimable task · 1 held by peers' "$TM/wait.log") (want 3 — LOG_TICK=20 over a 45s wait, NOT one per WAIT_TICK probe)" +# -F, two -e patterns: `\|` alternation is a GNU BRE extension and `\033[` leaves an unbalanced +# bracket, so the single-pattern form dies "brackets not balanced" on the macOS leg's BSD grep +echo "M1 no escapes : $(LC_ALL=C grep -cF -e $'\r' -e $'\033[' "$TM/wait.log") (want 0 — a pipe gets no \r and no cursor moves)" ``` -- **M1 PASS** — `exit = 124`, `decoy alive = yes`, `launches = 0`, `waiting line >= 2`, `no escapes = 0`. +- **M1 PASS** — `exit = 124`, `decoy alive = yes`, `launches = 0`, `waiting line = 3`, `no escapes = 0`. ### M2 — a dead peer's branch is claimable: claude IS launched to rescue it ```bash @@ -1231,6 +1233,34 @@ rm -f "$B" ``` - **M4 PASS** — `zero mode = 143`, `loop mode = 0`, `bucket branch = 143`. +### M5 — terminal pacing: one frame a second, clock in 5s steps, independent of `WAIT_TICK` +```bash +cd "$TM/repo" +# M2/M3 landed M1, so restore an unchecked, peer-held task for the wait to sit on +printf -- '- [ ] M5 x\n' > todo.md; git add -A; git commit -qm m5 +sleep 600 & PEER5=$! +git worktree add -q -b main-task-M5 "$TM/wt5" main +printf '%s\n%s\n%s\n%s\n' "$PEER5" "$(ps -o lstart= -p "$PEER5" | awk '{$1=$1;print}')" "$(date +%s)" "PEERINST" > "$TM/wt5/.owner" +printf '%s\n%s\n' "$(ps -o lstart= -p "$PEER5" | awk '{$1=$1;print}')" "M5" > "$TM/repo/.git/session/$PEER5" +# a pty is required: the repainting branch is behind `[ -t 1 ]` +/usr/bin/script -q "$TM/tty.txt" env PATH="$TM/bin:$PATH" timeout 21 env CLAUDEZERO_MAX_LOOPS=1 \ + bash "$SCRIPT" todo.md -t x >/dev/null 2>&1 +kill "$PEER5" 2>/dev/null; wait "$PEER5" 2>/dev/null || true +tr '\r' '\n' < "$TM/tty.txt" | grep 'waiting for a claimable task' > "$TM/frames.txt" +N=$(grep -c . "$TM/frames.txt") +echo "M5 repaints : $N ($( [ "$N" -ge 19 ] && [ "$N" -le 22 ] && echo yes || echo NO) — want yes: ~1/s over the 21s window. A probe-paced line would give 4)" +# the exact invariant, immune to a second of startup slop: the frames run |/-\ in order, forever. +# The sequence goes through a PIPE, never `awk -v` — awk expands backslash escapes in a -v value, +# which silently eats the `\` frame and shifts every comparison after it. +SEQ=$(grep -o '❄ .' "$TM/frames.txt" | sed 's/^❄ //' | tr -d '\n') +echo "M5 cycle : $(printf '%s\n' "$SEQ" | awk '{c="|/-\\"; for(i=1;i<=length($0);i++) if (substr($0,i,1) != substr(c,(i-1)%4+1,1)) {print "NO at "i; exit} print "yes"}') (want yes)" +echo "M5 clock steps : $(grep -oE '· [0-9]+m?[0-9]*s' "$TM/frames.txt" | sort -u | tr '\n' ' ') (want only 0s/5s/10s/15s/20s — WAIT_STEP=5)" +echo "M5 no odd clock : $(grep -cE '· [0-9]*[1-46-9]s' "$TM/frames.txt") (want 0 — no 1s/2s/3s ever printed)" +``` +- **M5 PASS** — `repaints = yes`, `cycle = yes`, `clock steps` only multiples of 5, `no odd clock = 0`. + Together they pin the frame rate and the clock step to `WAIT_FRAME`/`WAIT_STEP` rather than to + `WAIT_TICK`: the probe fires 4 times in this window, the line repaints ~21. + --- ## Scenario N — the `CLAUDEZERO_WATCHDOG` timer `[$TESTROOT/N]` (stub claude, deterministic) @@ -1292,6 +1322,111 @@ echo "N3 bad warning : $(grep -c 'ignoring CLAUDEZERO_WATCHDOG=15min' "$TN/bad --- +## Scenario O — `CLAUDEZERO_LINK` into task worktrees `[$TESTROOT/O]` (stub claude, deterministic) + +A worktree checks out tracked files only, so a gitignored spec directory a todo line points at is +absent there and the session works from the title alone. `CLAUDEZERO_LINK` symlinks it in. The link +must be readable, must write through to the real file, and must stay invisible to git — a `name/` +ignore pattern matches a directory, not a symlink to one, so without an `info/exclude` entry the +link rides along in the session's `git add -A`. Like Scenario I, `claim` needs a `claude` **ancestor +process**, so the driver is executed by a copy of `bash` named `claude`. + +### Setup +```bash +TO="$TESTROOT/O"; mkdir -p "$TO/repo" "$TO/bin" +printf '#!/usr/bin/env bash\nexit 0\n' > "$TO/bin/claude"; chmod +x "$TO/bin/claude" +cd "$TO/repo" +git init -q -b main; git config user.email t@t.t; git config user.name test +printf 'issues/\nsources/\n' > .gitignore +printf -- '- [ ] O1 a\n- [ ] O2 b\n- [ ] O3 c\n- [ ] O4 d\n' > todo.md +git add -A; git commit -qm init +mkdir issues sources +printf -- '- [ ] criterion one\n' > issues/ISSUE-O.md # the spec the todo line points at +printf 'private\n' > sources/s.txt # gitignored, NOT listed in CLAUDEZERO_LINK +PATH="$TO/bin:$PATH" timeout 30 env CLAUDEZERO_MAX_LOOPS=1 bash "$SCRIPT" todo.md -t x > "$TO/boot.log" 2>&1 || true +cp "$(command -v bash)" "$TO/bin/claude" # ensure_owner walks `ps -o comm=` for an ancestor named claude +cat > "$TO/drive.sh" <<'DRIVE' +set -uo pipefail +cd "$TO/repo" +ZERO="$(cd "$(git rev-parse --git-dir)" && pwd)/zero.sh" + +# O1 — listed name is linked, readable, and write-through; unlisted name is not linked +wt=$(CLAUDEZERO_LINK=issues "$ZERO" claim O1) +echo "O1 is symlink : $([ -L "$wt/issues" ] && echo yes || echo NO) (want yes — linked, not copied)" +echo "O1 readable : $(cat "$wt/issues/ISSUE-O.md" 2>/dev/null) (want '- [ ] criterion one')" +sed -i'' -e 's/- \[ \]/- [x]/' "$wt/issues/ISSUE-O.md" +echo "O1 write-through : $(cat "$TO/repo/issues/ISSUE-O.md") (want '- [x] criterion one' — no commit, no merge)" +echo "O1 status empty : $([ -z "$(git -C "$wt" status --short)" ] && echo yes || echo NO) (want yes — the exclude covers the symlink)" +git -C "$wt" add -A +echo "O1 add -A stages : $(git -C "$wt" status --short | grep -c 'issues') (want 0 — the link never reaches a commit)" +echo "O1 unlisted absent : $([ -e "$wt/sources" ] && echo NO || echo yes) (want yes — only listed names are linked)" +echo "O1 exclude entry : $(grep -c '^/issues$' "$(git -C "$wt" rev-parse --git-path info/exclude)") (want 1 — info/exclude is the COMMON dir's, shared by every worktree)" + +# O2 — unset variable links nothing at all +wt2=$("$ZERO" claim O2) +echo "O2 no link : $([ -e "$wt2/issues" ] && echo NO || echo yes) (want yes — default is off)" + +# O3 — link_ignored itself no longer re-checks the list: a bad entry cannot reach it, because +# startup refused the run (see O6). Its only remaining guard is the occupied-name test. +echo "O3 guards : $(sed -n '/^link_ignored() {/,/^}/p' "$ZERO" | grep -c '\-e "\$root/\$p"\|case "\$p" in') (want 0 — validation lives at startup, once)" + +# O4 — a second claim appends no duplicate exclude entry and leaves the link alone +wt4=$(CLAUDEZERO_LINK=issues "$ZERO" claim O4) +EX4="$(git -C "$wt4" rev-parse --git-path info/exclude)" +CLAUDEZERO_LINK=issues "$ZERO" claim O4 >/dev/null 2>&1 || true +echo "O4 exclude dupes : $(grep -c '^/issues$' "$EX4") (want 1 — claiming twice appends once)" +echo "O4 link intact : $([ -L "$wt4/issues" ] && echo yes || echo NO) (want yes)" +# the -L half of the guard, called directly — a second `claim` of a task this session already holds +# returns 1 before ever reaching link_ignored (see I2), so it cannot exercise this. A DANGLING link +# is `-L` true but `-e` false: without the -L test the `ln -s` dies "File exists" on BSD and GNU +# alike, and `set -e` inside acquire_task takes the whole claim down with it. +sed -n '/^link_ignored() {/,/^}/p' "$ZERO" > "$TO/li.sh" +rm -f "$wt4/issues"; ln -s "$TO/gone" "$wt4/issues" +( set -euo pipefail; . "$TO/li.sh"; CLAUDEZERO_LINK=issues link_ignored "$wt4" ); rc=$? +echo "O4 dangling exit : $rc (want 0 — the -L guard skips the name instead of failing on ln -s)" +echo "O4 dangling kept : $([ -L "$wt4/issues" ] && [ ! -e "$wt4/issues" ] && echo yes || echo NO) (want yes)" + +# O5 — the merge gate is unchanged: ticks in a linked file are not counted as checked boxes +sed -i'' -e 's/^- \[ \] O1 /- [x] O1 /' "$wt/todo.md" +git -C "$wt" add todo.md; git -C "$wt" commit -qm 'O1 done' +"$ZERO" merge O1 "$wt" > "$TO/merge.log" 2>&1 +echo "O5 merge exit : $? (want 0 — the gate scans only the todo file)" +echo "O5 refusal : $(grep -c 'checkbox-merge: refused' "$TO/merge.log") (want 0)" +DRIVE +``` + +### Run + assert +```bash +TO="$TO" "$TO/bin/claude" "$TO/drive.sh" +``` + +### O6 — a bad `CLAUDEZERO_LINK` refuses the run at startup, before any claude +```bash +cd "$TO/repo" +git checkout -q -- todo.md 2>/dev/null; git stash -q 2>/dev/null || true # O5 left the todo merged +export STUB_LAUNCHED="$TO/launched"; : > "$STUB_LAUNCHED" +printf '#!/usr/bin/env bash\necho launched >> "$STUB_LAUNCHED"\nexit 0\n' > "$TO/bin/cz-stub"; chmod +x "$TO/bin/cz-stub" +mkdir -p "$TO/stub"; cp "$TO/bin/cz-stub" "$TO/stub/claude" +run_bad(){ PATH="$TO/stub:$PATH" CLAUDEZERO_LINK="$1" timeout 30 env CLAUDEZERO_MAX_LOOPS=1 \ + bash "$SCRIPT" todo.md -t x > "$TO/bad-$2.log" 2>&1; echo $?; } +echo "O6 missing exit : $(run_bad 'nosuchdir' missing) (want 1)" +echo "O6 missing names it: $(grep -c "CLAUDEZERO_LINK entry 'nosuchdir' does not exist at" "$TO/bad-missing.log") (want 1)" +echo "O6 nested exit : $(run_bad 'issues/nested' nested) (want 1)" +echo "O6 nested names it : $(grep -c "CLAUDEZERO_LINK entry 'issues/nested' is not a top-level name" "$TO/bad-nested.log") (want 1)" +echo "O6 one bad kills : $(run_bad 'issues,nosuchdir' mixed) (want 1 — a good entry does not excuse a bad one)" +echo "O6 no launches : $(wc -l < "$STUB_LAUNCHED" | tr -d ' ') (want 0 — refused before the first session, so no tokens)" +``` +- **O6 PASS** — `missing exit = 1`, `nested exit = 1`, `one bad kills = 1`, each with its naming + line = 1, and `no launches = 0`. + +- **O PASS** — every line reports its `want` value. Together they cover the link and its + readability (O1), write-through with no commit and an empty `git add -A` (O1), the unlisted and + unset cases (O1/O2), `link_ignored` carrying no validation of its own (O3), a second claim + appending no duplicate `info/exclude` entry and a dangling link surviving the `-L` guard (O4), + the untouched merge gate (O5), and the startup refusals (O6). + +--- + ## Run all in parallel (optional) After Section 0 and each Setup, launch the Run blocks together: put A's and C's run diff --git a/claudezero.sh b/claudezero.sh index 8be93db..2ec10f8 100755 --- a/claudezero.sh +++ b/claudezero.sh @@ -18,6 +18,9 @@ PROG="$(basename "$0")" # name shown in usage/errors, from how the script was RESTART_WAIT=5 # seconds between claude restarts — the window to press Ctrl+C WAIT_TICK=5 # seconds between claimable-task probes while every unchecked task is peer-held +WAIT_FRAME=1 # seconds per spinner frame on a terminal — one |/-\ revolution every 4 +WAIT_STEP=5 # seconds the terminal's elapsed clock advances in — at 1Hz a live clock is noise +LOG_TICK=20 # seconds between waiting lines when stdout is a log or a pipe, not a terminal WATCHDOG_DEFAULT=15m # CLAUDEZERO_WATCHDOG default: how long ONE claude may run before it is killed WATCHDOG_GRACE=10 # seconds the watchdog waits after its SIGTERM before escalating to SIGKILL @@ -168,6 +171,7 @@ while true; do CLAUDE_ARGS+=(--debug-file "$DEBUG_FILE_BASE-$((LOOP_COUNT+1)).log") fi CLAUDEZERO_INSTANCE="$INSTANCE_ID" CLAUDEZERO_TRANSCRIPTS="$TRANSCRIPTS_FILE" CLAUDEZERO_MODE="$MODE" \ + CLAUDEZERO_LINK="${CLAUDEZERO_LINK:-}" \ claude "${CLAUDE_ARGS[@]}" "$PROMPT" >&4 2>&4 & CLAUDE_PID=$! arm_watchdog "$CLAUDE_PID" @@ -270,6 +274,20 @@ else REPO_ROOT="$(git worktree list --porcelain | sed -n '1s/^worktree //p')" [ "$PWD" = "$REPO_ROOT" ] || { echo "$PROG: not at the main repo root — cd to '$REPO_ROOT' first. (ClaudeZero's own ../ts-* task worktrees are never valid launch dirs.)"; exit 1; } [ -z "$(git status --porcelain)" ] || { echo "$PROG: working tree on '$BASE_BRANCH' is dirty — commit or stash first."; git status --short; exit 1; } + # CLAUDEZERO_LINK: validate the whole list once, here, and refuse the run. Skipping a bad entry + # per claim would be silent: sessions would keep starting, each unable to open the spec its todo + # line points at, each working from the one-line title and reporting done. A typo must cost the + # launch, not the tasks. Checked in zero mode only — `-l` forks no worktree to link into. + if [ -n "${CLAUDEZERO_LINK:-}" ]; then + ( IFS=, + for p in $CLAUDEZERO_LINK; do + case "$p" in + '') echo "$PROG: empty entry in CLAUDEZERO_LINK='$CLAUDEZERO_LINK'" >&2; exit 1 ;; + */*) echo "$PROG: CLAUDEZERO_LINK entry '$p' is not a top-level name — only entries directly under $REPO_ROOT can be linked" >&2; exit 1 ;; + esac + [ -e "$REPO_ROOT/$p" ] || { echo "$PROG: CLAUDEZERO_LINK entry '$p' does not exist at $REPO_ROOT" >&2; exit 1; } + done ) || exit 1 + fi printf ' zero mode · base %s · fork → implement → commit → merge\n\n' "$BASE_BRANCH" # todo path: from the positional arg, else ask interactively. if [ -n "$TODO_ARG" ]; then TODO_PATH="$TODO_ARG"; else read -r -p "Path to todo.md file: " TODO_PATH; fi @@ -547,7 +565,7 @@ held_todos() { # block until at least one unchecked task is free to claim. 0 = launch claude; 1 = break to the # closer (Ctrl+C/SIGTERM, or nothing unchecked left). No claude runs while we wait. wait_for_claimable() { - local start=0 spin=0 u h frames="|/-\\" + local start=0 last=0 spin=0 i u h frames="|/-\\" while true; do if [ "$STOP" = 1 ]; then return 1; fi u=$(unchecked_todos); h=$(held_todos) @@ -557,16 +575,31 @@ wait_for_claimable() { return 0 fi if [ "$start" = 0 ]; then start=$(date +%s); fi - # repaint in place on a terminal; one plain line per probe when stdout is a log or a pipe. + # repaint in place on a terminal; plain heartbeat lines when stdout is a log or a pipe. Neither + # surface is paced by the probe — see WAIT_FRAME / WAIT_STEP / LOG_TICK. if [ -t 1 ]; then - printf '\r❄ %s waiting for a claimable task · %s held by peers · %s\033[K' \ - "${frames:$((spin%4)):1}" "$h" "$(fmt_dur $(( $(date +%s) - start )))" + # animate across the whole nap, not once per probe: a line that only moves every WAIT_TICK + # reads as a hang. Only the frame and the clock move — the counts stay the probe's. The clock + # is floored to WAIT_STEP because at one frame a second a live seconds count is just noise. + i=0 + while [ "$i" -lt $((WAIT_TICK / WAIT_FRAME)) ]; do + printf '\r❄ %s waiting for a claimable task · %s held by peers · %s\033[K' \ + "${frames:$((spin%4)):1}" "$h" \ + "$(fmt_dur $(( ( ( $(date +%s) - start ) / WAIT_STEP ) * WAIT_STEP )))" + spin=$((spin+1)); i=$((i+1)) + sleep "$WAIT_FRAME" || true # SIGINT interrupts sleep and fires the INT trap + if [ "$STOP" = 1 ]; then return 1; fi + done else - printf '❄ waiting for a claimable task · %s held by peers · %s\n' \ - "$h" "$(fmt_dur $(( $(date +%s) - start )))" + # a log wants a heartbeat, not the probe cadence: one line per LOG_TICK, so a wait that runs + # overnight leaves a readable trail instead of burying the run's own reports under itself. + if [ $(( $(date +%s) - last )) -ge "$LOG_TICK" ]; then + last=$(date +%s) + printf '❄ waiting for a claimable task · %s held by peers · %s\n' \ + "$h" "$(fmt_dur $(( last - start )))" + fi + sleep "$WAIT_TICK" || true # SIGINT interrupts sleep and fires the INT trap fi - spin=$((spin+1)) - sleep "$WAIT_TICK" || true # SIGINT interrupts sleep and fires the INT trap done } @@ -905,6 +938,28 @@ setup_exclude() { # keep zero mode's .owner out o grep -qxF '/.owner' "$ex" 2>/dev/null || echo '/.owner' >> "$ex" } +# CLAUDEZERO_LINK: comma-separated top-level names symlinked from the repo root into a fresh task +# worktree. A worktree checks out TRACKED files only, so gitignored spec material a task's todo +# line points at is absent there and the agent works from the one-line title alone. Linked, not +# copied: an edit lands in the real file, not in a copy the worktree removal deletes. Added to +# info/exclude (the common dir's, like setup_exclude's) because a `name/` .gitignore pattern matches +# a DIRECTORY, not a symlink to one — unexcluded, the link rides along in the agent's `git add -A`. +link_ignored() { + local wt=$1 root p ex IFS=, + [ -n "${CLAUDEZERO_LINK:-}" ] || return 0 + root=$(git rev-parse --show-toplevel) + ex="$(git -C "$wt" rev-parse --git-path info/exclude)"; mkdir -p "$(dirname "$ex")" + for p in $CLAUDEZERO_LINK; do # entries were validated at startup, not re-checked here + # -e AND -L: `-e` follows the link, so a DANGLING link at that name reads as absent and the + # `ln -s` below would die `File exists`. Plain two-argument POSIX form — BSD and GNU agree on + # it, and diverge on the `-r`/`-f`/`-n` flags this deliberately avoids. + if [ ! -e "$wt/$p" ] && [ ! -L "$wt/$p" ]; then + ln -s "$root/$p" "$wt/$p" + grep -qxF "/$p" "$ex" 2>/dev/null || echo "/$p" >> "$ex" + fi + done +} + # acquire: print worktree path + exit 0 if claimed; exit 1 = could not acquire (skip). Holds a # per-task lock for the WHOLE decision so two peers never race the same task_id (distinct ids use # distinct locks, so tasks still run in parallel). fd 9 = bash-3.2-safe; auto-released on exit. @@ -946,7 +1001,7 @@ acquire_task() { else flock "$WT_LOCK" git worktree add "$wt" -b "$branch" "$BASE_BRANCH" >/dev/null 2>&1 || return 1 # fresh fork fi - setup_exclude "$wt"; claim_owner "$wt"; set_current "$n"; printf '%s' "$wt"; return 0 + setup_exclude "$wt"; link_ignored "$wt"; claim_owner "$wt"; set_current "$n"; printf '%s' "$wt"; return 0 } # claim: the WHOLE "is this task mine to work?" decision in one call — acquire, validate the diff --git a/todo.md b/todo.md index 5ad4d30..b3c1bfa 100644 --- a/todo.md +++ b/todo.md @@ -17,5 +17,6 @@ - [x] ISSUE-028 Show the script version on its own line below the usage heading in `--help` - [x] BUG-029 Background claude and trap TERM so a SIGTERM while claude hangs still lands at the closing report and exits 143 - [x] ISSUE-030 Surface claude's own exit code on the restart/stop lines and add an opt-in `CLAUDEZERO_DEBUG` `--debug-file` capture -- [x] ISSUE-031 Zero one task per claude session and wait for the next claimable task in the shell — drop `/loop`, keep the context-full restart -- [x] ISSUE-032 Kill a hung claude with a `CLAUDEZERO_WATCHDOG` timer (default 15m) and name the watchdog on its own console line +- [ ] ISSUE-031 Zero one task per claude session and wait for the next claimable task in the shell — drop `/loop`, keep the context-full restart +- [ ] ISSUE-032 Kill a hung claude with a `CLAUDEZERO_WATCHDOG` timer (default 15m) and name the watchdog on its own console line +- [ ] ISSUE-033 Symlink gitignored spec directories into every task worktree with `CLAUDEZERO_LINK` so a session reads the acceptance criteria its todo line points at From 8fb4a73e3a8664d179e724575ad121b9ba07ac8e Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Mon, 3 Aug 2026 09:55:27 +0200 Subject: [PATCH 13/22] feat: name CLAUDEZERO_LINK on the --help screen (ISSUE-033) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDEZERO_LINK is the only way a session ever sees the spec files its todo lines point at, but it lived in the README alone — a user who runs `-h` had no way to learn it exists short of reading the source. `usage()` grows an `Environment:` block naming the variable, its comma-separated form, that it is unset by default, and why a worktree needs the link at all: a worktree checks out tracked files only, so a gitignored spec directory is simply absent there. The block is plain text under the `-t and -l` line, untouched by the heredoc's sed constant injection. Tests: TEST.md Scenario O grows O7, which greps the rendered `-h` output for the block, the variable's comma form, and the default line. Scenario O and O6 were re-run against the edited script and still pass. todo.md ticks ISSUE-033: the implementation landed in b9baecf, and this change closes the last open criterion apart from the Linux CI leg, which needs the branch pushed. Co-Authored-By: Claude Opus 5 (1M context) --- TEST.md | 11 ++++++++++- claudezero.sh | 9 +++++++++ todo.md | 2 +- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/TEST.md b/TEST.md index 4cc5203..a6f458d 100644 --- a/TEST.md +++ b/TEST.md @@ -1419,11 +1419,20 @@ echo "O6 no launches : $(wc -l < "$STUB_LAUNCHED" | tr -d ' ') (want 0 — - **O6 PASS** — `missing exit = 1`, `nested exit = 1`, `one bad kills = 1`, each with its naming line = 1, and `no launches = 0`. +### O7 — `--help` names the variable, so it is discoverable without the README +```bash +H="$(bash "$SCRIPT" -h)" +echo "O7 env block : $(printf '%s' "$H" | grep -c 'Environment:') (want 1)" +echo "O7 names the var : $(printf '%s' "$H" | grep -c 'CLAUDEZERO_LINK=name\[,name') (want 1 — the comma-separated form)" +echo "O7 says default : $(printf '%s' "$H" | grep -c 'Unset by default') (want 1)" +``` +- **O7 PASS** — all three = 1. + - **O PASS** — every line reports its `want` value. Together they cover the link and its readability (O1), write-through with no commit and an empty `git add -A` (O1), the unlisted and unset cases (O1/O2), `link_ignored` carrying no validation of its own (O3), a second claim appending no duplicate `info/exclude` entry and a dangling link surviving the `-L` guard (O4), - the untouched merge gate (O5), and the startup refusals (O6). + the untouched merge gate (O5), the startup refusals (O6), and the `--help` `Environment:` block (O7). --- diff --git a/claudezero.sh b/claudezero.sh index 2ec10f8..cb8bb2f 100755 --- a/claudezero.sh +++ b/claudezero.sh @@ -47,6 +47,15 @@ version @@VERSION@@ -t and -l are mutually exclusive. + Environment: + + CLAUDEZERO_LINK=name[,name…] Top-level directories symlinked from the repo root + into every task worktree. Unset by default. A worktree + checks out tracked files only, so gitignored spec + directories a todo line points at are absent there; + listing them here lets a session read the acceptance + criteria and tick them in the real file. + Log a run (ClaudeZero's own output only; claude's TUI stays on the terminal): @@PROG@@ issues/todo.md 2>&1 | { trap '' INT; tee ../run.log; } diff --git a/todo.md b/todo.md index b3c1bfa..4a0fcfa 100644 --- a/todo.md +++ b/todo.md @@ -19,4 +19,4 @@ - [x] ISSUE-030 Surface claude's own exit code on the restart/stop lines and add an opt-in `CLAUDEZERO_DEBUG` `--debug-file` capture - [ ] ISSUE-031 Zero one task per claude session and wait for the next claimable task in the shell — drop `/loop`, keep the context-full restart - [ ] ISSUE-032 Kill a hung claude with a `CLAUDEZERO_WATCHDOG` timer (default 15m) and name the watchdog on its own console line -- [ ] ISSUE-033 Symlink gitignored spec directories into every task worktree with `CLAUDEZERO_LINK` so a session reads the acceptance criteria its todo line points at +- [x] ISSUE-033 Symlink gitignored spec directories into every task worktree with `CLAUDEZERO_LINK` so a session reads the acceptance criteria its todo line points at From 27aa5af5e22aa88fbb57519a96141f0cc70022e2 Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Mon, 3 Aug 2026 10:07:18 +0200 Subject: [PATCH 14/22] chore: check off ISSUE-031 after verifying its acceptance criteria (ISSUE-031) Implementation landed in ae0aa3d/b9baecf; the box was never ticked. Verified: scenario M (M1-M5) all pass, shellcheck + bash 3.2 parse clean, absence greps for /loop and LOOP_INTERVAL return nothing, README bullet present. Co-Authored-By: Claude Opus 5 (1M context) --- todo.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/todo.md b/todo.md index 4a0fcfa..363c92d 100644 --- a/todo.md +++ b/todo.md @@ -17,6 +17,6 @@ - [x] ISSUE-028 Show the script version on its own line below the usage heading in `--help` - [x] BUG-029 Background claude and trap TERM so a SIGTERM while claude hangs still lands at the closing report and exits 143 - [x] ISSUE-030 Surface claude's own exit code on the restart/stop lines and add an opt-in `CLAUDEZERO_DEBUG` `--debug-file` capture -- [ ] ISSUE-031 Zero one task per claude session and wait for the next claimable task in the shell — drop `/loop`, keep the context-full restart +- [x] ISSUE-031 Zero one task per claude session and wait for the next claimable task in the shell — drop `/loop`, keep the context-full restart - [ ] ISSUE-032 Kill a hung claude with a `CLAUDEZERO_WATCHDOG` timer (default 15m) and name the watchdog on its own console line - [x] ISSUE-033 Symlink gitignored spec directories into every task worktree with `CLAUDEZERO_LINK` so a session reads the acceptance criteria its todo line points at From cc3f9079544cc2ee3ca2f6425e2d58b4e1fc6031 Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Mon, 3 Aug 2026 10:31:35 +0200 Subject: [PATCH 15/22] feat: sample claude's CPU time for the watchdog and name the knob in --help (ISSUE-032) The watchdog shipped as a flat wall-clock cap, which kills the honest long runs the loop exists to leave unattended. Sample claude's own cumulative CPU time instead and restart the window on every advance: a claude that thinks, streams or runs tools burns CPU; one blocked on a dead socket burns none. The kill line now says "no progress from claude for N" rather than "ran N without exiting". Also document CLAUDEZERO_WATCHDOG in the --help Environment block, which listed only CLAUDEZERO_LINK, and add TEST.md N4: a stub burning CPU for 3x the window finishes on its own with no watchdog line. --- README.md | 2 +- TEST.md | 18 ++++++++++++++++-- claudezero.sh | 28 +++++++++++++++++++++++----- todo.md | 2 +- 4 files changed, 41 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index f2c9c29..d4c3538 100644 --- a/README.md +++ b/README.md @@ -225,7 +225,7 @@ claudezero -h CLAUDEZERO_MAX_LOOPS=3 claudezero todo.md ``` -**`CLAUDEZERO_WATCHDOG`** — how long one `claude` may run before it is killed. Default `15m`; accepts plain seconds or an `s`/`m`/`h` suffix (`900`, `90s`, `15m`, `1h`), and `0` disables it. A `claude` that stops making progress never exits, so without the timer the loop parks on it forever — no restart, no report, and nothing left to stop but the whole run. When the timer fires it says so on its own console line, then `SIGTERM`s `claude` (the same signal the Stop hook uses, so the restart path is the usual one) and escalates to `SIGKILL` 10s later. The task worktree survives the kill: the next session reclaims it through the crash-recovery path. +**`CLAUDEZERO_WATCHDOG`** — how long one `claude` may make no progress before it is killed. Default `15m`; accepts plain seconds or an `s`/`m`/`h` suffix (`900`, `90s`, `15m`, `1h`), and `0` disables it. A `claude` that stops making progress never exits, so without the timer the loop parks on it forever — no restart, no report, and nothing left to stop but the whole run. Progress is measured as `claude`'s own cumulative CPU time, not wall clock: one that is thinking, streaming, or running tools burns CPU and keeps resetting the window however long the task takes, while one blocked on a dead socket burns none. When the timer fires it says so on its own console line, then `SIGTERM`s `claude` (the same signal the Stop hook uses, so the restart path is the usual one) and escalates to `SIGKILL` 10s later. The task worktree survives the kill: the next session reclaims it through the crash-recovery path. ```sh CLAUDEZERO_WATCHDOG=45m claudezero todo.md diff --git a/TEST.md b/TEST.md index a6f458d..9b50a86 100644 --- a/TEST.md +++ b/TEST.md @@ -1268,7 +1268,8 @@ echo "M5 no odd clock : $(grep -cE '· [0-9]*[1-46-9]s' "$TM/frames.txt") (wan A claude that stops making progress never exits, so the loop parks on it forever. The watchdog is the cutoff: it names itself on its own console line, SIGTERMs claude onto the ordinary restart path, and escalates to SIGKILL for a claude that ignores TERM. A claude that exits on -its own must never see it, and `0` must switch it off. +its own must never see it, and `0` must switch it off. Progress is claude's own CPU time, not +wall clock, so a claude still working past the window must survive it (N4). ### Setup ```bash @@ -1284,7 +1285,7 @@ cd "$TN/repo" printf '#!/usr/bin/env bash\necho "stub hung"\nsleep 1000\n' > "$TN/bin/claude"; chmod +x "$TN/bin/claude" PATH="$TN/bin:$PATH" CLAUDEZERO_WATCHDOG=5 timeout 90 env CLAUDEZERO_MAX_LOOPS=2 bash "$SCRIPT" todo.md -t x > "$TN/hang.log" 2>&1 echo "N1 exit : $? (want 0 — the watchdog's kill is a restart, not a failure of the run)" -echo "N1 watchdog line : $(grep -c '❄ watchdog · claude ran 5s without exiting · killing it (CLAUDEZERO_WATCHDOG=5)' "$TN/hang.log") (want 2 — its own line, once per hung launch)" +echo "N1 watchdog line : $(grep -c '❄ watchdog · no progress from claude for 5s · killing it (CLAUDEZERO_WATCHDOG=5)' "$TN/hang.log") (want 2 — its own line, once per hung launch)" echo "N1 restart code : $(grep -c 'claude exited with code 143 after 1 runs · restarting in' "$TN/hang.log") (want 1 — SIGTERM, so the tested restart path)" echo "N1 runs : $(grep -c 'stub hung' "$TN/hang.log") (want 2 — the loop went on instead of parking on run 1)" echo "N1 orphans : $(pgrep -f "$TN/bin/claude" | wc -l | tr -d ' ') (want 0)" @@ -1320,6 +1321,19 @@ echo "N3 bad warning : $(grep -c 'ignoring CLAUDEZERO_WATCHDOG=15min' "$TN/bad - **N3 PASS** — `off exit = 124` with `off line = 0`, `healthy exit = 0` with `healthy line = 0`, and `bad exit = 0` with `bad warning = 1`. +### N4 — a claude still working past the window is not killed +```bash +cd "$TN/repo" +# burns CPU in the claude process itself for 3× the window, then exits on its own +printf '#!/usr/bin/env bash\necho "stub busy"\nend=$((SECONDS+15))\nwhile [ $SECONDS -lt $end ]; do :; done\nexit 0\n' > "$TN/bin/claude"; chmod +x "$TN/bin/claude" +PATH="$TN/bin:$PATH" CLAUDEZERO_WATCHDOG=5 timeout 60 env CLAUDEZERO_MAX_LOOPS=1 bash "$SCRIPT" todo.md -t x > "$TN/busy.log" 2>&1 +echo "N4 busy exit : $? (want 0 — the stub ran 3× the window and finished by itself)" +echo "N4 busy line : $(grep -c '❄ watchdog' "$TN/busy.log") (want 0 — advancing CPU time resets the window)" +echo "N4 own exit code : $(grep -c 'claude exited with code 0 after 1 runs' "$TN/busy.log") (want 1 — not 143/137)" +``` +- **N4 PASS** — `busy exit = 0`, `busy line = 0`, `own exit code = 1`. Wall clock alone would have + killed it at 5s; only the CPU-time sample tells honest long work from a wedged socket. + --- ## Scenario O — `CLAUDEZERO_LINK` into task worktrees `[$TESTROOT/O]` (stub claude, deterministic) diff --git a/claudezero.sh b/claudezero.sh index cb8bb2f..a4ce662 100755 --- a/claudezero.sh +++ b/claudezero.sh @@ -21,7 +21,7 @@ WAIT_TICK=5 # seconds between claimable-task probes while every uncheck WAIT_FRAME=1 # seconds per spinner frame on a terminal — one |/-\ revolution every 4 WAIT_STEP=5 # seconds the terminal's elapsed clock advances in — at 1Hz a live clock is noise LOG_TICK=20 # seconds between waiting lines when stdout is a log or a pipe, not a terminal -WATCHDOG_DEFAULT=15m # CLAUDEZERO_WATCHDOG default: how long ONE claude may run before it is killed +WATCHDOG_DEFAULT=15m # CLAUDEZERO_WATCHDOG default: how long claude may burn no CPU before it is killed WATCHDOG_GRACE=10 # seconds the watchdog waits after its SIGTERM before escalating to SIGKILL # The braces around the pipe reader in the logging example are load-bearing — do NOT tidy them @@ -31,7 +31,8 @@ WATCHDOG_GRACE=10 # seconds the watchdog waits after its SIGTERM before escal # inherits it and drains the pipe until ClaudeZero exits. usage() { # single-quoted heredoc keeps backticks literal; sed injects the RESTART_WAIT constant. - sed -e "s/@@RESTART_WAIT@@/$RESTART_WAIT/g" -e "s/@@PROG@@/$PROG/g" -e "s/@@VERSION@@/$VERSION/g" <<'USAGE' + sed -e "s/@@RESTART_WAIT@@/$RESTART_WAIT/g" -e "s/@@PROG@@/$PROG/g" -e "s/@@VERSION@@/$VERSION/g" \ + -e "s/@@WATCHDOG_DEFAULT@@/$WATCHDOG_DEFAULT/g" <<'USAGE' usage: @@PROG@@ [todo-file-path] [-t|--taskprompt TEXT | -l|--loopprompt TEXT] version @@VERSION@@ @@ -49,6 +50,12 @@ version @@VERSION@@ Environment: + CLAUDEZERO_WATCHDOG=duration How long claude may make no progress before the watchdog + kills it, in seconds or with an s/m/h suffix (900, 90s, + 15m, 1h). Default @@WATCHDOG_DEFAULT@@; 0 disables the watchdog. + Progress is claude's own CPU time, so a long honest run + is never killed — only one that has stopped working. + CLAUDEZERO_LINK=name[,name…] Top-level directories symlinked from the repo root into every task worktree. Unset by default. A worktree checks out tracked files only, so gitignored spec @@ -427,25 +434,36 @@ parse_dur() { printf '%s' $((n * mult)) } +# cumulative CPU time of pid $1 as `ps` prints it, spaces squeezed out; empty once the process is +# gone. The watchdog's liveness sample: only equality between two readings matters, so the differing +# BSD (`0:00.03`) and GNU (`00:00:00`) spellings both work and neither needs parsing. +cpu_time() { ps -o time= -p "$1" 2>/dev/null | tr -d '[:space:]'; } + # start the timer for the claude at $1 (no-op when the watchdog is disabled). A claude that stops # making progress never exits, so the loop would park on it forever — no restart, no report, and # nothing to Ctrl+C but the whole run. SIGTERM is what the Stop hook already uses, so the kill lands # on the tested restart path; SIGKILL follows for a claude that ignores it. The watchdog names # itself on its own console line, so the exit-code line under it is not read as claude's own choice. -# It counts down in WAIT_TICK naps instead of one long sleep so a claude that exits on its own +# It samples in WAIT_TICK naps instead of sleeping the whole window so a claude that exits on its own # retires its timer within a tick — an armed sleeper outliving its claude would eventually fire at # a pid the OS has since handed to somebody else. +# The sample is CPU time, not wall clock: a flat cap would kill the honest long runs this loop is +# built to leave unattended, while a claude that thinks, streams, or runs tools always burns CPU and +# one blocked on a dead socket burns none. Any advance restarts the full window. arm_watchdog() { WATCHDOG_PID="" [ "$WATCHDOG_SECS" -gt 0 ] || return 0 local pid=$1 { - local left=$WATCHDOG_SECS + local left=$WATCHDOG_SECS cpu last + last="$(cpu_time "$pid")" while [ "$left" -gt 0 ] && kill -0 "$pid" 2>/dev/null; do sleep "$WAIT_TICK"; left=$((left - WAIT_TICK)) + cpu="$(cpu_time "$pid")" + [ "$cpu" = "$last" ] || { last="$cpu"; left=$WATCHDOG_SECS; } done if kill -0 "$pid" 2>/dev/null; then - printf '\n❄ watchdog · claude ran %s without exiting · killing it (CLAUDEZERO_WATCHDOG=%s)\n' \ + printf '\n❄ watchdog · no progress from claude for %s · killing it (CLAUDEZERO_WATCHDOG=%s)\n' \ "$(fmt_dur "$WATCHDOG_SECS")" "$WATCHDOG_RAW" kill -TERM "$pid" 2>/dev/null || true sleep "$WATCHDOG_GRACE" diff --git a/todo.md b/todo.md index 363c92d..dd5ee6a 100644 --- a/todo.md +++ b/todo.md @@ -18,5 +18,5 @@ - [x] BUG-029 Background claude and trap TERM so a SIGTERM while claude hangs still lands at the closing report and exits 143 - [x] ISSUE-030 Surface claude's own exit code on the restart/stop lines and add an opt-in `CLAUDEZERO_DEBUG` `--debug-file` capture - [x] ISSUE-031 Zero one task per claude session and wait for the next claimable task in the shell — drop `/loop`, keep the context-full restart -- [ ] ISSUE-032 Kill a hung claude with a `CLAUDEZERO_WATCHDOG` timer (default 15m) and name the watchdog on its own console line +- [x] ISSUE-032 Kill a hung claude with a `CLAUDEZERO_WATCHDOG` timer (default 15m) and name the watchdog on its own console line - [x] ISSUE-033 Symlink gitignored spec directories into every task worktree with `CLAUDEZERO_LINK` so a session reads the acceptance criteria its todo line points at From 1cfe0098f595e1e581f6e61ba8a753991637f4df Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Mon, 3 Aug 2026 10:44:37 +0200 Subject: [PATCH 16/22] Update changelog --- CHANGELOG.md | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c30060..515931c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,65 @@ 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.16] — 2026-08-03 + +### Added + +- `CLAUDEZERO_WATCHDOG` kills a `claude` that has stopped making progress + mid-turn, so a wedged API call or a hung tool costs one timeout instead of the + whole overnight run. Progress is claude's own cumulative CPU time, so a long + honest run is never killed — only one that has stopped working. SIGTERM first, + SIGKILL after a 10s grace. Default `15m`; `0` disables it. The kill prints its + own `❄ watchdog · no progress from claude for …` line, so it is never mistaken + for the ordinary context-full restart (ISSUE-032). +- `CLAUDEZERO_LINK` — comma-separated top-level names symlinked from the repo + root into every task worktree, unset by default. A worktree checks out tracked + files only, so a gitignored spec directory a todo line points at is simply not + there: the session falls back to the one-line title and reports done against + it. Through a link the session reads the acceptance criteria and ticks them in + the real file. The whole list is validated at startup and a bad entry (empty, + containing `/`, or missing at the repo root) refuses the run — a typo costs the + launch, not the tasks (ISSUE-033). +- `CLAUDEZERO_DEBUG` adds `--debug-file .git/debug---.log` + to the `claude` invocation, so a repeat of a rare startup flake leaves a trace + instead of a bare hang. Off by default — a diagnostic opt-in, not a standing + cost on every run. Unset, the claude argv is unchanged (ISSUE-030). +- An `Environment:` block on the `-h`/`--help` screen naming `CLAUDEZERO_WATCHDOG` + and `CLAUDEZERO_LINK` with their formats and defaults, so neither knob has to be + found by reading the script (ISSUE-032, ISSUE-033). +- A waiting line while every unchecked task is peer-held: a spinner with an + elapsed clock and the held count on a terminal, a periodic line every 20s when + stdout is a log or a pipe (ISSUE-031). + +### Changed + +- A `claude` session zeroes exactly one task and exits; the wait for the next + claimable task lives in `claudezero.sh` instead of inside claude. `/loop` is + gone from the zero prompt, and the Stop hook now ends the session at every turn + end rather than only when the context bucket crosses its threshold. Every task + therefore starts on a context isolated from the task before it (~20% fewer + tokens on a working instance), and an instance with nothing to claim launches + no `claude` at all instead of re-sending a startup context per probe. + `-l/--loopprompt` is unaffected (ISSUE-031). +- Both "claude exited after N runs" lines — `CLAUDEZERO_MAX_LOOPS`-reached and + restarting — carry claude's real exit code: `claude exited with code %s after + %s runs · …`. A hang is visible without re-deriving it from timing, and it is + one line per event, not two (ISSUE-030). +- A run stopped by SIGTERM exits `143` (128+15), so a supervisor can tell + "terminated" from "finished". Plain `timeout` callers still see its own `124`; + use `timeout --preserve-status` to observe the 143 (BUG-029). + +### Fixed + +- A SIGTERM arriving while `claude` hangs no longer kills the run silently. + `claude` ran as a foreground child, and bash defers every trap until a + foreground child exits — so with a hung child no handler could run, the + follow-up SIGKILL ended the process mid-wait, and the run dropped its + `❄ execution stats` report, the fleet `❄ TOTAL`, and the EXIT trap that clears + the instance liveness marker. `claude` is now backgrounded and reaped with a + re-entrant `wait`, and a trapped TERM forwards to claude and breaks to the + existing closer, so every exit path lands at the closing report (BUG-029). + ## [0.0.15] — 2026-07-31 ### Added From 0f7a54b8bbddee9ef41ee25f5cdffb3cdfd4be5c Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Mon, 3 Aug 2026 11:28:06 +0200 Subject: [PATCH 17/22] fix: recheck kill -0 before the watchdog's SIGKILL escalation If the earlier SIGTERM already reaped claude, the pid can be recycled by the OS during the WATCHDOG_GRACE sleep; a blind kill -KILL then lands on whatever unrelated process holds that number next. Co-Authored-By: Claude Sonnet 5 --- claudezero.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/claudezero.sh b/claudezero.sh index a4ce662..cabbe8c 100755 --- a/claudezero.sh +++ b/claudezero.sh @@ -467,7 +467,9 @@ arm_watchdog() { "$(fmt_dur "$WATCHDOG_SECS")" "$WATCHDOG_RAW" kill -TERM "$pid" 2>/dev/null || true sleep "$WATCHDOG_GRACE" - kill -KILL "$pid" 2>/dev/null || true + # recheck: if the TERM already reaped it, the pid may be recycled by now — a blind + # KILL here would hit whatever unrelated process the OS handed that number to next. + if kill -0 "$pid" 2>/dev/null; then kill -KILL "$pid" 2>/dev/null || true; fi fi } & WATCHDOG_PID=$! From 887d91790c1ec877998edd6be7e80a3dd8ff20dc Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Mon, 3 Aug 2026 11:28:06 +0200 Subject: [PATCH 18/22] test: harden TEST.md against reaching outside its own sandbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REPO now resolves via `git worktree list` instead of $(pwd), so a cwd drift into a task worktree of this project can no longer point $SCRIPT at the real shared .git. Added in_testroot() as the per-invocation check that a scenario's own cd actually landed under $TESTROOT. Cleanup no longer pkills by bare 'claudezero.sh'/'claude .*--settings' (matches any such process on the machine, including a real dogfood loop or the live agent itself) — scoped to $TESTROOT instead, which every test-spawned claude's --settings hook path embeds. Co-Authored-By: Claude Sonnet 5 --- TEST.md | 42 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/TEST.md b/TEST.md index 9b50a86..a80b53d 100644 --- a/TEST.md +++ b/TEST.md @@ -29,9 +29,17 @@ their `../ts-*` worktrees, and all state live under `$TESTROOT` (outside the rep The only thing read from the repo is `claudezero.sh` itself (`$SCRIPT`). The final residue check (Cleanup) proves the project repo was untouched. -**Run every step from the repo root** (the dir holding this file). `$(pwd)` there is -the repo; the tests themselves execute against `$TESTROOT`, never `$(pwd)`. An agent -reading this file can run it autonomously and report the results. +`REPO` (Section 0) is resolved via `git worktree list`, never `$(pwd)` — deterministic +regardless of which worktree an agent happens to be sitting in when it starts, so a cwd +drift can no longer point `$SCRIPT` at a real task worktree of the project instead of the +main checkout (that worktree shares the project's actual `.git`; running claudezero.sh +there is a real run — real claude, real Stop hook — against real repo state, not a test). +`in_testroot` (Section 0) is the matching per-invocation check: every scenario's own `cd` +into its `$T*/repo` is what makes an individual run land under `$TESTROOT`, and this asserts +it did, refusing instead of silently proceeding on a miss. + +**Run every step from anywhere inside the repo** (any worktree). An agent reading this file +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 @@ -50,13 +58,18 @@ of passes, fails, and causes. ## 0. Shared setup -Run once, from the repo root. Defines `REPO`/`SCRIPT` (the code under test) and a fresh -`TESTROOT` **outside** the repo, plus `write_gate` (A, C) and `ago` (E) helpers. +Run once, from anywhere inside the repo (any worktree). Defines `REPO`/`SCRIPT` (the code +under test) and a fresh `TESTROOT` **outside** the repo, plus `write_gate` (A, C) and `ago` +(E) helpers. ```bash set -euo pipefail -REPO="$(pwd)" # repo holding claudezero.sh (never written to) +# main worktree, deterministically — never $(pwd): a cwd sitting inside a task worktree of +# this same project would otherwise point SCRIPT at the wrong .git (shared, real, live). +# `git worktree list` always lists the main working tree first, regardless of invocation cwd. +REPO="$(git worktree list | head -1 | awk '{print $1}')" SCRIPT="$REPO/claudezero.sh" +[ -f "$SCRIPT" ] || { echo "FATAL: $SCRIPT not found — not inside the claudezero.sh repo" >&2; exit 1; } TESTROOT="$(mktemp -d "${TMPDIR:-/tmp}/claudezero-tests.XXXXXX")" # isolated, outside the repo TESTROOT="$(cd "$TESTROOT" && pwd -P)" # canonical path: on macOS $TMPDIR is /var→/private/var; the root-guard compares $PWD to git's physical path, so an uncanonicalized /var path misfires "not at repo root" at the real root echo "TESTROOT=$TESTROOT" @@ -64,6 +77,12 @@ echo "TESTROOT=$TESTROOT" # touch-timestamp for N seconds ago, BSD (-v) or GNU (-d). Used by E. ago(){ date -v-"$1"S +%Y%m%d%H%M.%S 2>/dev/null || date -d "-$1 sec" +%Y%m%d%H%M.%S; } +# refuse to run claudezero.sh anywhere but under TESTROOT — call this right before every +# invocation of $SCRIPT. A real project worktree shares the project's .git, so a cwd mistake +# here would run a REAL claudezero session (real claude, real Stop hook) against real repo state. +in_testroot(){ case "$PWD" in "$TESTROOT"/*) return 0;; *) + echo "REFUSING: cwd $PWD is not under TESTROOT=$TESTROOT — not invoking claudezero.sh" >&2; return 1;; esac; } + write_gate(){ cat > "$1" <<'EOF' #!/usr/bin/env bash # Barrier + latch: block until NEED distinct agents are simultaneously in-flight, @@ -1467,8 +1486,15 @@ failing log tails. ## Cleanup ```bash -pkill -f claudezero.sh 2>/dev/null || true -pkill -f 'claude .*--settings' 2>/dev/null || true +# scoped to THIS run only: a nested real claude's --settings value is a JSON blob whose +# `command` field is $STOP_HOOK, always under $TESTROOT for every scenario — so this pattern +# never matches a claude/claudezero.sh process outside this run. Deliberately NOT a bare +# `claudezero.sh`/`claude .*--settings` pattern: that matches every such process on the +# machine, including this project's own real dogfood loop (if one happens to be running) and +# possibly the real agent itself, if its launch command line ever carries --settings too. Bare +# claudezero.sh loops need no separate catch here — every Run block already wraps its own +# invocation in `timeout`, which self-terminates them on schedule regardless of Cleanup. +pkill -f "$TESTROOT" 2>/dev/null || true rm -rf "$TESTROOT" ``` Then verify the tests left **no residue in the project repo itself** — the whole point of From daf1d5511d9697a9700e48ab2a58acc9e9cda9ff Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Mon, 3 Aug 2026 12:03:53 +0200 Subject: [PATCH 19/22] fix: prefer CLAUDE_PID over bare ancestor-name walk in term_owner/find_owner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both walked up to 8 parent hops matching bare `ps -o comm=` == claude with no check that the match was this session's own — any live claude sitting in the ancestry for an unrelated reason (nested Task agents, a claude session dogfood-testing this tool) got SIGTERMed instead. Claude Code already exports CLAUDE_PID (its own pid) into every Bash-tool/hook child; try that first (verified alive + still named claude), fall back to the walk only when it's absent. Co-Authored-By: Claude Sonnet 5 --- TEST.md | 19 ++++++++++++++++--- claudezero.sh | 19 +++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/TEST.md b/TEST.md index a80b53d..2484fa0 100644 --- a/TEST.md +++ b/TEST.md @@ -970,7 +970,12 @@ DRIVE ### Run + assert ```bash -TI="$TI" "$TI/bin/claude" "$TI/drive.sh" +# -u CLAUDE_PID: this driver IS the ancestor find_owner/term_owner must walk to. An agent +# running this file autonomously already has a real CLAUDE_PID in env (its own live session) — +# left set, find_owner trusts that inherited pid over the nearer stub ancestor below, since +# claudezero.sh trusts CLAUDE_PID whenever it is alive and named claude, with no check that it's +# actually this invocation's ancestor. Unset so the stub is the only candidate found. +TI="$TI" env -u CLAUDE_PID "$TI/bin/claude" "$TI/drive.sh" ``` - **I PASS** — every line reports its `want` value. Together they cover the four exit paths, the single-field stdout the prompt's `wt=$(…)` depends on, the exit-3 claim leak (I4/I5), and the @@ -1243,7 +1248,12 @@ echo "M3 closing report: $(grep -c 'execution stats' "$TM/done.log") (want >=1 cd "$TM/repo" CLAUDEZERO_TEST_EMIT=1 bash "$SCRIPT" todo.md -t x > /dev/null 2>&1 HOOK="$TM/repo/.git/compact-exit-hook.sh" -run_as_claude(){ CLAUDEZERO_MODE="$1" "$TM/owner/claude" -c 'printf "%s" "$2" | bash "$1" >/dev/null 2>&1; sleep 3' _ "$HOOK" "$2"; echo $?; } +# -u CLAUDE_PID: "$TM/owner/claude" below IS the ancestor term_owner must SIGTERM. An agent +# running this file autonomously already has a real CLAUDE_PID in env (its own live session) — +# left set, term_owner trusts that inherited pid unconditionally (alive + named claude, no check +# it's actually this invocation's ancestor) and SIGTERMs the live agent running the test instead +# of the stub. Unset so the stub is the only candidate the ancestor walk can find. +run_as_claude(){ CLAUDEZERO_MODE="$1" env -u CLAUDE_PID "$TM/owner/claude" -c 'printf "%s" "$2" | bash "$1" >/dev/null 2>&1; sleep 3' _ "$HOOK" "$2"; echo $?; } echo "M4 zero mode : $(run_as_claude zero '{}') (want 143 — ordinary turn end SIGTERMs the owning claude)" echo "M4 loop mode : $(run_as_claude loop '{}') (want 0 — -l has no task boundary, so it is left running)" B="${TMPDIR:-/tmp}"; B="${B%/}/claude-context-bucket-czM4"; : > "$B" @@ -1430,7 +1440,10 @@ DRIVE ### Run + assert ```bash -TO="$TO" "$TO/bin/claude" "$TO/drive.sh" +# -u CLAUDE_PID: see Scenario I — this driver IS the ancestor find_owner must walk to, and an +# inherited real CLAUDE_PID (an agent running this file autonomously has its own) would otherwise +# be trusted over the nearer stub. +TO="$TO" env -u CLAUDE_PID "$TO/bin/claude" "$TO/drive.sh" ``` ### O6 — a bad `CLAUDEZERO_LINK` refuses the run at startup, before any claude diff --git a/claudezero.sh b/claudezero.sh index cabbe8c..6237de7 100755 --- a/claudezero.sh +++ b/claudezero.sh @@ -373,7 +373,18 @@ if [ -n "$tf" ]; then fi # nearest ancestor named 'claude' → SIGTERM (graceful: reaps bash tree, runs SessionEnd hooks, # exits 143). Turn already saved, so the kill loses nothing. +# Claude Code exports CLAUDE_PID (its own pid) into this hook's env same as any Bash-tool child — +# that IS the owning session, no walk needed. Verified alive + still named claude before trusting +# it (a stale inherited value must never fire): this hook's own comment above the outer loop notes +# the same var can carry a STALE pid across an unrelated invocation boundary. Only when it is +# absent or fails that check do we fall back to the name-walk, which has no such guarantee — every +# ancestor up to 8 hops is bare-name-matched, and a live real `claude` sitting there for any other +# reason (e.g. this hook under test, nested inside a real claude session) gets SIGTERMed instead. term_owner() { + if [ -n "${CLAUDE_PID:-}" ] && kill -0 "$CLAUDE_PID" 2>/dev/null; then + comm="$(ps -o comm= -p "$CLAUDE_PID" 2>/dev/null)" + [ "${comm##*/}" = claude ] && { kill -TERM "$CLAUDE_PID"; return 0; } + fi pid=$PPID; depth=0 while [ -n "$pid" ] && [ "$pid" -gt 1 ] && [ "$depth" -lt 8 ]; do comm="$(ps -o comm= -p "$pid" 2>/dev/null)" || return 0 @@ -900,7 +911,15 @@ BR_SLUG="${BASE_BRANCH//\//-}" task_branch() { printf '%s-task-%s' "$BASE_BRANCH" "$1"; } # Owner = nearest ancestor process named 'claude' (the Bash tool may nest a shell in between). +# Claude Code exports CLAUDE_PID (its own pid) into this Bash-tool child same as the hook's — try +# it first (verified alive + still named claude) so a nested Task agent's zero.sh never has to +# guess which of several real 'claude' ancestors is its own; only fall back to the walk, whose +# bare-name match has no such guarantee, when that var is absent or fails the check. find_owner() { + if [ -n "${CLAUDE_PID:-}" ] && kill -0 "$CLAUDE_PID" 2>/dev/null; then + local c; c=$(ps -o comm= -p "$CLAUDE_PID" 2>/dev/null) + [ "${c##*/}" = claude ] && { echo "$CLAUDE_PID"; return 0; } + fi local pid=$PPID comm depth=0 while [ -n "$pid" ] && [ "$pid" -gt 1 ] && [ "$depth" -lt 8 ]; do comm=$(ps -o comm= -p "$pid" 2>/dev/null) || return 1 From 63e2bd68bada9b95493c979b6c3cb81a797bfa29 Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Mon, 3 Aug 2026 13:17:07 +0200 Subject: [PATCH 20/22] test: guard Scenario G's real Stop hook invocation against killing the test runner term_owner()'s ancestor walk finds the nearest claude-comm'd process within 8 PPID hops. G's stub piped straight into the real compact-exit-hook.sh with no protection, so an agent running TEST.md autonomously (a live claude ancestor within that hop count) got its own session SIGTERMed instead of the stub. Adds a shared `guard` helper (Section 0): a real bash binary copied to a file named claude, invoked with CLAUDE_PID unset and the payload backgrounded (`& wait $!`) so bash's tail-command exec optimization can't replace the decoy's own comm before the kill lands. Applied to G's grun(). Cross-referenced from I, M4, and O, which already carry the same technique inline for unrelated reasons (ensure_owner needing a findable ancestor, not a kill to avoid). --- TEST.md | 41 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/TEST.md b/TEST.md index 2484fa0..6d77f6a 100644 --- a/TEST.md +++ b/TEST.md @@ -83,6 +83,25 @@ ago(){ date -v-"$1"S +%Y%m%d%H%M.%S 2>/dev/null || date -d "-$1 sec" +%Y%m%d%H%M in_testroot(){ case "$PWD" in "$TESTROOT"/*) return 0;; *) echo "REFUSING: cwd $PWD is not under TESTROOT=$TESTROOT — not invoking claudezero.sh" >&2; return 1;; esac; } +# decoy ancestor for any block that manually fires the real compact-exit-hook.sh (only Scenario G +# does today). term_owner()/find_owner() in claudezero.sh first trust an inherited CLAUDE_PID if +# it's alive and named claude, else walk up to 8 PPID hops for the first ancestor whose `ps -o +# comm=` reads claude, and SIGTERM it. An agent running this file autonomously already has a real, +# live claude ancestor within that many hops (its own session) — the walk finds no `claude`-named +# process any closer because every wrapper in between (this file's own bash, claudezero.sh's own +# `bash "$SCRIPT"`, the agent's own Bash-tool shell) is invoked via `bash script`, not exec'd +# directly, so `ps -o comm=` reports bash for all of them, not the script's filename. `guard` +# plants a REAL bash binary (not a #!/bin/bash script — a script would report comm=bash too, same +# problem) copied to a file literally named claude, one hop above the command, and drops +# CLAUDE_PID first — so the walk's first (and only) claude-named match is this harmless decoy, +# never the real session further up. +# `$1 & wait $!`, not a bare `-c "$1"`: bash execve()-replaces a `-c` process in place (no fork, +# same pid, new image) when the whole script is one tail command — the decoy's own pid would +# silently become the payload's own command name mid-run, undoing the rename this exists for. +# Backgrounding forces a real fork; `wait $!` then blocks on it and forwards its real exit status. +mkdir -p "$TESTROOT/guard"; cp "$(command -v bash)" "$TESTROOT/guard/claude" +guard(){ env -u CLAUDE_PID "$TESTROOT/guard/claude" -c "$1"' & wait $!'; } + write_gate(){ cat > "$1" <<'EOF' #!/usr/bin/env bash # Barrier + latch: block until NEED distinct agents are simultaneously in-flight, @@ -634,9 +653,11 @@ cd "$TG/repo" git init -q -b main; git config user.email t@t.t; git config user.name test printf -- '- [ ] G1 x\n' > todo.md; git add -A; git commit -qm init # $1 = stub mode, $2 = log file. Fresh transcript dir per run. +# guarded (see Section 0): the stub below pipes straight into the real compact-exit-hook.sh, +# whose term_owner() SIGTERMs a `claude`-named ancestor — without the decoy that ancestor walk +# lands on the real agent session running this file, not the stub. grun() { rm -rf "$TG/tx"; mkdir -p "$TG/tx" - PATH="$TG/bin:$PATH" TG_TX="$TG/tx" TG_MODE="$1" \ - timeout 90 env CLAUDEZERO_MAX_LOOPS=3 bash "$SCRIPT" todo.md -t x > "$2" 2>&1 || true; } + guard "PATH='$TG/bin:\$PATH' TG_TX='$TG/tx' TG_MODE='$1' timeout 90 env CLAUDEZERO_MAX_LOOPS=3 bash '$SCRIPT' todo.md -t x > '$2' 2>&1" || true; } ``` ### G1 — dedupe, no double-count, accumulation across restarts @@ -917,6 +938,12 @@ git add -A; git commit -qm init # bootstrap: real claudezero writes .git/zero.sh, stub claude exits, loop ends PATH="$TI/bin:$PATH" timeout 30 env CLAUDEZERO_MAX_LOOPS=1 bash "$SCRIPT" todo.md -t x > "$TI/boot.log" 2>&1 || true cp "$(command -v bash)" "$TI/bin/claude" # ensure_owner walks `ps -o comm=` for an ancestor named claude +# same real-binary-not-script trick as `guard` in Section 0, but for the opposite reason: this one +# needs ensure_owner to FIND a claude ancestor (claim/merge FATAL out at exit 3 without one), not to +# stop term_owner from killing one — claim never SIGTERMs anything, so a wrongly-found ancestor here +# only misattributes bookkeeping, it can't take down this session. Kept inline (driver is a whole +# script file with real work between every claim, never a bare tail command) rather than routed +# through `guard`, whose `& wait $!` shape exists for a single -c command, not a multi-step driver. cat > "$TI/drive.sh" <<'DRIVE' set -uo pipefail cd "$TI/repo" @@ -1253,6 +1280,12 @@ HOOK="$TM/repo/.git/compact-exit-hook.sh" # left set, term_owner trusts that inherited pid unconditionally (alive + named claude, no check # it's actually this invocation's ancestor) and SIGTERMs the live agent running the test instead # of the stub. Unset so the stub is the only candidate the ancestor walk can find. +# This IS `guard` from Section 0's technique (real bash binary named claude, not a script — a +# shebang script would report comm=bash, not claude, to the walk), applied by hand instead of +# calling `guard` directly: this needs CLAUDEZERO_MODE set as a prefix on the SAME command, and the +# `-c` body is two statements ending in `sleep 3`, so — same reasoning as guard's own `& wait $!` — +# the real work (printf | bash "$1", where the hook's term_owner runs) is never in tail position and +# this process's own comm can't get execve()-replaced out from under it before the kill lands. run_as_claude(){ CLAUDEZERO_MODE="$1" env -u CLAUDE_PID "$TM/owner/claude" -c 'printf "%s" "$2" | bash "$1" >/dev/null 2>&1; sleep 3' _ "$HOOK" "$2"; echo $?; } echo "M4 zero mode : $(run_as_claude zero '{}') (want 143 — ordinary turn end SIGTERMs the owning claude)" echo "M4 loop mode : $(run_as_claude loop '{}') (want 0 — -l has no task boundary, so it is left running)" @@ -1388,6 +1421,10 @@ printf -- '- [ ] criterion one\n' > issues/ISSUE-O.md # the spec the todo l printf 'private\n' > sources/s.txt # gitignored, NOT listed in CLAUDEZERO_LINK PATH="$TO/bin:$PATH" timeout 30 env CLAUDEZERO_MAX_LOOPS=1 bash "$SCRIPT" todo.md -t x > "$TO/boot.log" 2>&1 || true cp "$(command -v bash)" "$TO/bin/claude" # ensure_owner walks `ps -o comm=` for an ancestor named claude +# same reasoning as Scenario I's identical line (see there): `guard` in Section 0 codifies this +# real-binary trick for the term_owner-kill case; claim/link_ignored here only need ensure_owner to +# FIND an ancestor (no kill involved), and the driver below is a multi-step script, not a bare +# tail command, so it's inline rather than routed through `guard`. cat > "$TO/drive.sh" <<'DRIVE' set -uo pipefail cd "$TO/repo" From 24f7e2b0c4980d9e50639c8e1e28389d97231214 Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Mon, 3 Aug 2026 14:20:07 +0200 Subject: [PATCH 21/22] test: fix grun() PATH bug, expand scenario list, split Scenario G grun()'s escaped \$PATH stayed literal through guard()'s nested decoy shell, breaking every G scenario run with "timeout: command not found"; also polls for all three per-loop reports since the decoy dies (SIGTERM'd by term_owner) after loop 1, before claudezero.sh's remaining loops finish. Intro scenario list now covers S and F..O (previously only A-E, G). The two same-lettered "Scenario G" sections are split into G1 (token accounting) and G2 (fd 4 descriptor), with their internal sub-tests renumbered G1.1/G1.2 and G2.1/G2.2 to match. Co-Authored-By: Claude Sonnet 5 --- TEST.md | 118 ++++++++++++++++++++++++++++++++++---------------------- 1 file changed, 71 insertions(+), 47 deletions(-) diff --git a/TEST.md b/TEST.md index 6d77f6a..dc0ef3c 100644 --- a/TEST.md +++ b/TEST.md @@ -4,11 +4,14 @@ Each scenario lives in its own folder under a single **isolated TESTROOT created outside the repo** (via `mktemp -d`). They touch separate throwaway git repos, never the project's own working tree or history, and can run concurrently. +- **S — static checks (shellcheck + bash 3.2 syntax).** No claude. Lints `claudezero.sh` and + the scripts it emits at runtime, then checks the bash 3.2 heredoc-in-`$(...)` constraint + structurally and, where available, with a real bash 3.x parse. - **A — parallel zeroing + restart-resume + timing.** 3 agents zero one todo in parallel; the script restarts each claude after every task (`CLAUDEZERO_MAX_LOOPS`) and resumes. Also asserts the per-instance execution-time report and env-hop. -- **B — startup-guard refusals.** dirty-tree and detached-HEAD both refuse to start. - Pure bash, no claude, finishes in seconds. +- **B — startup-guard refusals.** dirty-tree, detached-HEAD, wrong-cwd, inside-a-leftover + worktree, and untracked-todo all refuse to start. Pure bash, no claude, finishes in seconds. - **C — merge-conflict path.** two tasks edit the same line; one merges, the other hits a conflict, and the zero run aborts cleanly leaving the base green and the branch for a human. @@ -16,10 +19,40 @@ the project's own working tree or history, and can run concurrently. task's box; `merge_task` refuses with a pointer and the agent self-heals (step 2.e). - **E — timing accounting.** Deterministic, **no real claude** (a stub `claude` on PATH): per-instance in-flight credit routing + idempotency, and startup orphan-file GC. -- **G — token accounting.** Deterministic, **no real claude** (a stub that fabricates a +- **F — fenced-checkbox immunity.** Deterministic, no real claude. A `- [ ]`/`- [x]` box + inside a fenced code block is prose, not a task — proven for `all_todos_done`/`dojo_proud` + and for `zero.sh done`/`box_checked_on_base`. +- **G1 — token accounting.** Deterministic, **no real claude** (a stub that fabricates a session transcript and fires the real Stop hook): `requestId` dedupe, no double-count of the nested `iterations`/`cache_creation` fields, accumulation across restarts, and `Tokens: n/a` degradation. +- **G2 — claude's output descriptor (fd 4).** Deterministic. claude writes to fd 4 so a + captured run still gets the `❄` reports without the TUI; proves the no-tty fallback to + stdout and, via a pty, that the TUI/report split actually holds both ways. +- **H — claude session display name.** Deterministic. `--name` carries `() · + ` as one argv element, stable across restarts, unique among live peers, and + freed again on exit or crash. +- **I — `zero.sh claim` exit paths.** Deterministic. The four `claim` outcomes — free, + peer-held, peer-landed, validation-failed — and their exit codes, plus the exit-3 claim + leak and raw-vs-sanitized id matching. +- **J — fleet TOTAL on the exit path.** Deterministic. The exit-path report sums every + peer's per-instance todo/token files into one fleet TOTAL, including a crashed peer that + left files but no live marker. +- **K — SIGTERM while claude hangs.** Deterministic. A hung claude is reaped on SIGTERM + (backgrounded + interruptible `wait`) without losing the exit report; normal exit and + restart paths are unaffected. +- **L — claude's exit code + `--debug-file`.** Deterministic. Restart/stop log lines carry + claude's real exit code, and `CLAUDEZERO_DEBUG` opts into a per-invocation `--debug-file` + with no argv change when unset. +- **M — one task per session, the shell waits.** Deterministic. The claimable-task probe + lives in the shell now: nothing left → close; everything unchecked held by a live peer → + wait with no claude launched; anything free (including a crashed peer's branch) → launch. +- **N — the `CLAUDEZERO_WATCHDOG` timer.** Deterministic. A stalled claude is killed (TERM + then KILL) and restarted based on CPU-time inactivity, not wall clock; `0` disables it, a + healthy or still-working claude is left alone. +- **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. Parallelism (A, C) is enforced with a **file-lock barrier**, not `sleep`, so the proof is independent of claude startup/shutdown times. @@ -83,7 +116,7 @@ ago(){ date -v-"$1"S +%Y%m%d%H%M.%S 2>/dev/null || date -d "-$1 sec" +%Y%m%d%H%M in_testroot(){ case "$PWD" in "$TESTROOT"/*) return 0;; *) echo "REFUSING: cwd $PWD is not under TESTROOT=$TESTROOT — not invoking claudezero.sh" >&2; return 1;; esac; } -# decoy ancestor for any block that manually fires the real compact-exit-hook.sh (only Scenario G +# decoy ancestor for any block that manually fires the real compact-exit-hook.sh (only Scenario G1 # does today). term_owner()/find_owner() in claudezero.sh first trust an inherited CLAUDE_PID if # it's alive and named claude, else walk up to 8 PPID hops for the first ancestor whose `ps -o # comm=` reads claude, and SIGTERM it. An agent running this file autonomously already has a real, @@ -609,7 +642,7 @@ cd "$TF/repo" - **F2 PASS** — `real done id = 0`, both fenced ids = `1`: a checked example box never reports a task as landed. -## Scenario G — token accounting `[$TESTROOT/G]` (stub claude, deterministic) +## Scenario G1 — token accounting `[$TESTROOT/G]` (stub claude, deterministic) The token figures come from the session transcripts the Stop hook records, so a stub `claude` that fabricates a transcript and then fires the **real** hook exercises the whole @@ -656,22 +689,26 @@ printf -- '- [ ] G1 x\n' > todo.md; git add -A; git commit -qm init # guarded (see Section 0): the stub below pipes straight into the real compact-exit-hook.sh, # whose term_owner() SIGTERMs a `claude`-named ancestor — without the decoy that ancestor walk # lands on the real agent session running this file, not the stub. -grun() { rm -rf "$TG/tx"; mkdir -p "$TG/tx" - guard "PATH='$TG/bin:\$PATH' TG_TX='$TG/tx' TG_MODE='$1' timeout 90 env CLAUDEZERO_MAX_LOOPS=3 bash '$SCRIPT' todo.md -t x > '$2' 2>&1" || true; } +grun() { rm -rf "$TG/tx"; mkdir -p "$TG/tx"; : > "$2" + guard "PATH='$TG/bin:$PATH' TG_TX='$TG/tx' TG_MODE='$1' timeout 90 env CLAUDEZERO_MAX_LOOPS=3 bash '$SCRIPT' todo.md -t x > '$2' 2>&1" || true + # the decoy above IS the ancestor term_owner() SIGTERMs when loop 1's Stop hook fires, so it + # dies (and `wait $!` returns) after just one loop; the run itself keeps going detached the + # other two. Poll the log for all three per-loop reports instead of trusting the early return. + i=0; while [ "$(grep -c 'execution stats' "$2" 2>/dev/null || echo 0)" -lt 3 ] && [ "$i" -lt 190 ]; do sleep 0.5; i=$((i+1)); done; } ``` -### G1 — dedupe, no double-count, accumulation across restarts +### G1.1 — dedupe, no double-count, accumulation across restarts ```bash cd "$TG/repo" grun ok "$TG/ok.log" GC="$(cd "$(git rev-parse --git-common-dir)" && pwd)" inst="$(sed -n 's/.*execution stats (instance \([A-Za-z0-9]*\) · .*/\1/p' "$TG/ok.log" | head -1)" -echo "G1 heading : $(grep -c 'execution stats' "$TG/ok.log") (want 3 — a report between runs 1|2 and 2|3, plus the closing one)" -echo "G1 report 1 : $(grep -A1 'Tokens:' "$TG/ok.log" | sed -n '1,2p' | tr '\n' '|')" -echo "G1 report 2 : $(grep -A1 'Tokens:' "$TG/ok.log" | sed -n '4,5p' | tr '\n' '|')" -echo "G1 per-instance : $([ -f "$GC/transcripts-main-$inst" ] && echo yes || echo NO) (instance $inst)" +echo "G1.1 heading : $(grep -c 'execution stats' "$TG/ok.log") (want 3 — a report between runs 1|2 and 2|3, plus the closing one)" +echo "G1.1 report 1 : $(grep -A1 'Tokens:' "$TG/ok.log" | sed -n '1,2p' | tr '\n' '|')" +echo "G1.1 report 2 : $(grep -A1 'Tokens:' "$TG/ok.log" | sed -n '4,5p' | tr '\n' '|')" +echo "G1.1 per-instance : $([ -f "$GC/transcripts-main-$inst" ] && echo yes || echo NO) (instance $inst)" ``` -- **G1 PASS** — `heading = 3`, and the first two reports read exactly: +- **G1.1 PASS** — `heading = 3`, and the first two reports read exactly: - report 1: ` Tokens: 1.7k Total| in 15 · out 27 · cache write 248 · cache read 1.5k|` — `req_A`'s three identical lines counted **once** (10+5 in, 20+7 out), `iterations[]` not added on top, and the cache write is `248`, not `496` (leaves not added to parent). @@ -681,28 +718,28 @@ echo "G1 per-instance : $([ -f "$GC/transcripts-main-$inst" ] && echo yes || ec - `per-instance = yes`: the transcript list is namespaced `transcripts-main-`, so parallel instances can never read each other's figures. -### G2 — degradation: never lie, never fail the run +### G1.2 — degradation: never lie, never fail the run ```bash cd "$TG/repo" grun missing "$TG/missing.log"; grun bad "$TG/bad.log" -echo "G2 missing file : $(grep -c 'Tokens: n/a' "$TG/missing.log") (want 4 — one per report, plus the fleet TOTAL)" -echo "G2 invalid json : $(grep -c 'Tokens: n/a' "$TG/bad.log") (want 4 — one per report, plus the fleet TOTAL)" -echo "G2 timing kept : $(grep -c 'ClaudeZero run loop:' "$TG/bad.log") (want 3 — one per report; TOTAL omits the row, summed wall times are not a duration)" +echo "G1.2 missing file : $(grep -c 'Tokens: n/a' "$TG/missing.log") (want 4 — one per report, plus the fleet TOTAL)" +echo "G1.2 invalid json : $(grep -c 'Tokens: n/a' "$TG/bad.log") (want 4 — one per report, plus the fleet TOTAL)" +echo "G1.2 timing kept : $(grep -c 'ClaudeZero run loop:' "$TG/bad.log") (want 3 — one per report; TOTAL omits the row, summed wall times are not a duration)" ``` -- **G2 PASS** — both runs print `Tokens: n/a` in every report and in the fleet TOTAL, and +- **G1.2 PASS** — both runs print `Tokens: n/a` in every report and in the fleet TOTAL, and still print the timing rows: a deleted transcript or a truncated/invalid line degrades to `n/a` and the run completes normally instead of printing a wrong number. --- -## Scenario G — claude's output descriptor (fd 4) `[$TESTROOT/G]` (stub claude, deterministic) +## Scenario G2 — claude's output descriptor (fd 4) `[$TESTROOT/G]` (stub claude, deterministic) claude writes to fd 4 so `claudezero.sh … | tee run.log` logs the `❄` reports without the TUI. Only the *fallback* is deterministic here: with stdin off the terminal there is no tty to split to, so fd 4 must fall back to plain stdout and the stub's bytes must still appear in the captured stream. The split itself needs a pty — manual check below. -### Setup + assert +### G2.1 — fd 4 fallback (no tty) ```bash TG="$TESTROOT/G"; mkdir -p "$TG/repo" "$TG/bin" cat > "$TG/bin/claude" <<'EOF' @@ -717,15 +754,15 @@ printf -- '- [ ] G1 x\n' > todo.md; git add -A; git commit -qm init # stdin off the terminal: fd 4 has no tty to split to and must fall back to stdout env PATH="$TG/bin:$PATH" CLAUDEZERO_MAX_LOOPS=1 \ timeout 30 bash "$SCRIPT" todo.md -t x > "$TG/run.log" 2>&1 < /dev/null || true -echo "G1 stub output kept : $(grep -c 'STUB-CLAUDE-MARKER' "$TG/run.log") (want 1 — fd 4 fell back to stdout)" -echo "G1 own output kept : $(grep -c '❄ ClaudeZero' "$TG/run.log") (want >=1)" +echo "G2.1 stub output kept : $(grep -c 'STUB-CLAUDE-MARKER' "$TG/run.log") (want 1 — fd 4 fell back to stdout)" +echo "G2.1 own output kept : $(grep -c '❄ ClaudeZero' "$TG/run.log") (want >=1)" ``` -- **G1 PASS** — both counts as stated: with stdin not a terminal the `[ -t 0 ]` probe fails, +- **G2.1 PASS** — both counts as stated: with stdin not a terminal the `[ -t 0 ]` probe fails, fd 4 is a dup of stdout, and no scenario that captures output loses stub-claude bytes. Redirecting stdin explicitly matters — run from a terminal without `< /dev/null`, the probe succeeds and the stub's bytes go to the terminal by design, which is the whole point of the split. -### G2 — the split itself (automated, `script(1)` supplies the pty) +### G2.2 — the split itself (automated, `script(1)` supplies the pty) The operator's situation is stdin on a terminal, stdout on a pipe. `script(1)` allocates a pty and logs everything crossing it, so running the documented pipe form under it captures **both** @@ -754,11 +791,11 @@ export PATH="$TG/bin:$PATH" # inherited by the pty child — keeps the comma pty "CLAUDEZERO_MAX_LOOPS=1 bash '$SCRIPT' todo.md -t x 2>&1 | { trap '' INT; tee '$TG/pipe.log'; }" \ "$TG/typescript" >/dev/null 2>&1 tr -d '\r' < "$TG/typescript" > "$TG/tty.log" # pty writes CRLF; strip before matching -echo "G2 stub sees ttys : $(grep -o 'STUB TTY0=[a-z]* TTY1=[a-z]*' "$TG/tty.log" | head -1) (want both yes)" -echo "G2 TUI on tty : $(grep -c 'TUI-FRAME' "$TG/tty.log") (want >=1)" -echo "G2 TUI not in pipe: $(grep -c 'TUI-FRAME' "$TG/pipe.log") (want 0)" -echo "G2 report in pipe : $(grep -c 'execution stats' "$TG/pipe.log") (want >=1)" -echo "G2 no escapes : $(grep -c $'\033' "$TG/pipe.log") (want 0)" +echo "G2.2 stub sees ttys : $(grep -o 'STUB TTY0=[a-z]* TTY1=[a-z]*' "$TG/tty.log" | head -1) (want both yes)" +echo "G2.2 TUI on tty : $(grep -c 'TUI-FRAME' "$TG/tty.log") (want >=1)" +echo "G2.2 TUI not in pipe: $(grep -c 'TUI-FRAME' "$TG/pipe.log") (want 0)" +echo "G2.2 report in pipe : $(grep -c 'execution stats' "$TG/pipe.log") (want >=1)" +echo "G2.2 no escapes : $(grep -c $'\033' "$TG/pipe.log") (want 0)" # The other direction. Under `tee` the report reaches BOTH the file and the terminal — that is # what tee is for — so "own output stays off the tty" can only be asserted on the redirect form, @@ -766,29 +803,16 @@ echo "G2 no escapes : $(grep -c $'\033' "$TG/pipe.log") (want 0)" pty "CLAUDEZERO_MAX_LOOPS=1 bash '$SCRIPT' todo.md -t x > '$TG/redir.log' 2>&1" \ "$TG/typescript2" >/dev/null 2>&1 tr -d '\r' < "$TG/typescript2" > "$TG/tty2.log" -echo "G2 report in file : $(grep -c 'execution stats' "$TG/redir.log") (want >=1)" -echo "G2 report off tty : $(grep -c 'execution stats' "$TG/tty2.log") (want 0)" -echo "G2 TUI still tty : $(grep -c 'TUI-FRAME' "$TG/tty2.log") (want >=1)" +echo "G2.2 report in file : $(grep -c 'execution stats' "$TG/redir.log") (want >=1)" +echo "G2.2 report off tty : $(grep -c 'execution stats' "$TG/tty2.log") (want 0)" +echo "G2.2 TUI still tty : $(grep -c 'TUI-FRAME' "$TG/tty2.log") (want >=1)" ``` -- **G2 PASS** — all eight as stated. `stub sees ttys = yes yes` proves fd 4 is a real terminal +- **G2.2 PASS** — all eight as stated. `stub sees ttys = yes yes` proves fd 4 is a real terminal descriptor (a dup of stdin), not a pipe. The split is asserted in both directions: claude's `TUI-FRAME` reaches the terminal and never the capture file, and ClaudeZero's own `❄` report reaches the capture file and — on the redirect form, where `tee` is not echoing it back — never the terminal. No escape byte reaches the file. -### G3 — the kqueue regression (manual, needs a real `claude`) - -G2 proves the descriptor is a tty and the split holds, but a stub cannot reproduce what this -descriptor choice exists for: claude's Node runtime rejecting a `/dev/tty`-derived fd with -`EINVAL … kqueue`. Only the real binary can. Run the documented form by hand in a scratch repo: - -```bash -./claudezero.sh issues/todo.md 2>&1 | { trap '' INT; tee run.log; } -``` -- **G3 PASS** — a real `claude` reaches its prompt instead of dying at startup, the terminal - shows the TUI, and pressing Ctrl+C in the between-runs gap lands the closing report in - `run.log`. - --- ## Scenario H — claude session display name `[$TESTROOT/H]` (stub claude, deterministic) @@ -819,7 +843,7 @@ ACT=('drilling the fork-implement-merge kata' 'hauling snow buckets uphill' \ 'practicing one clean strike per task' "leaving a peer's branch untouched" \ 'walking back to the merge gate') # claude's own output goes to fd 4, which falls back to stdout only when stdin is not a terminal -# — run with stdin off the tty so the stub's ARGV line lands in the capture file (see Scenario G). +# — run with stdin off the tty so the stub's ARGV line lands in the capture file (see Scenario G2). notty() { "$@" < /dev/null; } # the fifteen nicknames, verbatim (claudezero.sh pick_nickname) NICKS=(ash bob cleo dax elk finn gus hana ivo jun kit lux moss nix opal) From 80182ace4cc555613396354e61b04e3f1499bf6a Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Mon, 3 Aug 2026 14:35:57 +0200 Subject: [PATCH 22/22] docs: changelog entries for the watchdog kill-0 recheck and CLAUDE_PID fixes Both claudezero.sh fixes (0f7a54b, daf1d55) landed in this 0.0.16 cycle without a changelog entry. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 515931c..2ece436 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,15 @@ All notable changes to ClaudeZero are documented here. Format follows the instance liveness marker. `claude` is now backgrounded and reaped with a re-entrant `wait`, and a trapped TERM forwards to claude and breaks to the existing closer, so every exit path lands at the closing report (BUG-029). +- The watchdog's SIGKILL escalation rechecks `kill -0` before firing: if the + earlier SIGTERM already reaped `claude`, the OS can recycle that pid during + the grace sleep, and a blind SIGKILL would land on whatever unrelated process + holds it next. +- `term_owner`/`find_owner` prefer the inherited `CLAUDE_PID` over the bare + ancestor-name walk: the walk matched any process named `claude` up to 8 hops + with no check it was this session's own, so a live `claude` sitting in the + ancestry for an unrelated reason (a nested Task agent, this tool being + dogfood-tested from inside a real session) got SIGTERMed instead. ## [0.0.15] — 2026-07-31