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/CHANGELOG.md b/CHANGELOG.md index 3c30060..2ece436 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,74 @@ 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). +- 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 ### Added 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/README.md b/README.md index 90cdd65..d4c3538 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 the coding session on a fresh context before rot.
+You can spawn multiple instances to parallelize.

+It's for practical Loop engineering.

@@ -32,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. @@ -40,13 +42,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. @@ -62,7 +70,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 … @@ -142,21 +150,29 @@ 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. 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 @@ -167,10 +183,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." @@ -208,6 +225,18 @@ claudezero -h CLAUDEZERO_MAX_LOOPS=3 claudezero todo.md ``` +**`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 +``` + +**`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 ed95469..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. @@ -29,9 +62,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 +91,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 +110,31 @@ 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; } + +# 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, +# 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, @@ -89,8 +160,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 +192,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. --- @@ -181,7 +336,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-')" @@ -487,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 @@ -531,23 +686,29 @@ 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. -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; } +# 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"; : > "$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 2 — renamed from 'execution time')" -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 = 2`, and the 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). @@ -557,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 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 "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 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. +- **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' @@ -593,29 +754,64 @@ 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 (manual, needs a terminal) +### G2.2 — the split itself (automated, `script(1)` supplies the pty) -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: +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 -./claudezero.sh issues/todo.md 2>&1 | { trap '' INT; tee run.log; } +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.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, +# 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.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)" ``` -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`. +- **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. --- @@ -647,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) @@ -766,6 +962,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" @@ -795,7 +997,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)" @@ -819,7 +1021,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 @@ -916,6 +1123,426 @@ 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 '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`. + +### 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`. + +--- + +## 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 .* 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)" +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`. + +--- + +## 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 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 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 = 3`, `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" +# -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. +# 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)" +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`. + +### 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) + +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. Progress is claude's own CPU time, not +wall clock, so a claude still working past the window must survive it (N4). + +### 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 · 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)" +``` +- **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`. + +### 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) + +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 +# 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" +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 +# -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 +```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`. + +### 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), the startup refusals (O6), and the `--help` `Environment:` block (O7). + +--- + ## Run all in parallel (optional) After Section 0 and each Setup, launch the Run blocks together: put A's and C's run @@ -933,8 +1560,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 diff --git a/claudezero.sh b/claudezero.sh index 5ef49c0..6237de7 100755 --- a/claudezero.sh +++ b/claudezero.sh @@ -13,11 +13,16 @@ # 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) 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 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 # into a bare pipe. Ctrl+C signals the whole foreground group; the reader's default SIGINT action @@ -26,7 +31,8 @@ RESTART_WAIT=5 # seconds between claude restarts — the window to press C # 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@@ @@ -42,6 +48,21 @@ version @@VERSION@@ -t and -l are mutually exclusive. + 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 + 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; } @@ -114,7 +135,12 @@ 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 +# 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 TODOS_DONE_BASE=$(read_counter "${TODOS_DONE_FILE:-}") LOOP_START=$(date +%s) # script loop (outer while loop) starts here @@ -124,12 +150,57 @@ 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 + +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 + # 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. - CLAUDEZERO_INSTANCE="$INSTANCE_ID" CLAUDEZERO_TRANSCRIPTS="$TRANSCRIPTS_FILE" \ - claude --settings "$STOP_SETTINGS" --permission-mode auto --name "$SESSION_NAME" "$PROMPT" >&4 2>&4 || true + 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" CLAUDEZERO_MODE="$MODE" \ + CLAUDEZERO_LINK="${CLAUDEZERO_LINK:-}" \ + 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. + # `$?` 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 + 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. @@ -137,13 +208,16 @@ 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" + 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 @@ -157,6 +231,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, @@ -213,6 +290,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 @@ -244,6 +335,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" @@ -277,19 +371,39 @@ 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 +# 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 + [ "${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" @@ -316,6 +430,69 @@ 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)) +} + +# 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 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 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 · 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" + # 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=$! +} + +# 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 @@ -393,6 +570,79 @@ 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 last=0 spin=0 i 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; 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 + # 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 + # 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 + 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 @@ -661,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 @@ -684,8 +942,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 @@ -727,6 +986,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. @@ -768,7 +1049,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 @@ -985,10 +1266,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. @@ -996,7 +1276,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 @@ -1005,7 +1285,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: @@ -1028,14 +1308,16 @@ 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 - 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). + 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; 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 @@ -1044,16 +1326,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 ae3320f..dd5ee6a 100644 --- a/todo.md +++ b/todo.md @@ -15,3 +15,8 @@ - [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` +- [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 +- [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