From 6bd413a6a20771dc60ff686327692a5832dcf1f6 Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Thu, 30 Jul 2026 23:39:06 +0200 Subject: [PATCH 01/26] fix(guard): refuse launch inside a linked worktree (BUG-014) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root guard used `git rev-parse --show-toplevel`, which inside a leftover `../ts-*` task worktree returns that worktree's root, so the guard passed and BASE_BRANCH became a peer's claim branch — shifting the per-task lock name and letting two instances claim the same todo, with merges landing in the peer's in-flight branch instead of the base. Replace the check with the first entry of `git worktree list --porcelain`, which is always the main worktree, so one compare refuses both the subdir launch and the linked-worktree launch. `--show-toplevel` is removed, not kept as a fallback. - TEST.md: loosen B3's message assertion, add B4 (leftover branch + worktree, launch inside it, assert non-zero exit and no `*-task-*-task-*` branch). - .github/smoke.sh: numbered case 7 for the porcelain|sed main-root form, asserted from both the main and a linked worktree (BSD vs GNU sed). --- .github/smoke.sh | 13 +++++++++++++ TEST.md | 27 +++++++++++++++++++-------- claudezero.sh | 8 +++++--- 3 files changed, 37 insertions(+), 11 deletions(-) diff --git a/.github/smoke.sh b/.github/smoke.sh index 3a829d3..29d4f08 100755 --- a/.github/smoke.sh +++ b/.github/smoke.sh @@ -67,4 +67,17 @@ flock "$repo/.wtlock" git worktree remove --force "$wt" || fail "git work git worktree prune || fail "git worktree prune failed" ok "git worktree add/repair/remove/prune" +# 7. main-worktree root — git worktree list --porcelain | sed -n '1s/^worktree //p' (claudezero.sh:191) +# First porcelain entry must be the MAIN worktree even when read from inside a linked one, +# which is what makes the root guard refuse a launch in a leftover ../ts-* worktree (BUG-014). +main_root="$(cd "$repo" && pwd -P)" +[ "$(git -C "$repo" worktree list --porcelain | sed -n '1s/^worktree //p')" = "$main_root" ] \ + || fail "porcelain/sed main-root form failed from the main worktree" +wt2="$tmp/repo-task-2" +git -C "$repo" worktree add "$wt2" -b "$base-task-2" "$base" >/dev/null 2>&1 || fail "worktree add for root check failed" +[ "$(cd "$wt2" && git worktree list --porcelain | sed -n '1s/^worktree //p')" = "$main_root" ] \ + || fail "porcelain/sed answered the linked worktree instead of the main one" +git -C "$repo" worktree remove --force "$wt2" >/dev/null 2>&1 +ok "git worktree list --porcelain | sed main-root" + echo "SMOKE PASS ($(uname -s), bash $BASH_VERSION)" diff --git a/TEST.md b/TEST.md index e534ac0..1e5ee1e 100644 --- a/TEST.md +++ b/TEST.md @@ -178,14 +178,14 @@ echo "zero.sh wrote files: $(ls "$T"/repo/.git 2>/dev/null | grep -c '^todos-sec ## Scenario B — startup-guard refusals `[$TESTROOT/B]` (no claude) -Three pristine repos: one with a dirty working tree, one on a detached HEAD, one clean -launched from a subdir. Each must make claudezero refuse to start with the matching -message and a non-zero exit. +Four pristine repos: one with a dirty working tree, one on a detached HEAD, one clean +launched from a subdir, one clean launched from inside a leftover `../ts-*` task worktree. +Each must make claudezero refuse to start with the matching message and a non-zero exit. ### Setup ```bash TB="$TESTROOT/B" -for name in dirty detached nested; do +for name in dirty detached nested worktree; do mkdir -p "$TB/$name" ( cd "$TB/$name"; git init -q; git config user.email t@t.t; git config user.name test echo x > f; git add f; git commit -qm init @@ -194,6 +194,9 @@ done ( cd "$TB/dirty"; echo change >> f ) # dirty working tree ( cd "$TB/detached"; git checkout -q --detach ) mkdir -p "$TB/nested/sub" # clean repo; we launch from this subdir +# leftover claim worktree, exactly what a crashed peer abandons: branch -task-1 + ../ts-* +( cd "$TB/worktree"; base="$(git rev-parse --abbrev-ref HEAD)" + git worktree add -q "$TB/ts-$base-task-1-dead" -b "$base-task-1" "$base" ) ``` ### Run + assert @@ -208,11 +211,19 @@ if out=$(timeout 20 bash "$SCRIPT" todo.md -t x 2>&1); then rc=0; else rc=$?; fi cd "$TB/nested/sub" # clean repo, but not at the repo root if out=$(timeout 20 bash "$SCRIPT" ../todo.md -t x 2>&1); then rc=0; else rc=$?; fi -{ [ "$rc" != 0 ] && echo "$out" | grep -qi 'not at repo root'; } && echo "B3 subdir-guard PASS" || echo "B3 FAIL (rc=$rc): $out" +{ [ "$rc" != 0 ] && echo "$out" | grep -qi 'not at.*repo root'; } && echo "B3 subdir-guard PASS" || echo "B3 FAIL (rc=$rc): $out" + +cd "$TB/ts-$(git -C "$TB/worktree" rev-parse --abbrev-ref HEAD)-task-1-dead" # a peer's claim worktree +if out=$(timeout 20 bash "$SCRIPT" todo.md -t x 2>&1); then rc=0; else rc=$?; fi +{ [ "$rc" != 0 ] && echo "$out" | grep -qi 'not at.*repo root'; } && echo "B4 worktree-guard PASS" || echo "B4 FAIL (rc=$rc): $out" +git -C "$TB/worktree" branch --list '*-task-*-task-*' | grep -q . && echo "B4 FAIL: second-claim branch created" ``` -- **B PASS** — B1, B2, and B3 all report PASS (non-zero exit + the expected message, - before any claude launch). B3 proves the worktree-path assumption is enforced: a subdir - launch refuses rather than misfiring `../ts-*` paths. +- **B PASS** — B1, B2, B3, and B4 all report PASS (non-zero exit + the expected message, + before any claude launch), and no `*-task-*-task-*` branch exists. B3 proves the + worktree-path assumption is enforced: a subdir launch refuses rather than misfiring + `../ts-*` paths. B4 proves the same for a launch *inside* a leftover claim worktree, where + the base branch would otherwise be poisoned to a peer's claim and both instances would take + the same todo (BUG-014). --- diff --git a/claudezero.sh b/claudezero.sh index 27f2f8c..8e424be 100755 --- a/claudezero.sh +++ b/claudezero.sh @@ -186,9 +186,11 @@ else # (not detached) and CLEAN before we start — else refuse and let the human decide. [ "$BASE_BRANCH" != HEAD ] || { echo "$PROG: detached HEAD — check out the base branch first."; exit 1; } # guardrail: claude's Bash calls run from cwd, and the zero prompt + `.git/zero.sh` + `../ts-*` - # worktree paths all assume cwd is the repo root — refuse a subdir launch so they never misfire. - REPO_ROOT="$(git rev-parse --show-toplevel)" - [ "$PWD" = "$REPO_ROOT" ] || { echo "$PROG: not at repo root — cd to '$REPO_ROOT' first."; exit 1; } + # worktree paths all assume cwd is the main worktree's root — refuse a subdir launch, and a + # launch inside a leftover `../ts-*` claim worktree, so they never misfire. The first entry of + # `git worktree list --porcelain` is always the main worktree, so one compare covers both. + 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; } printf ' zero mode · base %s · fork → implement → commit → merge\n\n' "$BASE_BRANCH" # todo path: from the positional arg, else ask interactively. From f9acb269d3ea1252812eded03dac958ca982b0bc Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Thu, 30 Jul 2026 23:48:02 +0200 Subject: [PATCH 02/26] feat(report): count todos zeroed per instance, drop Claude loops timing Increment a per-instance todos-done-- counter at the same merge_task rc=0 point that credits ownership time, so the count and the time credit always agree on who did the work. Report it on the Todos: line and rename the heading to execution stats. Delete the Claude loops accumulator (CLAUDE_TOTAL / WAS_DONE / RUN_START and its per-iteration all_todos_done call). Closes ISSUE-015 --- README.md | 5 ++-- TEST.md | 31 +++++++++++++-------- claudezero.sh | 76 +++++++++++++++++++++++++++++++-------------------- 3 files changed, 68 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 54680ac..5a82add 100644 --- a/README.md +++ b/README.md @@ -65,9 +65,8 @@ $ claudezero todo.md … fresh context, next task … -❄ execution time (instance a1b2c3d4) - Todos: 12m 30s - Claude loops: 41m 02s +❄ execution stats (instance a1b2c3d4) + Todos: 12m 30s · 5 completed ClaudeZero run loop: 48m 15s ❄ ClaudeZero surveys the frozen field, and is proud. diff --git a/TEST.md b/TEST.md index e534ac0..070abfe 100644 --- a/TEST.md +++ b/TEST.md @@ -161,18 +161,22 @@ echo "latch opened : $([ -f "$T/gate/opened" ] && echo yes || echo no)" echo "restarts (A/B/C): $(grep -c restarting "$T/log_AGENT_A.txt") $(grep -c restarting "$T/log_AGENT_B.txt") $(grep -c restarting "$T/log_AGENT_C.txt")" # timing / per-instance isolation: echo "instance ids : $(grep -h -oE 'instance [^)]+' "$T"/log_AGENT_*.txt | sort -u | wc -l | tr -d ' ')" -echo "report labels : $(grep -h -cE ' (Todos|Claude loops|ClaudeZero run loop):' "$T"/log_AGENT_A.txt | 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 : $(grep -h -oE 'Todos:.*· [0-9]+ completed' "$T"/log_AGENT_A.txt | tail -1)" 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-')" ``` - **Zero PASS** — all 5 markers present, `todos checked = 5/5`, `git clean = yes`. - **Parallel PASS** — `latch opened = yes` AND `distinct agents = 3`. - **Restart PASS** — restarts summed across the three logs ≥ 3. - **Timing PASS** — `instance ids = 3` (each instance a distinct id → isolation), each - agent's log shows the `Todos:` / `Claude loops:` / `ClaudeZero run loop:` lines, and - `zero.sh wrote files ≥ 1`. That last one is the **env-hop proof**: `zero.sh` only - writes `todos-seconds--` when it received `CLAUDEZERO_INSTANCE` from claude's - env. (An instance that merged 0 tasks writes no file, so the count can be < 3; ≥ 1 is - the gate.) + agent's log shows the `Todos:` / `ClaudeZero run loop:` lines with `stale loop line = 0`, + `todos counted` shows a non-zero `N completed` for an agent that merged, the per-agent + counts sum to 5, and `zero.sh wrote files ≥ 1` / `zero.sh wrote counts ≥ 1`. Those last + two are the **env-hop proof**: `zero.sh` only writes `todos-seconds--` and + `todos-done--` when it received `CLAUDEZERO_INSTANCE` from claude's env. (An + instance that merged 0 tasks writes no file, so the count can be < 3; ≥ 1 is the gate.) --- @@ -374,16 +378,21 @@ cd "$TE/repo" GC="$(cd "$(git rev-parse --git-common-dir)" && pwd)"; mkdir -p "$GC/instance" printf '%s\n%s\n' 999999 fake > "$GC/instance/DEADID" # marker of a dead instance : > "$GC/todos-seconds-main-DEADID"; : > "$GC/todos-seconds-main-DEADID.lock" +: > "$GC/todos-done-main-DEADID"; : > "$GC/todos-done-main-DEADID.lock" : > "$GC/todos-seconds-main-NOMARK" # file with no marker at all +: > "$GC/todos-done-main-NOMARK" # run claudezero once more (stub claude) → startup registers our live instance, then GCs orphans PATH="$TE/bin:$PATH" timeout 30 env CLAUDEZERO_MAX_LOOPS=1 bash "$SCRIPT" todo.md -t x > "$TE/boot2.log" 2>&1 || true echo "E2 dead file gone : $([ -f "$GC/todos-seconds-main-DEADID" ] && echo NO || echo yes)" +echo "E2 dead count gone : $([ -f "$GC/todos-done-main-DEADID" ] || [ -f "$GC/todos-done-main-DEADID.lock" ] && echo NO || echo yes)" echo "E2 dead marker gone : $([ -f "$GC/instance/DEADID" ] && echo NO || echo yes)" echo "E2 nomark file gone : $([ -f "$GC/todos-seconds-main-NOMARK" ] && echo NO || echo yes)" +echo "E2 nomark count gone: $([ -f "$GC/todos-done-main-NOMARK" ] && echo NO || echo yes)" ``` -- **E2 PASS** — all three `gone = yes`: the dead instance's marker was reaped by liveness, - and both its time-file (+ `.lock`) and the unmarked file were deleted. (This run's own - instance marker is live during the sweep, so a real instance's file is never collateral.) +- **E2 PASS** — all five `gone = yes`: the dead instance's marker was reaped by liveness, + and its time-file, its count-file (both + `.lock`) and the unmarked files were deleted. + (This run's own instance marker is live during the sweep, so a real instance's file is + never collateral.) ## Scenario F — fenced-checkbox immunity `[$TESTROOT/F]` (stub claude, deterministic) @@ -467,10 +476,10 @@ cd "$REPO" echo "TESTROOT gone : $([ -d "$TESTROOT" ] && echo NO || echo yes)" echo "stale worktrees: $(git worktree list | tail -n +2 | wc -l | tr -d ' ') (want 0)" echo "stale branches : $(git branch --list '*-task-*' | wc -l | tr -d ' ') (want 0)" -echo "stray files : $(ls .git 2>/dev/null | grep -c '^todos-seconds-\|^zero.sh$\|^instance$') (want 0)" +echo "stray files : $(ls .git 2>/dev/null | grep -c '^todos-seconds-\|^todos-done-\|^zero.sh$\|^instance$') (want 0)" git status --porcelain ``` All counts must be 0 and `git status` empty. A leftover worktree, `*-task-*` branch, or -`todos-seconds-*`/`zero.sh`/`instance/` under the project's `.git` means a test ran +`todos-seconds-*`/`todos-done-*`/`zero.sh`/`instance/` under the project's `.git` means a test ran claudezero against the project repo instead of its `$TESTROOT` sandbox — report it, don't silently delete. diff --git a/claudezero.sh b/claudezero.sh index 27f2f8c..c878341 100755 --- a/claudezero.sh +++ b/claudezero.sh @@ -95,9 +95,9 @@ run_loop() { # unlimited (normal). Set >0 for tests so the loop self-terminates without a SIGINT. MAX_LOOPS="${CLAUDEZERO_MAX_LOOPS:-0}" LOOP_COUNT=0 -CLAUDE_TOTAL=0 # summed claude runtime, frozen once all todos land STOP=0 # set by the INT trap; the loop breaks to the closer below -TODOS_BASE=$(read_todos_total) # snapshot: report only THIS run's slice of the shared aggregate +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 # Ctrl+C during the between-runs sleep (cooked mode) requests a clean stop; the loop breaks to the @@ -109,16 +109,12 @@ reap_dead_sessions # startup: clear markers left by crashed prior runs before while true; do # first prompt submitted straight from the CLI arg. The session Stop hook SIGTERMs claude # when context fills; exit 143 is the normal restart path, so swallow it. - RUN_START=$(date +%s) - # done BEFORE this run? if so it's an idle restart and its time doesn't count. - all_todos_done && WAS_DONE=1 || WAS_DONE=0 CLAUDEZERO_INSTANCE="$INSTANCE_ID" claude --settings "$STOP_SETTINGS" --permission-mode auto "$PROMPT" || true # 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. if [ -t 0 ]; then stty sane 2>/dev/null || true; fi NOW=$(date +%s) - if [ "$WAS_DONE" = 0 ]; then CLAUDE_TOTAL=$(( CLAUDE_TOTAL + (NOW - RUN_START) )); fi LOOP_COUNT=$((LOOP_COUNT+1)) printf '\n\n' if [ "$MAX_LOOPS" -gt 0 ] && [ "$LOOP_COUNT" -ge "$MAX_LOOPS" ]; then @@ -213,6 +209,7 @@ fi GITDIR_ABS="$(cd "$(git rev-parse --git-dir)" && pwd)" SESSION_DIR="$(cd "$(git rev-parse --git-common-dir)" && pwd)/session" # matches zero.sh's marker dir TODOS_TIME_FILE="$(cd "$(git rev-parse --git-common-dir)" && pwd)/todos-seconds-${BASE_BRANCH//\//-}-$INSTANCE_ID" # this instance's file (matches zero.sh's todos_file) +TODOS_DONE_FILE="$(cd "$(git rev-parse --git-common-dir)" && pwd)/todos-done-${BASE_BRANCH//\//-}-$INSTANCE_ID" # count of todos this instance merged (matches zero.sh's todos_done_file) 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" @@ -269,10 +266,11 @@ fmt_dur() { else printf '%ds' "$s"; fi } -# read the shared todos-time aggregate (seconds of task ownership zero.sh records), 0 if absent. -read_todos_total() { - local v=0 - [ -n "${TODOS_TIME_FILE:-}" ] && [ -f "$TODOS_TIME_FILE" ] && { read -r v < "$TODOS_TIME_FILE" 2>/dev/null || v=0; } +# read one of zero.sh's per-instance aggregates ($1 = path: seconds of task ownership, or todos +# merged), 0 if absent/unset/garbled. +read_counter() { + local v=0 f=${1:-} + [ -n "$f" ] && [ -f "$f" ] && { read -r v < "$f" 2>/dev/null || v=0; } case "$v" in ''|*[!0-9]*) v=0;; esac printf '%s' "$v" } @@ -290,17 +288,17 @@ all_todos_done() { END { exit (any && !unchecked) ? 0 : 1 }' } -# multiline execution-time report. $1 = now epoch. -# Todos = per-task ownership time, this run's delta of zero.sh's aggregate -# Claude loops = summed runtime of EVERY claude invocation up to the all-done moment (frozen -# after) — sum across restarts, not one invocation -# Script loop = wall time of the outer while loop (claude runs + between-run sleeps) +# 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 +# Script loop = wall time of the outer while loop (claude runs + between-run sleeps) print_report() { - printf '\n❄ execution time (instance %s)\n' "${INSTANCE_ID:-?}" + printf '\n❄ execution stats (instance %s)\n' "${INSTANCE_ID:-?}" if [ "${MODE:-}" = zero ]; then - printf ' %-20s %s\n' 'Todos:' "$(fmt_dur $(( $(read_todos_total) - TODOS_BASE )))" + printf ' %-20s %s · %s completed\n' 'Todos:' \ + "$(fmt_dur $(( $(read_counter "${TODOS_TIME_FILE:-}") - TODOS_BASE )))" \ + "$(( $(read_counter "${TODOS_DONE_FILE:-}") - TODOS_DONE_BASE ))" fi - printf ' %-20s %s\n' 'Claude loops:' "$(fmt_dur "$CLAUDE_TOTAL")" printf ' %-20s %s\n' 'ClaudeZero run loop:' "$(fmt_dur $(( $1 - LOOP_START )))" } @@ -353,11 +351,12 @@ reap_dead_sessions() { # A time-file is an orphan iff its id has no LIVE marker (crash-leaked markers are GC'd by liveness). proc_start() { ps -o lstart= -p "$1" 2>/dev/null | awk '{$1=$1;print}'; } instance_alive() { kill -0 "$1" 2>/dev/null && [ "$(proc_start "$1")" = "$2" ]; } # $1=pid $2=start -# delete todos-seconds-- (+ .lock/.tmp sidecars) whose instance is not live. Called at -# startup AFTER our marker is written, so this instance and live peers are always preserved. +# delete todos-seconds-- / todos-done-- (+ .lock/.tmp sidecars) whose instance +# is not live. Called at startup AFTER our marker is written, so this instance and live peers are +# always preserved. cleanup_orphan_time_files() { [ -n "${INSTANCE_DIR:-}" ] || return 0 - local gc slug f id m pid st + local gc slug f id m pid st pre gc="$(cd "$(git rev-parse --git-common-dir)" && pwd)"; slug="${BASE_BRANCH//\//-}" if [ -d "$INSTANCE_DIR" ]; then # GC crash-leaked markers first for m in "$INSTANCE_DIR"/*; do @@ -366,12 +365,14 @@ cleanup_orphan_time_files() { instance_alive "${pid:-0}" "${st:-}" || rm -f "$m" done fi - for f in "$gc/todos-seconds-$slug-"*; do - [ -e "$f" ] || continue - case "$f" in *.lock|*.tmp) continue;; esac # sidecars swept with their base file below - id="${f##*/todos-seconds-"$slug"-}" - [ -f "$INSTANCE_DIR/$id" ] && continue # id still has a (live) marker → keep - rm -f "$f" "$f.lock" "$f.tmp" + for pre in todos-seconds todos-done; do + for f in "$gc/$pre-$slug-"*; do + [ -e "$f" ] || continue + case "$f" in *.lock|*.tmp) continue;; esac # sidecars swept with their base file below + id="${f##*/"$pre"-"$slug"-}" + [ -f "$INSTANCE_DIR/$id" ] && continue # id still has a (live) marker → keep + rm -f "$f" "$f.lock" "$f.tmp" + done done } @@ -399,6 +400,8 @@ INSTANCE_ID="${CLAUDEZERO_INSTANCE:-shared}" # per-instance aggregate path: seconds of task ownership credited to instance $1. Namespaced by base # slug (like branches/worktrees/reclaim-locks) AND instance id, so peers keep separate, comparable totals. todos_file() { printf '%s/todos-seconds-%s-%s' "$GITDIR" "${BASE_BRANCH//\//-}" "$1"; } +# same namespacing for the count of todos instance $1 landed on the base branch. +todos_done_file() { printf '%s/todos-done-%s-%s' "$GITDIR" "${BASE_BRANCH//\//-}" "$1"; } # stat mtime-epoch flavor, probed once: BSD/macOS `-f %m` vs GNU/Linux `-c %Y`. if stat -f %m . >/dev/null 2>&1; then STAT_MTIME=(stat -f %m); else STAT_MTIME=(stat -c %Y); fi @@ -414,10 +417,21 @@ newest_mtime() { # fd 9 is acquire's). Credit goes to the instance that WORKED the span, not necessarily the caller # (a stealer credits the crashed owner). Silently ignores non-numeric / non-positive / no-instance. add_todos_time() { - local add=${1:-0} inst=${2:-} cur=0 f + local add=${1:-0} inst=${2:-} case "$add" in ''|*[!0-9]*) return 0;; esac; [ "$add" -gt 0 ] || return 0 [ -n "$inst" ] || inst=shared - f=$(todos_file "$inst") + add_counter "$add" "$(todos_file "$inst")" +} +# +1 to instance $1's completed-todo count, from the same rc=0 point that credits its time, so +# the count and the time credit can never disagree about who did the work. +add_todos_done() { + local inst=${1:-} + [ -n "$inst" ] || inst=shared + add_counter 1 "$(todos_done_file "$inst")" +} +# add $1 (positive int) to counter file $2, under a per-file lock. +add_counter() { + local add=$1 f=$2 cur=0 exec 8>"$f.lock"; flock 8 [ -f "$f" ] && { read -r cur < "$f" 2>/dev/null || cur=0; } case "$cur" in ''|*[!0-9]*) cur=0;; esac @@ -591,8 +605,10 @@ merge_task() { flock "$wtlock" git worktree remove --force "$wt" || true git branch -d "$branch" >/dev/null 2>&1 || true ' _ "$wt" "$branch" "$BASE_BRANCH" "$GITDIR/checkbox-merge.err" "$TODO_PATH" "$WT_LOCK"; then rc=0; else rc=$?; fi - # merged → credit full elapsed (acquire → now) to the instance that held it (line4). + # merged → credit full elapsed (acquire → now) and one completed todo to the instance that held + # it (line4). The branch is deleted right above, so no todo can be counted twice. if [ "$rc" -eq 0 ] && [ -n "$acq" ]; then add_todos_time "$(( $(date +%s) - acq ))" "$inst"; fi + if [ "$rc" -eq 0 ]; then add_todos_done "$inst"; fi return $rc } From b4d132e0c0e9362703acc509ac2038c702e9c0f0 Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Thu, 30 Jul 2026 23:51:24 +0200 Subject: [PATCH 03/26] feat(logging): send claude's TUI to fd 4 so a piped run logs only ClaudeZero (ISSUE-018) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `claudezero.sh 2>&1 | tee run.log` captured every TUI redraw and escape sequence. claude now writes to fd 4, which points at the terminal when stdout has been redirected and a controlling terminal exists, and falls back to a dup of stdout otherwise (no redirect, or no tty as in tests/CI/nohup) — so captured output is unchanged there. `usage()` gains the logging example with the Ctrl+C-safe `{ trap '' INT; tee run.log; }` wrapper, plus a comment recording why the braces are load-bearing: Ctrl+C signals the whole foreground group and a bare pipe reader dies, taking the closing report with it. No in-script logging mode: the shell already redirects; this only stops claude from polluting the redirect. TEST.md gains Scenario G — G1 asserts the no-tty fallback deterministically (session-leader detach via setsid or a python3 shim, since macOS has no setsid), G2 documents the pty check with both BSD and util-linux `script` syntaxes. --- TEST.md | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++ claudezero.sh | 18 ++++++++++++++++- 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/TEST.md b/TEST.md index 1e5ee1e..4742697 100644 --- a/TEST.md +++ b/TEST.md @@ -450,6 +450,61 @@ cd "$TF/repo" --- +## Scenario G — 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 output captured to a file there is no +controlling terminal, 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 +```bash +TG="$TESTROOT/G"; mkdir -p "$TG/repo" "$TG/bin" +cat > "$TG/bin/claude" <<'EOF' +#!/usr/bin/env bash +echo "STUB-CLAUDE-MARKER" +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 -- '- [ ] G1 x\n' > todo.md; git add -A; git commit -qm init +# run as a session leader so there is genuinely no controlling terminal (macOS has no setsid) +detach() { + if command -v setsid >/dev/null 2>&1; then setsid "$@" + elif command -v python3 >/dev/null 2>&1; then + python3 -c 'import os,sys; os.setsid(); os.execvp(sys.argv[1], sys.argv[1:])' "$@" + else echo "G1 SKIP — neither setsid nor python3 available to drop the controlling terminal" >&2; fi +} +detach env PATH="$TG/bin:$PATH" CLAUDEZERO_MAX_LOOPS=1 \ + timeout 30 bash "$SCRIPT" todo.md -t x > "$TG/run.log" 2>&1 || 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)" +``` +- **G1 PASS** — both counts as stated: with no controlling terminal the `(: >/dev/tty)` probe + fails, fd 4 is a dup of stdout, and no scenario that captures output loses stub-claude bytes. + Detaching explicitly matters — run from a terminal without `detach`, 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 pty) + +Not scripted: it needs a real terminal, and the two `script(1)` implementations take +opposite argument orders. Run one of these by hand in a scratch repo with a real `claude`: + +```bash +# macOS / BSD script — typescript to /dev/null; script's own stdout is the pipe +script -q /dev/null ./claudezero.sh issues/todo.md 2>&1 | { trap '' INT; tee run.log; } +# util-linux script — command via -c, typescript file last +script -q -c "./claudezero.sh issues/todo.md 2>&1" /dev/null | { trap '' INT; tee run.log; } +``` +`script` gives claudezero a pty (so `/dev/tty` opens) while its stdout is the pipe (so fd 1 is +not a tty) — exactly the operator's situation. +- **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`. + +--- + ## Run all in parallel (optional) After Section 0 and each Setup, launch the Run blocks together: put A's and C's run diff --git a/claudezero.sh b/claudezero.sh index 8e424be..23e553e 100755 --- a/claudezero.sh +++ b/claudezero.sh @@ -19,6 +19,11 @@ PROG="$(basename "$0")" # name shown in usage/errors, from how the script was 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 +# 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 +# is terminate, and once it dies the log has no writer left, losing exactly the closing report the +# operator ran the pipe for. `trap '' INT` sets *ignore*, which survives `exec`, so the reader +# 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" <<'USAGE' @@ -35,6 +40,12 @@ usage: @@PROG@@ [todo-file-path] [-t|--taskprompt TEXT | -l|--loopprompt TEXT] -h, --help Show this help. -t and -l are mutually exclusive. + + 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; } + + The `trap` keeps tee alive through Ctrl+C so the final report lands in the file. USAGE } @@ -94,6 +105,11 @@ run_loop() { # CLAUDEZERO_MAX_LOOPS: exit after N iterations instead of looping until Ctrl+C. 0/unset = # unlimited (normal). Set >0 for tests so the loop self-terminates without a SIGINT. MAX_LOOPS="${CLAUDEZERO_MAX_LOOPS:-0}" +# fd 4 = where claude's own chatter goes. Redirected stdout + a terminal present → claude keeps +# writing to the terminal, so piping claudezero.sh to a log file records the ❄ reports, not the TUI. +# No redirect, or no controlling terminal (tests, CI, nohup) → fd 4 is plain stdout, as today. +# Probe in a subshell: a failed `exec` redirection is shell-fatal, not testable. +if [ ! -t 1 ] && (: >/dev/tty) 2>/dev/null; then exec 4>/dev/tty; else exec 4>&1; fi LOOP_COUNT=0 CLAUDE_TOTAL=0 # summed claude runtime, frozen once all todos land STOP=0 # set by the INT trap; the loop breaks to the closer below @@ -112,7 +128,7 @@ while true; do RUN_START=$(date +%s) # done BEFORE this run? if so it's an idle restart and its time doesn't count. all_todos_done && WAS_DONE=1 || WAS_DONE=0 - CLAUDEZERO_INSTANCE="$INSTANCE_ID" claude --settings "$STOP_SETTINGS" --permission-mode auto "$PROMPT" || true + CLAUDEZERO_INSTANCE="$INSTANCE_ID" claude --settings "$STOP_SETTINGS" --permission-mode auto "$PROMPT" >&4 2>&4 || true # 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. From 6576945b711d5b59fcf0de1ab79fc933ae98bf5f Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Thu, 30 Jul 2026 23:59:31 +0200 Subject: [PATCH 04/26] feat(session): name each claude session () Pass --name to the claude launch so the prompt box, /resume picker and terminal title carry the same instance id the execution-stats report heads, plus a dojo student activity picked from the id (16# % 10) so the name is stable across context restarts. Loop mode gets it too. TEST.md Scenario G covers name construction, unsplit argv, restart stability and the derivation. Closes ISSUE-019 --- TEST.md | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++ claudezero.sh | 24 ++++++++++++++++++++- 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/TEST.md b/TEST.md index 7b8f732..2a06d67 100644 --- a/TEST.md +++ b/TEST.md @@ -459,6 +459,65 @@ cd "$TF/repo" --- +## Scenario G — claude session display name `[$TESTROOT/G]` (stub claude, deterministic) + +Each instance names its claude session `() ` via `--name`, so +parallel terminals are told apart without reading hex. The name must reach claude as ONE argv +element, and must be the SAME on every context restart (derived from the id, not `RANDOM`). +The stub claude echoes its argv, so no real claude is needed. + +### Setup +```bash +TG="$TESTROOT/G"; mkdir -p "$TG/repo" "$TG/bin" +cat > "$TG/bin/claude" <<'EOF' +#!/usr/bin/env bash +printf 'ARGV:'; for a in "$@"; do printf ' [%s]' "$a"; done; printf '\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 -- '- [ ] G1 x\n' > todo.md; git add -A; git commit -qm init +# the ten activities, verbatim (claudezero.sh dojo_student) +ACT=('drilling the fork-implement-merge kata' 'hauling snow buckets uphill' \ + 'claiming a track before stepping on it' 'reading the whole task before striking' \ + 'starting over on fresh snow' 'carving one checkbox into ice' 'chasing one unchecked box' \ + 'practicing one clean strike per task' "leaving a peer's branch untouched" \ + 'approaching the merge gate') +``` + +### G1 — name construction, unsplit argv, restart stability +```bash +cd "$TG/repo" +PATH="$TG/bin:$PATH" timeout 90 env CLAUDEZERO_MAX_LOOPS=3 bash "$SCRIPT" todo.md -t x > "$TG/run.log" 2>&1 || true +ID=$(grep -m1 -oE 'instance [0-9A-Za-z]+' "$TG/run.log" | awk '{print $2}') +WANT="($ID) ${ACT[$(( 16#${ID:0:2} % 10 ))]}" +echo "G1 want name : $WANT" +echo "G1 launches : $(grep -c '^ARGV:' "$TG/run.log") (want 3)" +echo "G1 named+unsplit : $(grep -c -F -- "[--name] [$WANT]" "$TG/run.log") (want 3)" +``` +- **G1 PASS** — `launches = 3` and `named+unsplit = 3`: `--name` carries the parenthesised, + space-containing name as a single argv element, the activity is the one the id selects by + `16# % 10`, and all three restarts used the same name. + +### G2 — loop mode named too; decimal (`$$`-shaped) id picks an activity, not an error +```bash +cd "$TG/repo" +PATH="$TG/bin:$PATH" timeout 60 env CLAUDEZERO_MAX_LOOPS=1 bash "$SCRIPT" -l 'hi' > "$TG/loop.log" 2>&1 || true +echo "G2 loop-mode name: $(grep -m1 -oE '\[--name\] \[[^]]*\]' "$TG/loop.log" || echo NONE)" +# the $$ fallback id (claudezero.sh:150) is decimal digits — valid hex, so the same derivation +# applies with no branch. Drive the real function on such an id. +eval "$(sed -n '/^dojo_student()/,/^}/p' "$SCRIPT")" +echo "G2 decimal id : $(dojo_student 48584); $(dojo_student 90210) (two activities, no error)" +echo "G2 deterministic : $([ "$(dojo_student a1b2c3d4)" = "$(dojo_student a1ffffff)" ] && echo yes || echo NO)" +echo "G2 a1 vs b2 : $([ "$(dojo_student a1b2c3d4)" != "$(dojo_student b2b2c3d4)" ] && echo differ || echo same)" +``` +- **G2 PASS** — the loop-mode line shows `[--name] [() ]`, both decimal ids render + an activity with no arithmetic error, `deterministic = yes` (only the first two chars select), + and `a1 vs b2 = differ` (`16#a1 % 10 = 1`, `16#b2 % 10 = 8`). + +--- + ## Run all in parallel (optional) After Section 0 and each Setup, launch the Run blocks together: put A's and C's run diff --git a/claudezero.sh b/claudezero.sh index 4af75a7..4b2540a 100755 --- a/claudezero.sh +++ b/claudezero.sh @@ -109,7 +109,7 @@ reap_dead_sessions # startup: clear markers left by crashed prior runs before while true; do # first prompt submitted straight from the CLI arg. The session Stop hook SIGTERMs claude # when context fills; exit 143 is the normal restart path, so swallow it. - CLAUDEZERO_INSTANCE="$INSTANCE_ID" claude --settings "$STOP_SETTINGS" --permission-mode auto "$PROMPT" || true + CLAUDEZERO_INSTANCE="$INSTANCE_ID" claude --settings "$STOP_SETTINGS" --permission-mode auto --name "$SESSION_NAME" "$PROMPT" || true # 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. @@ -144,6 +144,10 @@ BASE_BRANCH="$(git rev-parse --abbrev-ref HEAD)" # compare instances after a run and size the fleet next time. Exported into claude's env below, # inherited by its Bash-tool children; zero.sh reads it (CLAUDEZERO_INSTANCE) to route credit. INSTANCE_ID="$(uuidgen 2>/dev/null | tr -d - | head -c8)"; [ -n "$INSTANCE_ID" ] || INSTANCE_ID="$$" +# claude's display name (prompt box, /resume picker, terminal title) — the same id the report heads +# its stats with, plus a dojo-student activity so parallel terminals are told apart without reading +# hex. Derived from the id, never from chance, so every restart re-launches under the same name. +SESSION_NAME="($INSTANCE_ID) $(dojo_student "$INSTANCE_ID")" # -t/--taskprompt sets the zero task prompt; -l/--loopprompt runs claude on a # literal prompt (skips zero mode). They are mutually exclusive. @@ -331,6 +335,24 @@ dojo_wisdom() { ) printf '\n❄ %s\n' "${w[RANDOM % ${#w[@]}]}" } +# what the student under ClaudeZero's guidance is busy with — the tail of a claude session's +# display name. $1 = instance id; the first two chars (hex from uuidgen, decimal from the $$ +# fallback — both valid hex) pick the line, so the name is stable across context restarts. +dojo_student() { + local a=( + 'drilling the fork-implement-merge kata' + 'hauling snow buckets uphill' + 'claiming a track before stepping on it' + 'reading the whole task before striking' + 'starting over on fresh snow' + 'carving one checkbox into ice' + 'chasing one unchecked box' + 'practicing one clean strike per task' + "leaving a peer's branch untouched" + 'approaching the merge gate' + ) + printf '%s' "${a[$(( 16#${1:0:2} % 10 ))]}" +} # the proud closer — his quiet nod of pride, printed independently once every todo has landed. dojo_proud() { printf '\n❄ ClaudeZero surveys the frozen field, and is proud.\n'; } From 75d575d77b10cc211d42d603cfbaa9bc8161be05 Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Fri, 31 Jul 2026 00:01:46 +0200 Subject: [PATCH 05/26] feat(report): report per-instance token consumption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sum the four billed token categories (input, output, cache creation, cache read) from the claude session transcripts, and print them as a headline total with the breakdown beneath the timing rows. The Stop hook now records each session's transcript_path into a per-instance list in the git dir (destination passed via CLAUDEZERO_TRANSCRIPTS, since the hook file itself is shared by all instances in the repo). After claude exits, the loop makes one whole-file awk pass over those transcripts, deduping by requestId — a single API request is written as one line per content block, each repeating the same usage object — and taking only the parent field on each line, never the usage.iterations[] copy or the cache_creation ephemeral leaves that already sum into it. No dollar figure: there is no first-party programmatic rate source, and a hardcoded table would print confidently wrong money after any model launch. Any parse miss prints "Tokens: n/a" and leaves the run untouched. Also renames the report heading from "execution time" to "execution stats" — a token block is not a duration. Co-Authored-By: Claude Opus 5 (1M context) --- .github/smoke.sh | 24 ++++++++++++ README.md | 9 ++++- TEST.md | 98 +++++++++++++++++++++++++++++++++++++++++++++--- claudezero.sh | 82 ++++++++++++++++++++++++++++++++++++++-- 4 files changed, 203 insertions(+), 10 deletions(-) diff --git a/.github/smoke.sh b/.github/smoke.sh index 3a829d3..5702812 100755 --- a/.github/smoke.sh +++ b/.github/smoke.sh @@ -67,4 +67,28 @@ flock "$repo/.wtlock" git worktree remove --force "$wt" || fail "git work git worktree prune || fail "git worktree prune failed" ok "git worktree add/repair/remove/prune" +# 7. token accounting — transcript_path sed (claudezero.sh:243) + the usage awk (claudezero.sh:304). +# The awk must dedupe by requestId and take the PARENT field on each line, never the +# usage.iterations[] copy or the cache_creation ephemeral leaves. +tp="$(printf '%s' '{"transcript_path": "/a b/c.jsonl"}' \ + | sed -n 's/.*"transcript_path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')" +[ "$tp" = "/a b/c.jsonl" ] || fail "transcript_path parse got '$tp'" +u='"input_tokens":10,"cache_creation_input_tokens":248,"cache_read_input_tokens":1000,"output_tokens":20,"cache_creation":{"ephemeral_5m_input_tokens":148,"ephemeral_1h_input_tokens":100},"iterations":[{"input_tokens":10,"output_tokens":20,"cache_read_input_tokens":1000,"cache_creation_input_tokens":248}]' +for _ in 1 2; do printf '{"requestId":"req_A","message":{"usage":{%s}}}\n' "$u"; done > "$tmp/t.jsonl" +sums="$(awk ' + function num(key, s) { + if (!match($0, "\"" key "\":[0-9]+")) return 0 + s = substr($0, RSTART, RLENGTH); sub(/.*:/, "", s); return s + 0 + } + /"output_tokens":/ { + k = match($0, /"requestId":"[^"]+"/) ? substr($0, RSTART + 13, RLENGTH - 14) : "line" NR + if (k in seen) next + seen[k] = 1; n++ + i += num("input_tokens"); o += num("output_tokens") + cc += num("cache_creation_input_tokens"); cr += num("cache_read_input_tokens") + } + END { if (n) printf "%d %d %d %d %d\n", i, o, cc, cr, i + o + cc + cr }' "$tmp/t.jsonl")" +[ "$sums" = "10 20 248 1000 1278" ] || fail "usage awk got '$sums' (want '10 20 248 1000 1278')" +ok "sed transcript_path / awk usage dedupe" + echo "SMOKE PASS ($(uname -s), bash $BASH_VERSION)" diff --git a/README.md b/README.md index 54680ac..7d2ed4e 100644 --- a/README.md +++ b/README.md @@ -65,11 +65,14 @@ $ claudezero todo.md … fresh context, next task … -❄ execution time (instance a1b2c3d4) +❄ execution stats (instance a1b2c3d4) Todos: 12m 30s Claude loops: 41m 02s ClaudeZero run loop: 48m 15s + Tokens: 5.8M Total + in 2.1k · out 84.3k · cache write 312k · cache read 5.4M + ❄ ClaudeZero surveys the frozen field, and is proud. ``` @@ -107,6 +110,10 @@ Supported on **macOS and Linux** (the script is bash-3.2-safe, so stock macOS `b Both prerequisites are guard-checked at startup; the script exits with a clear message if either is missing. +**Transcript-schema contract.** The token figures in the execution-stats report are a second coupling to Claude Code internals, alongside the state file above. ClaudeZero's own Stop hook records each session's `transcript_path` (a field of the hook payload it already parses `session_id` from), and after `claude` exits the loop reads those session JSONL transcripts and sums the `message.usage` fields of every assistant line: `input_tokens`, `output_tokens`, `cache_creation_input_tokens`, `cache_read_input_tokens` — the four categories Anthropic bills separately. One API request is written as several transcript lines, one per content block, each repeating the same `usage` object verbatim, so records are **deduped by the line's `requestId`**, and only the first (parent) match of each field name on a line is taken: `usage.iterations[]` repeats all four names one level down, and `usage.cache_creation` carries the `ephemeral_5m`/`ephemeral_1h` leaves that already sum into the parent. Transcripts are only ever read, and no schema change can fail a run — any parse miss prints `Tokens: n/a` and the run continues. + +Two limits: **subagent tokens are invisible** — a session that used the Agent tool writes no `isSidechain` usage lines, so anything the task prompt spawns is missing from the totals, and the size of the under-count is not measurable from inside; and the figures are **per instance run, for this repo only**, unlike whole-machine tools such as `ccusage`, whose denominator is every Claude Code session on the box. + ## Todo file format GitHub-style Markdown checkboxes, one task per line. Each line carries a **unique id** as the first whitespace-delimited token right after the checkbox — it names the task's branch and worktree: diff --git a/TEST.md b/TEST.md index e534ac0..b3cbc57 100644 --- a/TEST.md +++ b/TEST.md @@ -1,6 +1,6 @@ # TEST.md — end-to-end tests for `claudezero.sh` -Five scenarios, each in its own folder under a single **isolated TESTROOT created +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. @@ -16,6 +16,10 @@ 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.f). - **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 + 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. Parallelism (A, C) is enforced with a **file-lock barrier**, not `sleep`, so the proof is independent of claude startup/shutdown times. @@ -32,10 +36,10 @@ 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 `~/.claude/settings.json` (claudezero.sh refuses to start without it — if A/C logs show -an immediate hook error, report "prerequisite missing — suggest-compact hook"). B and E -do **not** need real claude but still need `flock` and the `suggest-compact` hook (both -startup guards run before any claude launch; E supplies a stub `claude` so claudezero -writes `zero.sh` and loops out at once). A missing hook makes B/E exit with the wrong +an immediate hook error, report "prerequisite missing — suggest-compact hook"). B, E, F and +G do **not** need real claude but still need `flock` and the `suggest-compact` hook (both +startup guards run before any claude launch; E/F/G supply a stub `claude` so claudezero +writes `zero.sh` and loops out at once). A missing hook makes them exit with the wrong message and their assertions fail. Inform about progress during the test; at the end return a summary report @@ -437,6 +441,88 @@ 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) + +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 +path with no API calls and no real claude. `MAX_LOOPS=3` is deliberate: the report prints +*between* runs (the `MAX_LOOPS` break comes before it), so three runs give two reports — +the second proves figures accumulate across a context restart. Covers what a naive summer +gets wrong: one API request writes one transcript line **per content block**, all repeating +the same `usage`, and each `usage` repeats all four field names inside `iterations[]` plus +the `cache_creation` ephemeral leaves that already sum into the parent. + +### Setup (stub + repo) +```bash +TG="$TESTROOT/G"; mkdir -p "$TG/repo" "$TG/bin" "$TG/tx" +cat > "$TG/bin/claude" <<'EOF' +#!/usr/bin/env bash +# stub claude: no API calls. Fabricates ONE session transcript per run, then fires the real +# Stop hook (path pulled out of the --settings JSON claudezero passed us) with the payload +# shape claude sends, so transcript_path recording is exercised for real. +n=$(( $(cat "$TG_TX/count" 2>/dev/null || echo 0) + 1 )); echo "$n" > "$TG_TX/count" +t="$TG_TX/t$n.jsonl" +case "${TG_MODE:-ok}" in + missing) : ;; # record a path with no file + bad) printf '{"message":{"usage":{"outp\n' > "$t" ;; # truncated / invalid JSON + *) if [ "$n" = 1 ]; then + # req_A on 3 content-block lines with IDENTICAL usage (must count once), each carrying + # the usage.iterations[] copy and the cache_creation ephemeral leaves (148+100 = 248). + u='"input_tokens":10,"cache_creation_input_tokens":248,"cache_read_input_tokens":1000,"output_tokens":20,"cache_creation":{"ephemeral_5m_input_tokens":148,"ephemeral_1h_input_tokens":100},"iterations":[{"input_tokens":10,"output_tokens":20,"cache_read_input_tokens":1000,"cache_creation_input_tokens":248}]' + for i in 1 2 3; do printf '{"requestId":"req_A","type":"assistant","message":{"usage":{%s}}}\n' "$u"; done > "$t" + printf '{"requestId":"req_B","type":"assistant","message":{"usage":{"input_tokens":5,"cache_creation_input_tokens":0,"cache_read_input_tokens":500,"output_tokens":7}}}\n' >> "$t" + else + printf '{"requestId":"req_C","type":"assistant","message":{"usage":{"input_tokens":100,"cache_creation_input_tokens":300,"cache_read_input_tokens":400,"output_tokens":200}}}\n' > "$t" + fi ;; +esac +hook="" +for a in "$@"; do case "$a" in *compact-exit-hook.sh*) hook="$(printf '%s' "$a" | sed -n 's/.*"command":"\([^"]*\)".*/\1/p')";; esac; done +[ -n "$hook" ] && printf '{"session_id":"stub%s","transcript_path":"%s"}' "$n" "$t" | "$hook" +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 -- '- [ ] 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; } +``` + +### G1 — 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)" +``` +- **G1 PASS** — `heading = 2`, and the 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). + Total `15+27+248+1500 = 1790` → `1.7k`, i.e. exactly the sum of the four categories. + - report 2: ` Tokens: 2.7k Total| in 115 · out 227 · cache write 548 · cache read 1.9k|` + — run 2's transcript **added** to run 1's, not replacing it (2790 → `2.7k`). + - `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 +```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)" +``` +- **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. + --- ## Run all in parallel (optional) @@ -467,7 +553,7 @@ cd "$REPO" echo "TESTROOT gone : $([ -d "$TESTROOT" ] && echo NO || echo yes)" echo "stale worktrees: $(git worktree list | tail -n +2 | wc -l | tr -d ' ') (want 0)" echo "stale branches : $(git branch --list '*-task-*' | wc -l | tr -d ' ') (want 0)" -echo "stray files : $(ls .git 2>/dev/null | grep -c '^todos-seconds-\|^zero.sh$\|^instance$') (want 0)" +echo "stray files : $(ls .git 2>/dev/null | grep -c '^todos-seconds-\|^transcripts-\|^zero.sh$\|^instance$') (want 0)" git status --porcelain ``` All counts must be 0 and `git status` empty. A leftover worktree, `*-task-*` branch, or diff --git a/claudezero.sh b/claudezero.sh index 27f2f8c..709b8f4 100755 --- a/claudezero.sh +++ b/claudezero.sh @@ -112,7 +112,8 @@ while true; do RUN_START=$(date +%s) # done BEFORE this run? if so it's an idle restart and its time doesn't count. all_todos_done && WAS_DONE=1 || WAS_DONE=0 - CLAUDEZERO_INSTANCE="$INSTANCE_ID" claude --settings "$STOP_SETTINGS" --permission-mode auto "$PROMPT" || true + CLAUDEZERO_INSTANCE="$INSTANCE_ID" CLAUDEZERO_TRANSCRIPTS="$TRANSCRIPTS_FILE" \ + claude --settings "$STOP_SETTINGS" --permission-mode auto "$PROMPT" || true # 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. @@ -213,6 +214,9 @@ fi GITDIR_ABS="$(cd "$(git rev-parse --git-dir)" && pwd)" SESSION_DIR="$(cd "$(git rev-parse --git-common-dir)" && pwd)/session" # matches zero.sh's marker dir TODOS_TIME_FILE="$(cd "$(git rev-parse --git-common-dir)" && pwd)/todos-seconds-${BASE_BRANCH//\//-}-$INSTANCE_ID" # this instance's file (matches zero.sh's todos_file) +# 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" 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" @@ -230,6 +234,15 @@ cat >"$STOP_HOOK" <<'HOOK_EOF' # loop restarts fresh. Reusing that bucket file as the signal means no separate flag and no edit # to the hook. Couples to its filename/tmpdir; update if ECC changes them. input="$(cat)" +# token accounting: record this session's transcript path for the outer loop to sum after claude +# exits. The hook file is shared by all instances in this git dir, so the destination comes from +# the env of the claude WE launched (CLAUDEZERO_TRANSCRIPTS), never baked in. One line per path; +# best-effort, never fails the turn. +tf="${CLAUDEZERO_TRANSCRIPTS:-}" +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() @@ -269,6 +282,60 @@ fmt_dur() { else printf '%ds' "$s"; fi } +# format a token count as 5.8M / 84.3k / 312 — integer arithmetic only (bash 3.2 has no floats). +fmt_tok() { + local n=$1 + if [ "$n" -ge 1000000 ]; then printf '%d.%dM' $((n/1000000)) $(( (n%1000000)/100000 )) + elif [ "$n" -ge 1000 ]; then printf '%d.%dk' $((n/1000)) $(( (n%1000)/100 )) + else printf '%d' "$n"; fi +} + +# sum this instance's claude token usage over the session transcripts its Stop hook recorded. +# Echoes "input output cache_create cache_read total"; EMPTY when nothing parses → report says n/a. +# Whole-file pass per call, not incremental byte offsets: a duplicate-requestId group can straddle +# an incremental boundary and get double-counted. Transcripts are bounded by the restart-at- +# context-threshold design, so re-reading them is cheap. +# dedupe by requestId — ONE API request is written as several transcript lines, one per content +# block (text, tool_use, thinking), each repeating the SAME usage object verbatim. Summing per +# line inflates every figure by the average blocks-per-turn. +# first match per line is the parent field — usage.iterations[] repeats all four names one level +# down, and usage.cache_creation carries the ephemeral_5m/1h leaves that already sum into +# cache_creation_input_tokens. Take the parent only, never the leaves or the nested copy. +read_tokens_total() { + [ -n "${TRANSCRIPTS_FILE:-}" ] && [ -f "$TRANSCRIPTS_FILE" ] || return 0 + local p + while IFS= read -r p; do + if [ -f "$p" ]; then cat "$p"; fi + done < "$TRANSCRIPTS_FILE" | awk ' + function num(key, s) { + if (!match($0, "\"" key "\":[0-9]+")) return 0 + s = substr($0, RSTART, RLENGTH); sub(/.*:/, "", s); return s + 0 + } + /"output_tokens":/ { + k = match($0, /"requestId":"[^"]+"/) ? substr($0, RSTART + 13, RLENGTH - 14) : "line" NR + if (k in seen) next + seen[k] = 1; n++ + i += num("input_tokens"); o += num("output_tokens") + cc += num("cache_creation_input_tokens"); cr += num("cache_read_input_tokens") + } + END { if (n) printf "%d %d %d %d %d\n", i, o, cc, cr, i + o + cc + cr }' 2>/dev/null +} + +# the report's token block: headline total, then the four billed categories beneath it. They do not +# overlap (total_input = cache_read + cache_creation + input, output on its own axis) and each bills +# at its own rate, so the total is a SCALE figure for comparison, not a cost. No money figure: there +# is no first-party programmatic rate source, only a hardcoded table that would rot. Degrade, never +# lie — a parse miss, a missing transcript or a schema change prints n/a and leaves the run alone. +print_tokens() { + local t; t="$(read_tokens_total || true)" + # shellcheck disable=SC2086 # deliberate split: awk emits five space-separated integers + set -- $t + if [ "$#" -ne 5 ]; then printf '\n Tokens: n/a\n'; return 0; fi + printf '\n Tokens: %s Total\n' "$(fmt_tok "$5")" + printf ' in %s · out %s · cache write %s · cache read %s\n' \ + "$(fmt_tok "$1")" "$(fmt_tok "$2")" "$(fmt_tok "$3")" "$(fmt_tok "$4")" +} + # read the shared todos-time aggregate (seconds of task ownership zero.sh records), 0 if absent. read_todos_total() { local v=0 @@ -290,18 +357,21 @@ all_todos_done() { END { exit (any && !unchecked) ? 0 : 1 }' } -# multiline execution-time report. $1 = now epoch. +# multiline execution-stats report. $1 = now epoch. # Todos = per-task ownership time, this run's delta of zero.sh's aggregate # Claude loops = summed runtime of EVERY claude invocation up to the all-done moment (frozen # after) — sum across restarts, not one invocation # Script loop = wall time of the outer while loop (claude runs + between-run sleeps) +# Tokens = this instance's claude token usage, own block (not a duration, so it does not +# share the timing rows' label column) print_report() { - printf '\n❄ execution time (instance %s)\n' "${INSTANCE_ID:-?}" + printf '\n❄ execution stats (instance %s)\n' "${INSTANCE_ID:-?}" if [ "${MODE:-}" = zero ]; then printf ' %-20s %s\n' 'Todos:' "$(fmt_dur $(( $(read_todos_total) - TODOS_BASE )))" fi printf ' %-20s %s\n' 'Claude loops:' "$(fmt_dur "$CLAUDE_TOTAL")" printf ' %-20s %s\n' 'ClaudeZero run loop:' "$(fmt_dur $(( $1 - LOOP_START )))" + print_tokens } # credit orphaned in-flight tasks before an exit-path report: zero.sh folds worktrees whose owner @@ -373,6 +443,12 @@ cleanup_orphan_time_files() { [ -f "$INSTANCE_DIR/$id" ] && continue # id still has a (live) marker → keep rm -f "$f" "$f.lock" "$f.tmp" done + for f in "$gc/transcripts-$slug-"*; do # same rule for the token-accounting lists + [ -e "$f" ] || continue + id="${f##*/transcripts-"$slug"-}" + [ -f "$INSTANCE_DIR/$id" ] && continue + rm -f "$f" + done } # write .git/zero.sh (the per-task acquire/release/merge helper the zero prompt calls) with From 5dc42d4a17f992f33fbb569a32d6ae182514d2d6 Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Fri, 31 Jul 2026 00:11:31 +0200 Subject: [PATCH 06/26] feat(zero.sh): collapse acquire/validate/re-check into one `claim` (ISSUE-020) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The zero prompt spent three agent steps on one decision — "is this task mine to work?" — and a failed validation in step 2.c left the claim from 2.b behind until a peer stole it. `claim_task` composes the three functions that already sit side by side in the emitted zero.sh: acquire, validate the worktree's branch, then `is_done`. It prints the worktree path on stdout (single field, no newline) and exits 0 when the task is yours; each skip reason goes to stderr with its own code — 1 not claimed, 3 validation failed, 4 already landed — which the prompt treats alike. On exit 3 it clears the session marker but leaves the worktree alone: force-removing a worktree on an unexpected branch would destroy unknown work. `acquire`, `release` and `done` stay as subcommands (TEST.md drives them directly). No new lock, no new fd — acquire's per-task reclaim lock already spans the whole call. Prompt steps b/c/d collapse into one CLAIM step; e/f/g become c/d/e, and the step-letter references in TEST.md were recomputed against the final prompt (the merge references said 2.f while the prompt already had 2.g). TASK_BRANCH stops being an agent variable — failure reports now read the branch with `git -C "$wt" symbolic-ref --short HEAD`. TEST.md gains Scenario H covering all four exit paths, the single-field stdout, the exit-3 claim leak, and the raw-vs-sanitized id split. `claim` goes through ensure_owner, so the driver runs under a copy of bash named `claude`. --- TEST.md | 90 ++++++++++++++++++++++++++++++++++++++++++++++++--- claudezero.sh | 67 +++++++++++++++++++++++++------------- 2 files changed, 130 insertions(+), 27 deletions(-) diff --git a/TEST.md b/TEST.md index 45c1018..0e2c372 100644 --- a/TEST.md +++ b/TEST.md @@ -13,7 +13,7 @@ the project's own working tree or history, and can run concurrently. hits a conflict, and the zero run aborts cleanly leaving the base green and the branch for a human. - **D — foreign check-off refusal + self-heal.** one agent is induced to tick a second - task's box; `merge_task` refuses with a pointer and the agent self-heals (step 2.f). + 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. @@ -236,7 +236,7 @@ git -C "$TB/worktree" branch --list '*-task-*-task-*' | grep -q . && echo "B4 FA Two tasks overwrite the same line of `conflict.txt`. The barrier (`need=2`) forces both agents to branch off the same base before either merges, so the second merge is a guaranteed modify/modify conflict. Zero mode must abort it, keep the base green, and -leave the losing branch for a human (zero algorithm, step 2.f). +leave the losing branch for a human (zero algorithm, step 2.e). ### Setup ```bash @@ -281,10 +281,10 @@ grep -Eril 'conflict|merge fail|resolve|stop' "$TC"/log_*.txt >/dev/null && echo ## Scenario D — foreign check-off refusal + self-heal `[$TESTROOT/D]` One agent, two tasks. The `-t` prompt **induces** the agent to tick a SECOND checkbox -(a task it does not own) and skip the step-2.e pre-commit check, so the foreign tick +(a task it does not own) and ignore step 2.d's `touch no other line` rule, so the foreign tick reaches the merge. `merge_task` enforces the one-box invariant on **every** merge (not just conflicting ones), refuses with a `checkbox-merge: refused` pointer listing the -offending `file:line`s, and step 2.f self-heals: uncheck the foreign line, amend, retry — +offending `file:line`s, and step 2.e self-heals: uncheck the foreign line, amend, retry — then the merge lands. No parallelism, no gate; a single instance triggers it because the branch carries two check-offs vs its fork point. @@ -305,7 +305,7 @@ printf '%s\n%s\n' "$U1" "$U2" > "$H/uuids.txt" ```bash cd "$H/repo" timeout -k 10 300 env CLAUDEZERO_MAX_LOOPS=3 bash "$SCRIPT" todo.md \ - -t "You are agent HEAL. TEST INDUCEMENT for step d only: after doing your acquired task, ALSO tick the OTHER task's checkbox to [x] in todo.md, and DO NOT run the step 2.e diff self-check — commit both ticks on your task branch. Then proceed to the merge (step 2.f) normally and follow its instructions to the letter." \ + -t "You are agent HEAL. TEST INDUCEMENT for step d only: after doing your acquired task, ALSO tick the OTHER task's checkbox to [x] in todo.md, and IGNORE step 2.d's \`touch no other line\` rule — commit both ticks on your task branch. Then proceed to the merge (step 2.e) normally and follow its instructions to the letter." \ > "$H/log.txt" 2>&1 || true ``` @@ -514,6 +514,86 @@ not a tty) — exactly the operator's situation. --- +## Scenario H — `zero.sh claim` exit paths `[$TESTROOT/H]` (stub claude, deterministic) + +`claim` is the whole "is this task mine to work?" decision: acquire, validate, re-check. It +prints the worktree path on stdout and exits 0 when the task is yours, else 1 (not claimed), +3 (validation failed) or 4 (a peer already landed it). No real claude needed — but `claim` +goes through `ensure_owner`, which requires a `claude` **ancestor process**, so the driver +below is executed by a copy of `bash` named `claude`. + +### Setup +```bash +TH="$TESTROOT/H"; mkdir -p "$TH/repo" "$TH/bin" +printf '#!/usr/bin/env bash\nexit 0\n' > "$TH/bin/claude"; chmod +x "$TH/bin/claude" +cd "$TH/repo" +git init -q -b main; git config user.email t@t.t; git config user.name test +printf -- '- [ ] H1 a\n- [ ] H3 b\n- [ ] H4 c\n- [ ] H5 d\n- [ ] H6/a e\n' > todo.md +git add -A; git commit -qm init +# bootstrap: real claudezero writes .git/zero.sh, stub claude exits, loop ends +PATH="$TH/bin:$PATH" timeout 30 env CLAUDEZERO_MAX_LOOPS=1 bash "$SCRIPT" todo.md -t x > "$TH/boot.log" 2>&1 || true +cp "$(command -v bash)" "$TH/bin/claude" # ensure_owner walks `ps -o comm=` for an ancestor named claude +cat > "$TH/drive.sh" <<'DRIVE' +set -uo pipefail +cd "$TH/repo" +ZERO="$(cd "$(git rev-parse --git-dir)" && pwd)/zero.sh" +GC="$(cd "$(git rev-parse --git-common-dir)" && pwd)" + +# H1 — free task: exit 0, worktree path on stdout and nothing else +wt=$("$ZERO" claim H1); rc=$? +echo "H1 exit : $rc (want 0)" +echo "H1 branch : $(git -C "$wt" symbolic-ref --short HEAD 2>/dev/null) (want main-task-H1)" +echo "H1 stdout clean : $([ "$(printf '%s' "$wt" | wc -l | tr -d ' ')" = 0 ] && [ "$(printf '%s' "$wt" | wc -w | tr -d ' ')" = 1 ] && echo yes || echo NO)" + +# H2 — already held by a live session: exit 1, no second worktree +before=$(git worktree list | grep -c 'task-H1') +out=$("$ZERO" claim H1 2>&1 >/dev/null); rc=$? +echo "H2 exit : $rc (want 1)" +echo "H2 stderr : $out" +echo "H2 no new worktree : $([ "$(git worktree list | grep -c 'task-H1')" = "$before" ] && echo yes || echo NO)" + +# H3 — a peer landed it on base first: exit 4, and the worktree/branch it briefly held are gone +sed -i'' -e 's/^- \[ \] H3 /- [x] H3 /' todo.md; git commit -qam 'peer landed H3' +out=$("$ZERO" claim H3 2>&1 >/dev/null); rc=$? +echo "H3 exit : $rc (want 4)" +echo "H3 stderr : $out" +echo "H3 worktree gone : $(git worktree list | grep -c 'task-H3') (want 0)" +echo "H3 branch gone : $(git branch --list 'main-task-H3' | wc -l | tr -d ' ') (want 0)" + +# H4 — 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|' \ + "$ZERO" > "$TH/zero-bad.sh"; chmod +x "$TH/zero-bad.sh" +out=$("$TH/zero-bad.sh" claim H4 2>&1 >/dev/null); rc=$? +echo "H4 exit : $rc (want 3)" +echo "H4 stderr : $out" +echo "H4 worktree kept : $(git worktree list | grep -c 'task-H4') (want 1 — exit 3 must NOT remove it)" +echo "H4 marker cleared : $(grep -l '^H4$' "$GC"/session/* 2>/dev/null | wc -l | tr -d ' ') (want 0 — no session still names H4)" + +# H5 — the leaked-claim regression: after an exit 3 the session may still claim another task +wt5=$("$ZERO" claim H5); rc=$? +echo "H5 exit : $rc (want 0 — the failed claim did not leak)" +echo "H5 branch : $(git -C "$wt5" symbolic-ref --short HEAD 2>/dev/null) (want main-task-H5)" + +# H6 — the RAW id reaches is_done: an id needing sanitization still matches its todo line +sed -i'' -e 's|^- \[ \] H6/a |- [x] H6/a |' todo.md; git commit -qam 'peer landed H6/a' +out=$("$ZERO" claim 'H6/a' 2>&1 >/dev/null); rc=$? +echo "H6 exit : $rc (want 4 — raw 'H6/a' matched the todo line, the branch used the slug)" + +echo "H7 usage : $("$ZERO" 2>&1 | grep -c 'claim N') (want 1)" +DRIVE +``` + +### Run + assert +```bash +TH="$TH" "$TH/bin/claude" "$TH/drive.sh" +``` +- **H 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 (H4/H5), and the + raw-vs-sanitized id split (H6). + +--- + ## Run all in parallel (optional) After Section 0 and each Setup, launch the Run blocks together: put A's and C's run diff --git a/claudezero.sh b/claudezero.sh index 5548643..469aa14 100755 --- a/claudezero.sh +++ b/claudezero.sh @@ -576,6 +576,33 @@ acquire_task() { setup_exclude "$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 +# worktree, then re-check that no peer landed it first. Prints the worktree path (stdout, no +# newline) + exit 0 when it is yours; every skip reason goes to stderr with a distinct exit code +# so TEST.md can assert which path ran: 1 = not claimed, 3 = validation failed, 4 = already landed. +# The agent treats all non-zero the same. Takes the RAW id: acquire/release want it sanitized, +# is_done wants it raw (it matches the todo line's first token), so both values are held here. +claim_task() { + local raw=$1 n wt br; n=$(sanitize_id "$raw") + if ! wt=$(acquire_task "$n"); then + echo "claim $raw: not claimed — a peer owns it or it is being rescued" >&2; return 1 + fi + br="" # set -e: keep the probe in an `if` — a bare failing `&&` chain would kill the process + if [ -n "$wt" ] && [ -d "$wt" ]; then br=$(git -C "$wt" symbolic-ref --short HEAD 2>/dev/null || true); fi + if [ "$br" != "$(task_branch "$n")" ]; then + # do NOT release: that would force-remove a worktree sitting on an unexpected branch with + # unknown contents. Just drop the session claim; a later acquire steals it the normal way. + set_current none + echo "claim $raw: validation failed — worktree ${wt:-} is on ${br:-}, want $(task_branch "$n")" >&2 + return 3 + fi + if is_done "$raw" "$wt"; then + release_task "$n" "$wt" + echo "claim $raw: already landed on $BASE_BRANCH by a peer — released" >&2; return 4 + fi + printf '%s' "$wt" +} + # release: undo a claim (worktree + branch) and mark this session idle. -d refuses a branch with # unmerged commits (safety), leaving an orphan branch a later acquire reattaches. release_task() { @@ -696,7 +723,7 @@ box_checked_on_base() { # done: deterministic "has this task already LANDED on the base branch?" — exit 0 = landed (safe to # release/skip), exit 1 = not landed (own it, drive+merge it). Given the RAW task id. Authoritative # signal is the base box: OR-merge + the one-box invariant mean a base [x] can only come from THIS -# task's branch actually merging. A worktree that ticked its own TASK_BRANCH box but whose merge +# task's branch actually merging. A worktree that ticked its own box on its task branch but whose merge # failed/aborted leaves base at [ ] = not done. If a wt is passed and still exists, also assert its # tip is an ancestor of base — catches the ticked-box-but-unmerged wt from the failed-merge rescue path. is_done() { @@ -707,12 +734,13 @@ is_done() { } case "${1:-}" in + claim) ensure_owner; claim_task "$2" ;; acquire) ensure_owner; acquire_task "$(sanitize_id "$2")" ;; release) ensure_owner; release_task "$(sanitize_id "$2")" "$3" ;; merge) ensure_owner; merge_task "$(sanitize_id "$2")" "$3" && set_current none || exit $? ;; # clear only on success done) is_done "$2" "${3:-}" ;; credit_inflight_time) credit_inflight_time ;; - *) echo "usage: zero.sh {acquire N | release N WT | merge N WT | done N [WT] | credit_inflight_time}" >&2; exit 64 ;; + *) echo "usage: zero.sh {claim N | acquire N | release N WT | merge N WT | done N [WT] | credit_inflight_time}" >&2; exit 64 ;; esac ZERO_EOF } > "$gitdir/zero.sh" @@ -797,35 +825,30 @@ Keep these facts in mind for every iteration: - Any inbound edge to an unchecked task → skip to the next task_id. - No such edge (every claimed edge is either to a checked task or unquotable) → continue to step b. - b. ACQUIRE: wt=$(.git/zero.sh acquire task_id) - - exit ≠ 0 → could not claim it (a peer owns it, or it is being rescued) → skip to the next task_id. + b. CLAIM: wt=$(.git/zero.sh claim task_id) + - exit ≠ 0 → not yours (a peer owns it, it is being rescued, or a peer already landed it) + → 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. - c. VALIDATE: $wt is non-empty and a directory, and TASK_BRANCH=`git -C "$wt" symbolic-ref --short HEAD` - starts with "@@BASE_BRANCH@@-task-". If not, skip to the next task_id. - d. RE-CHECK for a race, deterministically: `.git/zero.sh done task_id "$wt"`. - - exit 0 → a peer already LANDED it on @@BASE_BRANCH@@ (box `[x]` on base AND merged) just before - your claim → run `.git/zero.sh release task_id "$wt"` and skip to the next task_id. - - exit ≠ 0 → NOT landed (base still `[ ]`, e.g. a ticked box left on an unmerged branch by an - earlier failed merge) → you own it, continue to step e. Do NOT trust the box in `$wt/@@TODO@@`. - e. 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 g — this is per-task, not per-session). + 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). @@LOOPPROMPT@@ - f. CHECK OFF only your task_id's line in `$wt/@@TODO@@` (`[ ]`→`[x]`); touch no other line. - Then commit in `$wt` on TASK_BRANCH. - g. MERGE: .git/zero.sh merge task_id "$wt" + 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 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 `git -C "$wt" commit --amend --no-edit`, then retry `.git/zero.sh merge task_id "$wt"` ONCE. If it fails again for this cause, STOP THE LOOP IMMEDIATELY — report the offending lines, task_id, - and its worktree $wt (still on TASK_BRANCH), and ask the human to clear the unrelated checkboxes, - then merge by hand. + 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 - and its worktree $wt (still on TASK_BRANCH), and ask the human to "resolve the conflict - on that branch, then merge by hand". + no better merge option. If you cannot, MERGE FAILED: STOP THE LOOP 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. PROMPT_EOF From 1d448a064e2c03e5e5337b3b4c83ab0b0eb0ec73 Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Fri, 31 Jul 2026 00:17:14 +0200 Subject: [PATCH 07/26] feat(report): print a fleet-wide TOTAL on the exit path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every exit path (Ctrl+C break, MAX_LOOPS break) now prints this instance's execution-stats report after credit_inflight_time, then a TOTAL block summing todos, todo time and tokens across every instance of this run. The sum is a glob over the per-instance files in the git common dir — no registry, no flock, no baseline — so a crashed peer's landed work still counts. Solo runs print no TOTAL. TEST.md Scenario I covers the sums, dedupe, solo suppression and degradation. Closes ISSUE-017 --- TEST.md | 79 ++++++++++++++++++++++++++++++++++++++++++++++++++- claudezero.sh | 59 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 134 insertions(+), 4 deletions(-) diff --git a/TEST.md b/TEST.md index 42fb63e..a413acf 100644 --- a/TEST.md +++ b/TEST.md @@ -167,7 +167,8 @@ 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 : $(grep -h -oE 'Todos:.*· [0-9]+ completed' "$T"/log_AGENT_A.txt | tail -1)" +echo "todos counted : $(awk '/❄ TOTAL/{exit} /Todos:.*· [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-')" ``` @@ -181,6 +182,8 @@ echo "zero.sh wrote counts: $(ls "$T"/repo/.git 2>/dev/null | grep -c '^todos-do two are the **env-hop proof**: `zero.sh` only writes `todos-seconds--` and `todos-done--` when it received `CLAUDEZERO_INSTANCE` from claude's env. (An instance that merged 0 tasks writes no file, so the count can be < 3; ≥ 1 is the gate.) +- **Fleet PASS** — `TOTAL blocks = 1 1 1`: each agent ended its run with one fleet TOTAL block + (the `todos counted` reading is taken from before it, so it stays the per-instance figure). --- @@ -669,6 +672,80 @@ echo "H2 a1 vs b2 : $([ "$(dojo_student a1b2c3d4)" != "$(dojo_student b2b2c --- +## Scenario I — fleet TOTAL on the exit path `[$TESTROOT/I]` (stub claude, deterministic) + +The exit path (Ctrl+C or `MAX_LOOPS`) prints this instance's report and then a fleet-wide +TOTAL summed from every peer's per-instance files for this base. No real claude and no +parallelism: the stub fabricates two peer instances **from inside the run**, so they land +after startup GC — `PEER1` with a live marker, `DEADPEER` with files but no marker (a crashed +peer's merged todos still belong in the total). The fabricated transcript repeats one +`requestId` on three lines and carries the nested `iterations[]`/`cache_creation` copies, so +the token sum also proves dedupe and no-double-count. + +### Setup +```bash +TI="$TESTROOT/I"; mkdir -p "$TI/repo" "$TI/bin" +cat > "$TI/bin/claude" <<'EOF' +#!/usr/bin/env bash +GC="$(cd "$(git rev-parse --git-common-dir)" && pwd)"; mkdir -p "$GC/instance" +# PEER1: live marker (this stub's own pid) → survives any later GC. DEADPEER: files only. +printf '%s\n%s\n' "$$" "$(ps -o lstart= -p $$ | awk '{$1=$1;print}')" > "$GC/instance/PEER1" +printf '100\n' > "$GC/todos-seconds-main-PEER1"; printf '2\n' > "$GC/todos-done-main-PEER1" +printf '50\n' > "$GC/todos-seconds-main-DEADPEER"; printf '1\n' > "$GC/todos-done-main-DEADPEER" +tr="$GC/tx-PEER1.jsonl"; : > "$tr" +u='"input_tokens":10,"output_tokens":20,"cache_creation_input_tokens":248,"cache_creation":{"ephemeral_5m_input_tokens":148,"ephemeral_1h_input_tokens":100},"cache_read_input_tokens":1000,"iterations":[{"input_tokens":10,"output_tokens":20,"cache_creation_input_tokens":248,"cache_read_input_tokens":1000}]' +for i in 1 2 3; do printf '{"requestId":"req_AAA","message":{"usage":{%s}}}\n' "$u" >> "$tr"; done +printf '%s\n' "$tr" > "$GC/transcripts-main-PEER1" +exit 0 +EOF +chmod +x "$TI/bin/claude" +cd "$TI/repo" +git init -q -b main; git config user.email t@t.t; git config user.name test +printf -- '- [ ] I1 x\n' > todo.md; git add -A; git commit -qm init +``` + +### I1 — TOTAL equals the sum of the per-instance files +```bash +cd "$TI/repo" +PATH="$TI/bin:$PATH" timeout 40 env CLAUDEZERO_MAX_LOOPS=1 bash "$SCRIPT" todo.md -t x > "$TI/run.log" 2>&1 +echo "I1 exit : $? (want 0 — the report never fails the exit path)" +sed -n '/❄ TOTAL/,$p' "$TI/run.log" +echo "I1 instances : $(grep -o '❄ TOTAL ([0-9]*' "$TI/run.log" | tr -dc 0-9) (want 2 — PEER1 + DEADPEER, dead peer counted)" +echo "I1 todos sum : $(sed -n '/❄ TOTAL/,$p' "$TI/run.log" | grep -oE '2m30s.*3 completed' | head -1) (want 2m30s · 3 completed = 100+50s, 2+1)" +echo "I1 token sum : $(sed -n '/❄ TOTAL/,$p' "$TI/run.log" | grep -oE 'Tokens: [^ ]+ Total') (want 1.2k = 10+20+248+1000, counted ONCE)" +echo "I1 categories : $(sed -n '/❄ TOTAL/,$p' "$TI/run.log" | grep -oE 'in 10 · out 20 · cache write 248 · cache read 1.0k') (want that line)" +echo "I1 no registry : $(ls "$TI/repo/.git" | grep -cE '^(fleet|total)-') (want 0 — glob, no aggregate file)" +``` +- **I1 PASS** — `exit = 0`, `instances = 2`, the todo sum is `2m30s · 3 completed`, the token + total is `1.2k` with categories `in 10 · out 20 · cache write 248 · cache read 1.0k` + (the `requestId` appeared on three lines and the nested `iterations[]`/`ephemeral_*` copies + were ignored), and `no registry = 0`. + +### I2 — solo run prints no TOTAL; unreadable peer files degrade, never lie +```bash +cd "$TI/repo" +printf '#!/usr/bin/env bash\nexit 0\n' > "$TI/bin/claude" # no peers fabricated +PATH="$TI/bin:$PATH" timeout 40 env CLAUDEZERO_MAX_LOOPS=1 bash "$SCRIPT" todo.md -t x > "$TI/solo.log" 2>&1 || true +echo "I2 solo TOTAL : $(grep -c '❄ TOTAL' "$TI/solo.log") (want 0 — one id, the total would restate the block above)" +cat > "$TI/bin/claude" <<'EOF' +#!/usr/bin/env bash +GC="$(cd "$(git rev-parse --git-common-dir)" && pwd)" +printf 'not-a-number\n' > "$GC/todos-seconds-main-GARB"; printf 'xx\n' > "$GC/todos-done-main-GARB" +printf '/nonexistent/transcript.jsonl\n' > "$GC/transcripts-main-GONE" +exit 0 +EOF +chmod +x "$TI/bin/claude" +PATH="$TI/bin:$PATH" timeout 40 env CLAUDEZERO_MAX_LOOPS=1 bash "$SCRIPT" todo.md -t x > "$TI/degrade.log" 2>&1 +echo "I2 degrade exit : $? (want 0)" +echo "I2 degrade TOTAL : $(sed -n '/❄ TOTAL/,$p' "$TI/degrade.log" | grep -cE '0s · 0 completed|Tokens: n/a') (want 2 — zeroed todos row + n/a tokens)" +echo "I2 loop mode : $(PATH="$TI/bin:$PATH" timeout 40 env CLAUDEZERO_MAX_LOOPS=1 bash "$SCRIPT" -l hi 2>&1 | sed -n '/❄ TOTAL/,$p' | grep -c 'Todos:') (want 0 — token rows only)" +``` +- **I2 PASS** — `solo TOTAL = 0`, `degrade exit = 0` with `degrade TOTAL = 2` (a garbled counter + contributes 0 and a missing transcript degrades to `Tokens: n/a`), and `loop mode = 0` todo + rows in the TOTAL block. + +--- + ## Run all in parallel (optional) After Section 0 and each Setup, launch the Run blocks together: put A's and C's run diff --git a/claudezero.sh b/claudezero.sh index eb0ac77..bba5160 100755 --- a/claudezero.sh +++ b/claudezero.sh @@ -146,7 +146,12 @@ while true; do done # single closer — every exit path (Ctrl+C or MAX_LOOPS) lands here, so dojo_proud lives in one place. +# The exit report goes here too: the loop's own report prints before the between-runs sleep, so a +# Ctrl+C in that gap used to end the run on figures stale by one claude run. After credit_inflight_time +# so a task still in flight is folded in before the files are read. credit_inflight_time +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 } @@ -320,12 +325,13 @@ fmt_tok() { # first match per line is the parent field — usage.iterations[] repeats all four names one level # down, and usage.cache_creation carries the ephemeral_5m/1h leaves that already sum into # cache_creation_input_tokens. Take the parent only, never the leaves or the nested copy. +# $1 = transcript-list file, default this instance's — the fleet total passes each peer's in turn. read_tokens_total() { - [ -n "${TRANSCRIPTS_FILE:-}" ] && [ -f "$TRANSCRIPTS_FILE" ] || return 0 - local p + local tf=${1:-${TRANSCRIPTS_FILE:-}} p + [ -n "$tf" ] && [ -f "$tf" ] || return 0 while IFS= read -r p; do if [ -f "$p" ]; then cat "$p"; fi - done < "$TRANSCRIPTS_FILE" | awk ' + done < "$tf" | awk ' function num(key, s) { if (!match($0, "\"" key "\":[0-9]+")) return 0 s = substr($0, RSTART, RLENGTH); sub(/.*:/, "", s); return s + 0 @@ -394,6 +400,53 @@ print_report() { print_tokens } +# fleet-wide TOTAL for this base, printed once on the exit path beneath this instance's report. +# The sum is a GLOB, not a registry: every figure is already one file per instance in the git common +# dir, so a shared aggregate would only be a second copy that can disagree with the first. Read +# without flock — all three writers publish with temp-file + mv, so a reader sees the old file or +# the new one, never a torn line. No baseline: per-instance files are created fresh under a new +# INSTANCE_ID each launch and dead runs' files are GC'd at startup, so what is on disk IS this run; +# subtracting a startup snapshot would under-report peers that started earlier. A crashed peer's +# files are summed too — its merged todos did land. Solo run prints nothing: with one id the total +# just restates the block above it. `ClaudeZero run loop:` is omitted — instances' wall times +# overlap, so their sum is not a duration anything took. +print_fleet_total() { + local gc slug pre f id ids="" n=0 secs=0 done_n=0 t any=0 ti=0 to=0 tcc=0 tcr=0 tt=0 + gc="$(cd "$(git rev-parse --git-common-dir)" 2>/dev/null && pwd)" || return 0 + [ -n "$gc" ] || return 0 + slug="${BASE_BRANCH//\//-}" + for pre in todos-seconds todos-done transcripts; do # union: an instance that merged nothing + for f in "$gc/$pre-$slug-"*; do # writes no todos-done file, but has tokens + [ -e "$f" ] || continue + case "$f" in *.lock|*.tmp) continue;; esac + id="${f##*/"$pre"-"$slug"-}" + case " $ids " in *" $id "*) continue;; esac + ids="$ids $id"; n=$((n+1)) + done + done + [ "$n" -gt 1 ] || return 0 + for id in $ids; do + secs=$(( secs + $(read_counter "$gc/todos-seconds-$slug-$id") )) + done_n=$(( done_n + $(read_counter "$gc/todos-done-$slug-$id") )) + t="$(read_tokens_total "$gc/transcripts-$slug-$id" || true)" + # shellcheck disable=SC2086 # deliberate split: awk emits five space-separated integers + set -- $t + if [ "$#" -eq 5 ]; then any=1; ti=$((ti+$1)); to=$((to+$2)); tcc=$((tcc+$3)); tcr=$((tcr+$4)); tt=$((tt+$5)); fi + done + printf '\n-----------------------------------------------\n' + printf '❄ TOTAL (%s instances)\n' "$n" + if [ "${MODE:-}" = zero ]; then + printf ' %-20s %s · %s completed\n' 'Todos:' "$(fmt_dur "$secs")" "$done_n" + fi + if [ "$any" = 1 ]; then + printf '\n Tokens: %s Total\n' "$(fmt_tok "$tt")" + printf ' in %s · out %s · cache write %s · cache read %s\n' \ + "$(fmt_tok "$ti")" "$(fmt_tok "$to")" "$(fmt_tok "$tcc")" "$(fmt_tok "$tcr")" + else + printf '\n Tokens: n/a\n' + fi +} + # credit orphaned in-flight tasks before an exit-path report: zero.sh folds worktrees whose owner # claude exited (mid-task work never merged nor stolen) into the todos aggregate. No-op in loop # mode / before zero.sh exists. Never fails the caller. From ae8e3a57f8e39b81c6c3f96c22211ee1428c65e3 Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Fri, 31 Jul 2026 09:36:58 +0200 Subject: [PATCH 08/26] feat(session): give each instance a short unique nickname MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Names the claude session `() · `: a 3-5 letter word no peer running against this repo currently holds, so an operator can say "kill kit" instead of reading eight hex chars off a terminal title. The nickname lives on line 3 of the existing $INSTANCE_DIR/ liveness marker, so it is released on exit for free and a crashed instance's name comes back at the next launch's GC — no registry file, no reservation TTL. The pick runs once per launch, after the startup GC and under instance.lock, so two instances launched together cannot draw the same word. Past fifteen live instances names take an ascending suffix ("bob 1", then "bob 2"). An unreadable registry or a failed lock costs uniqueness, never the launch. ClaudeZero's own output is unchanged — reports still key on the id. Co-Authored-By: Claude Opus 5 (1M context) --- TEST.md | 79 +++++++++++++++++++++++++++++++++++++++++++++------ claudezero.sh | 40 +++++++++++++++++++++++--- 2 files changed, 106 insertions(+), 13 deletions(-) diff --git a/TEST.md b/TEST.md index 0da12ed..bde9dcf 100644 --- a/TEST.md +++ b/TEST.md @@ -605,10 +605,12 @@ not a tty) — exactly the operator's situation. ## Scenario H — claude session display name `[$TESTROOT/H]` (stub claude, deterministic) -Each instance names its claude session `() ` via `--name`, so -parallel terminals are told apart without reading hex. The name must reach claude as ONE argv -element, and must be the SAME on every context restart (derived from the id, never from chance). -The stub claude echoes its argv, so no real claude is needed. +Each instance names its claude session `() · ` via +`--name`, so parallel terminals are told apart without reading hex. The name must reach claude as +ONE argv element, and must be the SAME on every context restart (the id and the activity are +derived from the id; the nickname is picked once at launch and stored on the liveness marker). +The nickname must differ from every peer live in this repo. The stub claude echoes its argv, so no +real claude is needed. ### Setup ```bash @@ -636,6 +638,13 @@ detachH() { python3 -c 'import os,sys; os.setsid(); os.execvp(sys.argv[1], sys.argv[1:])' "$@" else echo "H SKIP — neither setsid nor python3 available to drop the controlling terminal" >&2; fi } +# the fifteen nicknames, verbatim (claudezero.sh pick_nickname) +NICKS=(ash bob cleo dax elk finn gus hana ivo jun kit lux moss nix opal) +GCH="$(cd "$(git rev-parse --git-common-dir)" && pwd)" +# a fabricated LIVE peer holding nickname $1 (pid/start of this shell, so liveness keeps it) +mkpeer() { mkdir -p "$GCH/instance" + printf '%s\n%s\n%s\n' "$$" "$(ps -o lstart= -p $$ | awk '{$1=$1;print}')" "$1" > "$GCH/instance/fake-$2"; } +nick_of() { sed -E 's/.*\) (.*) · .*/\1/'; } # nickname out of a `[--name] [...]` line ``` ### H1 — name construction, unsplit argv, restart stability @@ -644,14 +653,19 @@ cd "$TH/repo" detachH env PATH="$TH/bin:$PATH" CLAUDEZERO_MAX_LOOPS=3 \ timeout 90 bash "$SCRIPT" todo.md -t x > "$TH/run.log" 2>&1 || true ID=$(grep -m1 -oE 'instance [0-9A-Za-z]+' "$TH/run.log" | awk '{print $2}') -WANT="($ID) ${ACT[$(( 16#${ID:0:2} % 10 ))]}" +NICK=$(grep -m1 -oE '\[--name\] \[[^]]*\]' "$TH/run.log" | nick_of) +WANT="($ID) $NICK · ${ACT[$(( 16#${ID:0:2} % 10 ))]}" echo "H1 want name : $WANT" echo "H1 launches : $(grep -c '^ARGV:' "$TH/run.log") (want 3)" echo "H1 named+unsplit : $(grep -c -F -- "[--name] [$WANT]" "$TH/run.log") (want 3)" +echo "H1 nick in list : $(printf '%s\n' "${NICKS[@]}" | grep -qxF "$NICK" && echo yes || echo NO)" +echo "H1 name released : $(ls "$GCH/instance" 2>/dev/null | wc -l | tr -d ' ') (want 0 — marker gone on exit)" ``` - **H1 PASS** — `launches = 3` and `named+unsplit = 3`: `--name` carries the parenthesised, space-containing name as a single argv element, the activity is the one the id selects by - `16# % 10`, and all three restarts used the same name. + `16# % 10`, the nickname sits between id and activity, and all three restarts + used the same name. `nick in list = yes` (one of the fifteen, lowercase) and + `name released = 0` — the exiting instance unlinked its marker, so its nickname is free again. ### H2 — loop mode named too; decimal (`$$`-shaped) id picks an activity, not an error ```bash @@ -666,9 +680,56 @@ echo "H2 decimal id : $(dojo_student 48584); $(dojo_student 90210) (two acti echo "H2 deterministic : $([ "$(dojo_student a1b2c3d4)" = "$(dojo_student a1ffffff)" ] && echo yes || echo NO)" echo "H2 a1 vs b2 : $([ "$(dojo_student a1b2c3d4)" != "$(dojo_student b2b2c3d4)" ] && echo differ || echo same)" ``` -- **H2 PASS** — the loop-mode line shows `[--name] [() ]`, both decimal ids render - an activity with no arithmetic error, `deterministic = yes` (only the first two chars select), - and `a1 vs b2 = differ` (`16#a1 % 10 = 1`, `16#b2 % 10 = 8`). +- **H2 PASS** — the loop-mode line shows `[--name] [() · ]`, both decimal + ids render an activity with no arithmetic error, `deterministic = yes` (only the first two chars + select), and `a1 vs b2 = differ` (`16#a1 % 10 = 1`, `16#b2 % 10 = 8`). + +### H3 — two instances launched at the same moment draw different nicknames +```bash +cd "$TH/repo"; rm -rf "$GCH/instance" +detachH env PATH="$TH/bin:$PATH" CLAUDEZERO_MAX_LOOPS=1 timeout 90 bash "$SCRIPT" todo.md -t x > "$TH/a.log" 2>&1 & +detachH env PATH="$TH/bin:$PATH" CLAUDEZERO_MAX_LOOPS=1 timeout 90 bash "$SCRIPT" todo.md -t x > "$TH/b.log" 2>&1 & +wait +NA=$(grep -m1 -oE '\[--name\] \[[^]]*\]' "$TH/a.log" | nick_of) +NB=$(grep -m1 -oE '\[--name\] \[[^]]*\]' "$TH/b.log" | nick_of) +echo "H3 nicks : '$NA' '$NB'" +echo "H3 differ : $([ -n "$NA" ] && [ "$NA" != "$NB" ] && echo yes || echo NO)" +``` +- **H3 PASS** — `differ = yes`: the scan-then-claim ran under `instance.lock`, so the second + instance saw the first's line 3 and picked another word. + +### H4 — the free set is what the live markers leave; suffix past fifteen; a crashed peer frees its name +```bash +cd "$TH/repo" +rm -rf "$GCH/instance"; i=0; for n in "${NICKS[@]}"; do [ "$n" = moss ] || mkpeer "$n" $((i++)); done +detachH env PATH="$TH/bin:$PATH" CLAUDEZERO_MAX_LOOPS=1 timeout 90 bash "$SCRIPT" todo.md -t x > "$TH/c.log" 2>&1 || true +echo "H4 fourteen held : $(grep -m1 -oE '\[--name\] \[[^]]*\]' "$TH/c.log" | nick_of) (want moss)" +rm -rf "$GCH/instance"; i=0; for n in "${NICKS[@]}"; do mkpeer "$n" $((i++)); done +detachH env PATH="$TH/bin:$PATH" CLAUDEZERO_MAX_LOOPS=1 timeout 90 bash "$SCRIPT" todo.md -t x > "$TH/d.log" 2>&1 || true +echo "H4 all fifteen : $(grep -m1 -oE '\[--name\] \[[^]]*\]' "$TH/d.log" | nick_of) (want ' 1')" +rm -rf "$GCH/instance"; i=0; for n in "${NICKS[@]}"; do mkpeer "$n" $((i++)); mkpeer "$n 1" $((i++)); done +detachH env PATH="$TH/bin:$PATH" CLAUDEZERO_MAX_LOOPS=1 timeout 90 bash "$SCRIPT" todo.md -t x > "$TH/e.log" 2>&1 || true +echo "H4 both levels : $(grep -m1 -oE '\[--name\] \[[^]]*\]' "$TH/e.log" | nick_of) (want ' 2')" +# crashed peer: a marker whose pid is dead still names moss — the startup GC unlinks it first +rm -rf "$GCH/instance"; mkdir -p "$GCH/instance"; printf '999999\ndead\nmoss\n' > "$GCH/instance/crashed" +i=0; for n in "${NICKS[@]}"; do [ "$n" = moss ] || mkpeer "$n" $((i++)); done +detachH env PATH="$TH/bin:$PATH" CLAUDEZERO_MAX_LOOPS=1 timeout 90 bash "$SCRIPT" todo.md -t x > "$TH/f.log" 2>&1 || true +echo "H4 crashed freed : $(grep -m1 -oE '\[--name\] \[[^]]*\]' "$TH/f.log" | nick_of) (want moss)" +rm -rf "$GCH/instance" +``` +- **H4 PASS** — `fourteen held = moss` (the one free word), `all fifteen` is a ` 1` form and + `both levels` a ` 2` form (ascending suffix, random within the level), and + `crashed freed = moss` — the dead peer's marker was GC'd before the pick, so its name came back. + +### H5 — degradation: an unreadable registry still names the session +```bash +cd "$TH/repo" +eval "$(sed -n '/^pick_nickname()/,/^}/p' "$SCRIPT")" +( set -euo pipefail; INSTANCE_DIR="$TH/gone/instance"; INSTANCE_ID=zz + echo "H5 fallback name : '$(pick_nickname)' rc=$?" ) +``` +- **H5 PASS** — a name from the full fifteen is printed and `rc=0`: a missing registry costs + uniqueness, never the launch. (`$TH/gone` is never created — nothing is written.) --- diff --git a/claudezero.sh b/claudezero.sh index 9c6f033..a3761c6 100755 --- a/claudezero.sh +++ b/claudezero.sh @@ -166,10 +166,6 @@ BASE_BRANCH="$(git rev-parse --abbrev-ref HEAD)" # compare instances after a run and size the fleet next time. Exported into claude's env below, # inherited by its Bash-tool children; zero.sh reads it (CLAUDEZERO_INSTANCE) to route credit. INSTANCE_ID="$(uuidgen 2>/dev/null | tr -d - | head -c8)"; [ -n "$INSTANCE_ID" ] || INSTANCE_ID="$$" -# claude's display name (prompt box, /resume picker, terminal title) — the same id the report heads -# its stats with, plus a dojo-student activity so parallel terminals are told apart without reading -# hex. Derived from the id, never from chance, so every restart re-launches under the same name. -SESSION_NAME="($INSTANCE_ID) $(dojo_student "$INSTANCE_ID")" # -t/--taskprompt sets the zero task prompt; -l/--loopprompt runs claude on a # literal prompt (skips zero mode). They are mutually exclusive. @@ -250,6 +246,12 @@ mkdir -p "$INSTANCE_DIR"; printf '%s\n%s\n' "$$" "$(proc_start "$$")" > "$INSTAN trap 'rm -f "$INSTANCE_DIR/$INSTANCE_ID" 2>/dev/null' EXIT cleanup_orphan_time_files +# claude's display name (prompt box, /resume picker, terminal title) — the same id the report heads +# its stats with, a nickname short enough to say out loud, and a dojo-student activity, so parallel +# terminals are told apart without reading hex. The id and activity are derived from the id, never +# from chance; the nickname is picked once here, so every restart re-launches under the same name. +SESSION_NAME="($INSTANCE_ID) $(pick_nickname) · $(dojo_student "$INSTANCE_ID")" + STOP_HOOK="$GITDIR_ABS/compact-exit-hook.sh" cat >"$STOP_HOOK" <<'HOOK_EOF' #!/usr/bin/env bash @@ -545,6 +547,36 @@ cleanup_orphan_time_files() { done } +# a short, speakable handle for this instance — one of the fifteen below, held by no peer running +# against this repo right now, so "kill kit" beats reading eight hex chars aloud. Line 3 of a +# marker is its holder's nickname (line 1/2 untouched, so the GC above still reads them), which +# makes the liveness registry the name registry too — no second list to disagree with the first. +# Call ONCE, AFTER cleanup_orphan_time_files, or dead peers' markers still hold names hostage. +# scan-then-claim runs under one lock so two instances launched together cannot draw the same word. +# Past fifteen live instances the names take an ascending suffix ("bob 1", then "bob 2", …). +# Degrade, never lie: an unwritable lock or registry costs uniqueness, never the launch. +pick_nickname() { + local names=(ash bob cleo dax elk finn gus hana ivo jun kit lux moss nix opal) + local m taken="" free=() cand n suffix=0 + { exec 6>"$INSTANCE_DIR.lock" && flock 6; } 2>/dev/null || true + for m in "$INSTANCE_DIR"/*; do + [ -e "$m" ] || continue + taken+="$(sed -n 3p "$m" 2>/dev/null)"$'\n' # no line 3 = a peer not yet at the lock: takes nothing + done + while [ ${#free[@]} -eq 0 ]; do + for n in "${names[@]}"; do + [ "$suffix" -eq 0 ] || n="$n $suffix" + case $'\n'"$taken" in *$'\n'"$n"$'\n'*) continue;; esac + free+=("$n") + done + suffix=$((suffix+1)) + done + cand="${free[RANDOM % ${#free[@]}]}" # random among the free, never derived from the id + printf '%s\n' "$cand" 2>/dev/null >> "$INSTANCE_DIR/$INSTANCE_ID" || true + exec 6>&- # close fd, release lock + printf '%s' "$cand" +} + # write .git/zero.sh (the per-task acquire/release/merge helper the zero prompt calls) with # BASE_BRANCH baked in, then echo the parallel zero-mode prompt on stdout. $1 = TODO path (repo-relative). build_zero_prompt() { From 96beb66793446cb127284b98bffd90db21a3d7c7 Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Fri, 31 Jul 2026 10:12:20 +0200 Subject: [PATCH 09/26] fix(report): print the fleet TOTAL block on solo runs, singular heading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit print_fleet_total suppressed the block whenever the base-slug globs yielded a single instance id, on the grounds that a one-instance total only restates the per-instance block above it. That holds for the figures but not for the heading, which names the instance count — the one figure the per-instance block never carries. Suppressing it made a correct solo sum indistinguishable from a fleet sum that matched no files because the base slug was not what the operator thought. Lower the guard to -ge 1 and pluralize the heading from the same printf, so the block reads identically at every fleet size. n=0 stays silent: absence now has one meaning. TEST.md J2 fabricates this run's own transcript so exactly one id lands on disk, asserts the singular heading and matching figures, and adds a zero-ids case proving the guard is not -ge 0. ISSUE-017's superseded solo-suppression spans point at this bug. --- TEST.md | 23 +++++++++++++++++++---- claudezero.sh | 16 ++++++++++------ 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/TEST.md b/TEST.md index bde9dcf..ce5d1b1 100644 --- a/TEST.md +++ b/TEST.md @@ -862,12 +862,25 @@ echo "J1 no registry : $(ls "$TJ/repo/.git" | grep -cE '^(fleet|total)-') (wa (the `requestId` appeared on three lines and the nested `iterations[]`/`ephemeral_*` copies were ignored), and `no registry = 0`. -### J2 — solo run prints no TOTAL; unreadable peer files degrade, never lie +### J2 — solo run prints a singular TOTAL; unreadable peer files degrade, never lie ```bash cd "$TJ/repo" -printf '#!/usr/bin/env bash\nexit 0\n' > "$TJ/bin/claude" # no peers fabricated +cat > "$TJ/bin/claude" <<'EOF' # no peers: this run's own id only +#!/usr/bin/env bash +GC="$(cd "$(git rev-parse --git-common-dir)" && pwd)"; tr="$GC/tx-solo.jsonl" +printf '{"requestId":"req_S","message":{"usage":{"input_tokens":70,"output_tokens":30,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}\n' > "$tr" +printf '%s\n' "$tr" > "$CLAUDEZERO_TRANSCRIPTS" # the hook's job, done by hand: one id on disk +exit 0 +EOF +chmod +x "$TJ/bin/claude" PATH="$TJ/bin:$PATH" timeout 40 env CLAUDEZERO_MAX_LOOPS=1 bash "$SCRIPT" todo.md -t x > "$TJ/solo.log" 2>&1 || true -echo "J2 solo TOTAL : $(grep -c '❄ TOTAL' "$TJ/solo.log") (want 0 — one id, the total would restate the block above)" +echo "J2 solo TOTAL : $(grep -c '❄ TOTAL' "$TJ/solo.log") (want 1 — one id still gets the block; the heading names the count)" +echo "J2 solo heading : $(grep -o '❄ TOTAL (1 instance)' "$TJ/solo.log") (want '❄ TOTAL (1 instance)' — singular)" +echo "J2 solo figures : $(sed -n '/❄ TOTAL/,$p' "$TJ/solo.log" | grep -cE '0s · 0 completed|Tokens: 100 Total|in 70 · out 30 · cache write 0 · cache read 0') (want 3 — each equals the per-instance block above)" +echo "J2 solo run loop : $(sed -n '/❄ TOTAL/,$p' "$TJ/solo.log" | grep -c 'ClaudeZero run loop:') (want 0 — summed wall times are not a duration)" +printf '#!/usr/bin/env bash\nexit 0\n' > "$TJ/bin/claude" # writes nothing: zero ids on disk +PATH="$TJ/bin:$PATH" timeout 40 env CLAUDEZERO_MAX_LOOPS=1 bash "$SCRIPT" todo.md -t x > "$TJ/none.log" 2>&1 || true +echo "J2 no-files TOTAL: $(grep -c '❄ TOTAL' "$TJ/none.log") (want 0 — n=0 stays silent, the guard is -ge 1 not -ge 0)" cat > "$TJ/bin/claude" <<'EOF' #!/usr/bin/env bash GC="$(cd "$(git rev-parse --git-common-dir)" && pwd)" @@ -881,7 +894,9 @@ echo "J2 degrade exit : $? (want 0)" echo "J2 degrade TOTAL : $(sed -n '/❄ TOTAL/,$p' "$TJ/degrade.log" | grep -cE '0s · 0 completed|Tokens: n/a') (want 2 — zeroed todos row + n/a tokens)" echo "J2 loop mode : $(PATH="$TJ/bin:$PATH" timeout 40 env CLAUDEZERO_MAX_LOOPS=1 bash "$SCRIPT" -l hi 2>&1 | sed -n '/❄ TOTAL/,$p' | grep -c 'Todos:') (want 0 — token rows only)" ``` -- **J2 PASS** — `solo TOTAL = 0`, `degrade exit = 0` with `degrade TOTAL = 2` (a garbled counter +- **J2 PASS** — `solo TOTAL = 1` with the singular heading, `solo figures = 3`, + `solo run loop = 0` and `no-files TOTAL = 0`, + `degrade exit = 0` with `degrade TOTAL = 2` (a garbled counter contributes 0 and a missing transcript degrades to `Tokens: n/a`), and `loop mode = 0` todo rows in the TOTAL block. diff --git a/claudezero.sh b/claudezero.sh index a3761c6..8086cc4 100755 --- a/claudezero.sh +++ b/claudezero.sh @@ -409,11 +409,14 @@ print_report() { # the new one, never a torn line. No baseline: per-instance files are created fresh under a new # INSTANCE_ID each launch and dead runs' files are GC'd at startup, so what is on disk IS this run; # subtracting a startup snapshot would under-report peers that started earlier. A crashed peer's -# files are summed too — its merged todos did land. Solo run prints nothing: with one id the total -# just restates the block above it. `ClaudeZero run loop:` is omitted — instances' wall times -# overlap, so their sum is not a duration anything took. +# files are summed too — its merged todos did land. A solo run prints the block as well: its figures +# restate the block above, but the heading carries the instance count, which nothing else prints and +# which is worth most when it reads 1 — that is the case an absent block cannot be told apart from a +# sum that matched no files (BUG-022). Zero ids stays silent, so absence keeps one meaning. +# `ClaudeZero run loop:` is omitted — instances' wall times overlap, so their sum is not a duration +# anything took. print_fleet_total() { - local gc slug pre f id ids="" n=0 secs=0 done_n=0 t any=0 ti=0 to=0 tcc=0 tcr=0 tt=0 + local gc slug pre f id ids="" n=0 secs=0 done_n=0 t any=0 ti=0 to=0 tcc=0 tcr=0 tt=0 plural=s gc="$(cd "$(git rev-parse --git-common-dir)" 2>/dev/null && pwd)" || return 0 [ -n "$gc" ] || return 0 slug="${BASE_BRANCH//\//-}" @@ -426,7 +429,8 @@ print_fleet_total() { ids="$ids $id"; n=$((n+1)) done done - [ "$n" -gt 1 ] || return 0 + [ "$n" -ge 1 ] || return 0 # n=0: no files for this slug — stay silent, absence means only that + if [ "$n" -eq 1 ]; then plural=""; fi for id in $ids; do secs=$(( secs + $(read_counter "$gc/todos-seconds-$slug-$id") )) done_n=$(( done_n + $(read_counter "$gc/todos-done-$slug-$id") )) @@ -436,7 +440,7 @@ print_fleet_total() { if [ "$#" -eq 5 ]; then any=1; ti=$((ti+$1)); to=$((to+$2)); tcc=$((tcc+$3)); tcr=$((tcr+$4)); tt=$((tt+$5)); fi done printf '\n-----------------------------------------------\n' - printf '❄ TOTAL (%s instances)\n' "$n" + printf '❄ TOTAL (%s instance%s)\n' "$n" "$plural" if [ "${MODE:-}" = zero ]; then printf ' %-20s %s · %s completed\n' 'Todos:' "$(fmt_dur "$secs")" "$done_n" fi From f475ea197d351afe4af9628c281dcf3646e80342 Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Fri, 31 Jul 2026 10:42:14 +0200 Subject: [PATCH 10/26] fix(dojo): reword three activities so a session name reads as a series MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Indices 5, 6, and 9 of dojo_student each claimed a single unit of work, so a name like `(2A56168E) moss · carving one checkbox into ice` asserted a ceiling the instance does not have — it keeps claiming todos until the list is empty. Reword in place at their existing indices; the modulus and the id → index mapping are untouched, so names stay restart-stable. Indices 5 and 6 also stop sharing the checkbox-carving image, so the ten slots are ten distinct labels again. TEST.md's ACT array, ISSUE-019's list and rationale, and ISSUE-021's example name follow the new strings. Co-Authored-By: Claude Opus 5 (1M context) --- TEST.md | 4 ++-- claudezero.sh | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/TEST.md b/TEST.md index ce5d1b1..1f3c0a2 100644 --- a/TEST.md +++ b/TEST.md @@ -627,9 +627,9 @@ printf -- '- [ ] H1 x\n' > todo.md; git add -A; git commit -qm init # the ten activities, verbatim (claudezero.sh dojo_student) ACT=('drilling the fork-implement-merge kata' 'hauling snow buckets uphill' \ 'claiming a track before stepping on it' 'reading the whole task before striking' \ - 'starting over on fresh snow' 'carving one checkbox into ice' 'chasing one unchecked box' \ + 'starting over on fresh snow' 'carving checkbox after checkbox into ice' 'hunting the next box on the list' \ 'practicing one clean strike per task' "leaving a peer's branch untouched" \ - 'approaching the merge gate') + 'walking back to the merge gate') # claude's own output goes to fd 4, which falls back to stdout only when there is no controlling # terminal — run detached so the stub's ARGV line lands in the capture file (see Scenario G). detachH() { diff --git a/claudezero.sh b/claudezero.sh index 8086cc4..c2e5657 100755 --- a/claudezero.sh +++ b/claudezero.sh @@ -490,11 +490,11 @@ dojo_student() { 'claiming a track before stepping on it' 'reading the whole task before striking' 'starting over on fresh snow' - 'carving one checkbox into ice' - 'chasing one unchecked box' + 'carving checkbox after checkbox into ice' + 'hunting the next box on the list' 'practicing one clean strike per task' "leaving a peer's branch untouched" - 'approaching the merge gate' + 'walking back to the merge gate' ) printf '%s' "${a[$(( 16#${1:0:2} % 10 ))]}" } From 905883a1fc6d2ed8c30a1f8f6d920d5d357cbe6a Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Fri, 31 Jul 2026 10:49:12 +0200 Subject: [PATCH 11/26] chore: stop tracking issues/ Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e4d62f4..2348d8e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ sources/ probity.config.js +issues/ From 3881bc231987dacfff81373b71795cfdb668686e Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Fri, 31 Jul 2026 11:42:38 +0200 Subject: [PATCH 12/26] chore(release): 0.0.15 VERSION and a dated CHANGELOG section for the 27 commits on this branch: fleet-wide TOTAL, per-instance tokens and todo counts, session names and nicknames, claude's TUI on fd 4, the collapsed `claim` step, and three fixes (linked-worktree launch guard, solo TOTAL, dojo wording). Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 42 ++++++++++++++++++++++++++++++++++++++++++ claudezero.sh | 2 +- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5a9dca..b639412 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,48 @@ 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.15] — 2026-07-31 + +### Added + +- Fleet-wide `TOTAL` block on every exit path, summing todos, todo time and + tokens across every instance of the run. A crashed peer's landed work still + counts. +- Per-instance token consumption in the execution-stats report: a headline total + plus the input / output / cache-creation / cache-read breakdown. No dollar + figure — there is no first-party rate source, and a hardcoded table would + print confidently wrong money after any model launch. +- Per-instance count of todos zeroed, credited at the same point as the + ownership time so the two always agree. +- Each `claude` session is named `() · `, visible + in the prompt box, `/resume` picker and terminal title. The nickname is a + short word no live peer holds, so you can say "kill kit" instead of reading + eight hex characters off a terminal title. Names survive context restarts. + +### Changed + +- `claude`'s TUI goes to fd 4, so `claudezero.sh 2>&1 | tee run.log` captures + only ClaudeZero's own output instead of every TUI redraw. Falls back to + stdout when there is no redirect or no controlling terminal. `-h` documents + the Ctrl+C-safe pipe form. +- The zero prompt's acquire / validate / re-check steps collapse into one + `claim` call, so a failed validation no longer leaves a claim behind until a + peer steals it. +- The report heading is "execution stats", not "execution time" — a token block + is not a duration. The aggregate Claude-loops timing is gone. + +### Fixed + +- Launching inside a leftover task worktree is refused. It previously passed the + root guard and took a peer's claim branch as the base, letting two instances + claim the same todo and landing merges in the peer's in-flight branch + (BUG-014). +- The `TOTAL` block prints on solo runs, with a singular heading. Suppressing it + made a correct one-instance total indistinguishable from a fleet total that + matched no files because the base slug was not what you thought. +- Three dojo activities no longer claim a single unit of work, which read as a + ceiling the instance does not have. + ## [0.0.14] — 2026-07-26 First public release. Base version — prior `0.0.x` iterations were pre-public diff --git a/claudezero.sh b/claudezero.sh index c2e5657..4f49e8f 100755 --- a/claudezero.sh +++ b/claudezero.sh @@ -13,7 +13,7 @@ # Run -h for usage. set -euo pipefail -VERSION="0.0.14" +VERSION="0.0.15" 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) From 26017d7cb2c2ffb117104d71e89d6eb5c96ef8d9 Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Fri, 31 Jul 2026 11:42:52 +0200 Subject: [PATCH 13/26] ci(tap): bump the Homebrew tap on a pushed v* tag, not on a release The tap bump fired on `release: published`, so cutting a version needed a GitHub Release in addition to the tag. Homebrew only needs the tarball at `archive/refs/tags/vX.Y.Z.tar.gz`, which exists as soon as the tag does, and a Release adds nothing the CHANGELOG does not already carry. A tag push has no release payload, so name the tag and commit explicitly via `github.ref_name` and `github.sha`. Not automated: creating the Release from a workflow. A Release created with GITHUB_TOKEN does not trigger other workflows, so a chained tap bump would silently never fire. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/bump-tap.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/bump-tap.yml b/.github/workflows/bump-tap.yml index 202b65b..e8a5061 100644 --- a/.github/workflows/bump-tap.yml +++ b/.github/workflows/bump-tap.yml @@ -1,10 +1,10 @@ name: Bump Homebrew tap -# On a published release, bump url + sha256 in the tap formula. +# On a pushed v* tag, bump url + sha256 in the tap formula. # Wraps `brew bump-formula-pr`. Does not build bottles or touch this repo. on: - release: - types: [published] + push: + tags: ['v*'] jobs: bump: @@ -16,4 +16,7 @@ jobs: token: ${{ secrets.TAP_TOKEN }} tap: IvanRublev/homebrew-tap formula: claudezero + # No release payload on a tag push — name the tag and commit explicitly. + tag: ${{ github.ref_name }} + revision: ${{ github.sha }} # Opens a PR on the tap by default. Add `push: true` to commit direct. From a4aef8cc6ee7ca09b159975287c297ce8f517c48 Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Fri, 31 Jul 2026 11:58:54 +0200 Subject: [PATCH 14/26] docs(readme): reword two lines "permission auto mode" reads as a mode named "permission auto"; the flag's own wording is auto permission mode. "zero todos faster" repeats the tool's own jargon where plain "work out todos" carries it. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2c8c649..33de753 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Spawn many instances to parallelize. License: MIT

-Runs [`claude`](https://claude.com/product/claude-code) on a predefined prompt in a loop on the given todo list file. Makes it complete, commit, and check off each todo. The session stays interactive, so you can add prompts and make choices as it runs. Launches `claude` in permission auto mode by default, restarts it on a fresh context before rot sets in. +Runs [`claude`](https://claude.com/product/claude-code) on a predefined prompt in a loop on the given todo list file. Makes it complete, commit, and check off each todo. The session stays interactive, so you can add prompts and make choices as it runs. Launches `claude` in auto permission mode by default, restarts it on a fresh context before rot sets in. ## Contents @@ -45,7 +45,7 @@ Change to your repo root with a todo-list file, and make sure the working tree i claudezero todo.md ``` -Run that command in multiple parallel terminals to zero todos faster. +Run that command in multiple parallel terminals to work out todos faster. > ⚠️ ClaudeZero runs `claude` **unattended with permissions auto-approved** and **commits on its own** to the branch you launch it on. Only ever point it at a todo file you wrote or reviewed, on a branch with a clean, committed tree — git is your only undo. From b1b95f5e064ed5470c28b6f9ca94e91fd312691e Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Fri, 31 Jul 2026 12:28:09 +0200 Subject: [PATCH 15/26] chore: track todo.md at the repo root Zero mode reads the todo through `git show :`, so the list has to live in the repo whose branches carry the work. It sat under the now-ignored `issues/`, where every lookup resolved to nothing. Drops the per-line ticket links: the ticket files stay in the issues repo, which a task worktree cannot see. Co-Authored-By: Claude Opus 5 (1M context) --- todo.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 todo.md diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..baf963d --- /dev/null +++ b/todo.md @@ -0,0 +1,14 @@ +# Todo + +- [x] BUG-014 Fix the root guard so a launch inside a leftover `../ts-*` task worktree refuses instead of starting with a peer's claim branch as its base +- [x] ISSUE-015 Report how many todos an instance zeroed and drop the `Claude loops` timing line +- [x] ISSUE-016 Report token consumption per instance run: a headline total plus the four billed categories +- [x] ISSUE-017 Print a fleet-wide TOTAL of todos, time, and tokens on the Ctrl+C exit path (blocked by 015 + 016) +- [x] ISSUE-018 Send claude's TUI to the terminal on fd 4 so a piped run logs only ClaudeZero's reports, and document the Ctrl+C-safe `tee` command in `-h` +- [x] ISSUE-019 Name each claude session `() ` via `--name` +- [x] ISSUE-020 Collapse the zero prompt's acquire/validate/re-check steps into one `.git/zero.sh claim task_id` call +- [x] ISSUE-021 Give each instance's claude session a short unique nickname: `() · ` +- [x] BUG-022 Print the `TOTAL` block on solo runs too, singular heading — its absence today is indistinguishable from a fleet sum that matched nothing +- [x] BUG-023 Reword the two dojo activities that say `one` checkbox (plus the merge-gate arrival) so a session name reads as a continuing series, not a one-todo run +- [ ] 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 +- [ ] ISSUE-027 Print the instance nickname next to its id in the execution stats header From cec0579fdea40fce3b1c4b855f4d405da52284fb Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Fri, 31 Jul 2026 12:41:16 +0200 Subject: [PATCH 16/26] fix: refuse a zero-mode launch when the todo is untracked on the base branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge gate diffs the todo against the branch's fork point on the base. A todo that is not tracked there — never added, or gitignored, which the dirty-tree guard does not catch — makes every merge refuse for checking zero boxes, so no task can ever land. Refuse at startup instead. Covered by TEST.md scenario B5. --- TEST.md | 19 ++++++++++++++----- claudezero.sh | 4 ++++ todo.md | 2 +- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/TEST.md b/TEST.md index 1f3c0a2..df6243d 100644 --- a/TEST.md +++ b/TEST.md @@ -189,14 +189,15 @@ echo "zero.sh wrote counts: $(ls "$T"/repo/.git 2>/dev/null | grep -c '^todos-do ## Scenario B — startup-guard refusals `[$TESTROOT/B]` (no claude) -Four pristine repos: one with a dirty working tree, one on a detached HEAD, one clean -launched from a subdir, one clean launched from inside a leftover `../ts-*` task worktree. +Five pristine repos: one with a dirty working tree, one on a detached HEAD, one clean +launched from a subdir, one clean launched from inside a leftover `../ts-*` task worktree, +and one clean whose todo is gitignored (so untracked on the base branch). Each must make claudezero refuse to start with the matching message and a non-zero exit. ### Setup ```bash TB="$TESTROOT/B" -for name in dirty detached nested worktree; do +for name in dirty detached nested worktree untracked; do mkdir -p "$TB/$name" ( cd "$TB/$name"; git init -q; git config user.email t@t.t; git config user.name test echo x > f; git add f; git commit -qm init @@ -208,6 +209,9 @@ mkdir -p "$TB/nested/sub" # clean repo; we launch from this # leftover claim worktree, exactly what a crashed peer abandons: branch -task-1 + ../ts-* ( cd "$TB/worktree"; base="$(git rev-parse --abbrev-ref HEAD)" git worktree add -q "$TB/ts-$base-task-1-dead" -b "$base-task-1" "$base" ) +# todo present on disk but gitignored → untracked on the base, yet the tree still reads clean +( cd "$TB/untracked"; git rm -q --cached todo.md + echo todo.md > .gitignore; git add .gitignore; git commit -qm ignore-todo ) ``` ### Run + assert @@ -228,13 +232,18 @@ cd "$TB/ts-$(git -C "$TB/worktree" rev-parse --abbrev-ref HEAD)-task-1-dead" # if out=$(timeout 20 bash "$SCRIPT" todo.md -t x 2>&1); then rc=0; else rc=$?; fi { [ "$rc" != 0 ] && echo "$out" | grep -qi 'not at.*repo root'; } && echo "B4 worktree-guard PASS" || echo "B4 FAIL (rc=$rc): $out" git -C "$TB/worktree" branch --list '*-task-*-task-*' | grep -q . && echo "B4 FAIL: second-claim branch created" + +cd "$TB/untracked" # clean repo, but the todo is not in the base tree +if out=$(timeout 20 bash "$SCRIPT" todo.md -t x 2>&1); then rc=0; else rc=$?; fi +{ [ "$rc" != 0 ] && echo "$out" | grep -qi 'not tracked'; } && echo "B5 untracked-guard PASS" || echo "B5 FAIL (rc=$rc): $out" ``` -- **B PASS** — B1, B2, B3, and B4 all report PASS (non-zero exit + the expected message, +- **B PASS** — B1, B2, B3, B4, and B5 all report PASS (non-zero exit + the expected message, before any claude launch), and no `*-task-*-task-*` branch exists. B3 proves the worktree-path assumption is enforced: a subdir launch refuses rather than misfiring `../ts-*` paths. B4 proves the same for a launch *inside* a leftover claim worktree, where the base branch would otherwise be poisoned to a peer's claim and both instances would take - the same todo (BUG-014). + the same todo (BUG-014). B5 proves a todo the base branch does not track refuses up front + instead of letting every merge be refused for checking zero boxes (BUG-026). --- diff --git a/claudezero.sh b/claudezero.sh index 4f49e8f..25373d3 100755 --- a/claudezero.sh +++ b/claudezero.sh @@ -222,6 +222,10 @@ else "$PWD/"*) TODO_PATH="${ABS_PATH#"$PWD"/}" ;; *) echo "$PROG: path not under working dir $PWD: $TODO_PATH"; exit 1 ;; esac + # guardrail: the merge gate diffs the todo against the branch's fork point on the base, so a + # todo that is not tracked there (never added, or gitignored — the dirty-tree guard above misses + # a gitignored file) makes EVERY merge refuse "newly checks 0 boxes" and nothing can ever land. + git cat-file -e "$BASE_BRANCH:$TODO_PATH" 2>/dev/null || { echo "$PROG: '$TODO_PATH' is not tracked on '$BASE_BRANCH' — commit it there first, else every merge is refused and no task can land."; exit 1; } PROMPT="$(build_zero_prompt "$TODO_PATH" "$TASK_PROMPT")" fi diff --git a/todo.md b/todo.md index baf963d..cc0fc23 100644 --- a/todo.md +++ b/todo.md @@ -10,5 +10,5 @@ - [x] ISSUE-021 Give each instance's claude session a short unique nickname: `() · ` - [x] BUG-022 Print the `TOTAL` block on solo runs too, singular heading — its absence today is indistinguishable from a fleet sum that matched nothing - [x] BUG-023 Reword the two dojo activities that say `one` checkbox (plus the merge-gate arrival) so a session name reads as a continuing series, not a one-todo run -- [ ] 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] 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 - [ ] ISSUE-027 Print the instance nickname next to its id in the execution stats header From a022a6b5cc6a1f7102044e35d213c4d3d9b4baf3 Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Fri, 31 Jul 2026 12:43:30 +0200 Subject: [PATCH 17/26] feat(report): name the instance next to its id in the stats header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The execution-stats header printed only the eight hex characters of the instance id, while the terminal title and /resume picker carry the nickname. A report in a scrollback could not be matched to the terminal it came from without reading hex. The nickname is now drawn into INSTANCE_NICK at launch (still one pick_nickname call) and the header reads `execution stats (instance · )`. ISSUE-027 --- CHANGELOG.md | 3 +++ README.md | 2 +- TEST.md | 7 +++++-- claudezero.sh | 9 +++++---- todo.md | 2 +- 5 files changed, 15 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b639412..6697b62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,9 @@ All notable changes to ClaudeZero are documented here. Format follows peer steals it. - The report heading is "execution stats", not "execution time" — a token block is not a duration. The aggregate Claude-loops timing is gone. +- The execution-stats header carries the instance nickname next to its id + (`instance a1b2c3d4 · moss`), so a report in a scrollback matches the terminal + title it came from without reading eight hex characters. ### Fixed diff --git a/README.md b/README.md index 33de753..778ceba 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ $ claudezero todo.md … fresh context, next task … -❄ execution stats (instance a1b2c3d4) +❄ execution stats (instance a1b2c3d4 · moss) Todos: 12m 30s · 5 completed ClaudeZero run loop: 48m 15s diff --git a/TEST.md b/TEST.md index 1f3c0a2..6f72899 100644 --- a/TEST.md +++ b/TEST.md @@ -518,7 +518,7 @@ grun() { rm -rf "$TG/tx"; mkdir -p "$TG/tx" 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)" +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' '|')" @@ -659,12 +659,15 @@ echo "H1 want name : $WANT" echo "H1 launches : $(grep -c '^ARGV:' "$TH/run.log") (want 3)" echo "H1 named+unsplit : $(grep -c -F -- "[--name] [$WANT]" "$TH/run.log") (want 3)" echo "H1 nick in list : $(printf '%s\n' "${NICKS[@]}" | grep -qxF "$NICK" && echo yes || echo NO)" +echo "H1 header nick : $(grep -qF "execution stats (instance $ID · $NICK)" "$TH/run.log" && echo yes || echo NO)" echo "H1 name released : $(ls "$GCH/instance" 2>/dev/null | wc -l | tr -d ' ') (want 0 — marker gone on exit)" ``` - **H1 PASS** — `launches = 3` and `named+unsplit = 3`: `--name` carries the parenthesised, space-containing name as a single argv element, the activity is the one the id selects by `16# % 10`, the nickname sits between id and activity, and all three restarts - used the same name. `nick in list = yes` (one of the fifteen, lowercase) and + used the same name. `nick in list = yes` (one of the fifteen, lowercase), + `header nick = yes` — the execution-stats header reads `(instance · )` with the same + nickname the session name carries, so a report and a terminal title can be matched by word — and `name released = 0` — the exiting instance unlinked its marker, so its nickname is free again. ### H2 — loop mode named too; decimal (`$$`-shaped) id picks an activity, not an error diff --git a/claudezero.sh b/claudezero.sh index 4f49e8f..c0633b2 100755 --- a/claudezero.sh +++ b/claudezero.sh @@ -246,11 +246,12 @@ mkdir -p "$INSTANCE_DIR"; printf '%s\n%s\n' "$$" "$(proc_start "$$")" > "$INSTAN trap 'rm -f "$INSTANCE_DIR/$INSTANCE_ID" 2>/dev/null' EXIT cleanup_orphan_time_files -# claude's display name (prompt box, /resume picker, terminal title) — the same id the report heads -# its stats with, a nickname short enough to say out loud, and a dojo-student activity, so parallel +# claude's display name (prompt box, /resume picker, terminal title) — the same id and nickname the +# report heads its stats with, the nickname short enough to say out loud, and a dojo-student activity, so parallel # terminals are told apart without reading hex. The id and activity are derived from the id, never # from chance; the nickname is picked once here, so every restart re-launches under the same name. -SESSION_NAME="($INSTANCE_ID) $(pick_nickname) · $(dojo_student "$INSTANCE_ID")" +INSTANCE_NICK="$(pick_nickname)" # kept in a var: the report header says it too, and it is drawn once +SESSION_NAME="($INSTANCE_ID) $INSTANCE_NICK · $(dojo_student "$INSTANCE_ID")" STOP_HOOK="$GITDIR_ABS/compact-exit-hook.sh" cat >"$STOP_HOOK" <<'HOOK_EOF' @@ -392,7 +393,7 @@ all_todos_done() { # Tokens = this instance's claude token usage, own block (not a duration, so it does not # share the timing rows' label column) print_report() { - printf '\n❄ execution stats (instance %s)\n' "${INSTANCE_ID:-?}" + printf '\n❄ execution stats (instance %s · %s)\n' "${INSTANCE_ID:-?}" "${INSTANCE_NICK:-?}" if [ "${MODE:-}" = zero ]; then printf ' %-20s %s · %s completed\n' 'Todos:' \ "$(fmt_dur $(( $(read_counter "${TODOS_TIME_FILE:-}") - TODOS_BASE )))" \ diff --git a/todo.md b/todo.md index baf963d..9c2eaef 100644 --- a/todo.md +++ b/todo.md @@ -11,4 +11,4 @@ - [x] BUG-022 Print the `TOTAL` block on solo runs too, singular heading — its absence today is indistinguishable from a fleet sum that matched nothing - [x] BUG-023 Reword the two dojo activities that say `one` checkbox (plus the merge-gate arrival) so a session name reads as a continuing series, not a one-todo run - [ ] 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 -- [ ] ISSUE-027 Print the instance nickname next to its id in the execution stats header +- [x] ISSUE-027 Print the instance nickname next to its id in the execution stats header From 8642f56f972d25f9f3aa083ea0cd48b98bbdccfa Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Fri, 31 Jul 2026 13:16:20 +0200 Subject: [PATCH 18/26] chore(todo): file BUG-024 and BUG-025 for the macOS bash 3.2 failures Co-Authored-By: Claude Opus 5 (1M context) --- todo.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/todo.md b/todo.md index c7fddf9..d37befa 100644 --- a/todo.md +++ b/todo.md @@ -10,5 +10,7 @@ - [x] ISSUE-021 Give each instance's claude session a short unique nickname: `() · ` - [x] BUG-022 Print the `TOTAL` block on solo runs too, singular heading — its absence today is indistinguishable from a fleet sum that matched nothing - [x] BUG-023 Reword the two dojo activities that say `one` checkbox (plus the merge-gate arrival) so a session name reads as a continuing series, not a one-todo run +- [ ] BUG-024 Take the zero prompt's heredoc out of the command substitution so `claudezero.sh` parses under bash 3.2 — stock macOS `/bin/bash` dies at parse time today and the macOS CI leg is red +- [ ] BUG-025 Source fd 4 from a dup of stdin instead of `/dev/tty` — a descriptor opened from the clone device is not kqueue-registrable on macOS, so a piped run kills claude at startup with `EINVAL … kqueue` - [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 From ac2ab3466e9d8e57000090dc1bae6170a4d87d6d Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Fri, 31 Jul 2026 13:35:50 +0200 Subject: [PATCH 19/26] fix: parse the zero prompt's heredoc outside the command substitution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bash 3.2 — the /bin/bash macOS ships, and what a Homebrew install runs — cannot parse a heredoc inside $(…). `prompt=$(cat <<'PROMPT_EOF' … )` made the whole script unparsable there: "unexpected EOF while looking for matching `''", so the macOS CI leg was red and no mac user could run it. Read the heredoc straight into the variable with `read -r -d ''` instead, trimming the trailing newline `$(cat)` used to strip. Rendered prompt is byte-identical to before, under both bash 3.2 and bash 5. Covered by TEST.md scenario S3. --- CHANGELOG.md | 3 +++ TEST.md | 20 +++++++++++++++++--- claudezero.sh | 7 +++++-- todo.md | 2 +- 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6697b62..448ddac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,9 @@ All notable changes to ClaudeZero are documented here. Format follows matched no files because the base slug was not what you thought. - Three dojo activities no longer claim a single unit of work, which read as a ceiling the instance does not have. +- The script parses under bash 3.2, the `/bin/bash` macOS ships. The zero + prompt's heredoc sat inside a command substitution, which bash 3.2 cannot + parse, so every macOS run died before doing anything (BUG-024). ## [0.0.14] — 2026-07-26 diff --git a/TEST.md b/TEST.md index 1ac793d..cfaed11 100644 --- a/TEST.md +++ b/TEST.md @@ -87,9 +87,10 @@ chmod +x "$1"; } --- -## Scenario S — static check (shellcheck) (no claude) +## Scenario S — static checks (shellcheck + bash 3.2 syntax) (no claude) -Lints claudezero.sh (S1) **and the scripts it emits at runtime** (S2). The emitted +Lints claudezero.sh (S1) **and the scripts it emits at runtime** (S2), then parses the +script with stock macOS bash (S3). 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 @@ -116,7 +117,20 @@ else echo "S SKIP — shellcheck not installed" fi ``` -- **S PASS** — S1 and S2 both PASS (claudezero.sh clean, all three emitted scripts clean). + +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. + +```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" +else + echo "S3 SKIP — /bin/bash is not 3.2 (not macOS)" +fi +``` +- **S PASS** — S1 and S2 both PASS (claudezero.sh clean, all three emitted scripts clean), + and S3 PASS or SKIP. --- diff --git a/claudezero.sh b/claudezero.sh index c430dd0..35702a7 100755 --- a/claudezero.sh +++ b/claudezero.sh @@ -978,7 +978,10 @@ DRIVER_EOF # single-quoted heredoc keeps $wt/backticks literal; inject params via bash replace (safe for # arbitrary @@LOOPPROMPT@@ text — no sed metachar/delimiter escaping). - local prompt; prompt=$(cat <<'PROMPT_EOF' + # `read -d ''` not `$(cat <) · ` - [x] BUG-022 Print the `TOTAL` block on solo runs too, singular heading — its absence today is indistinguishable from a fleet sum that matched nothing - [x] BUG-023 Reword the two dojo activities that say `one` checkbox (plus the merge-gate arrival) so a session name reads as a continuing series, not a one-todo run -- [ ] BUG-024 Take the zero prompt's heredoc out of the command substitution so `claudezero.sh` parses under bash 3.2 — stock macOS `/bin/bash` dies at parse time today and the macOS CI leg is red +- [x] BUG-024 Take the zero prompt's heredoc out of the command substitution so `claudezero.sh` parses under bash 3.2 — stock macOS `/bin/bash` dies at parse time today and the macOS CI leg is red - [ ] BUG-025 Source fd 4 from a dup of stdin instead of `/dev/tty` — a descriptor opened from the clone device is not kqueue-registrable on macOS, so a piped run kills claude at startup with `EINVAL … kqueue` - [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 From cf8c2f15cae7d16fa538b37fa58cf53083182011 Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Fri, 31 Jul 2026 13:39:36 +0200 Subject: [PATCH 20/26] fix: source fd 4 from a dup of stdin instead of /dev/tty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A descriptor opened from the /dev/tty clone device is not kqueue-registrable on macOS, so a piped run killed claude at startup with `EINVAL … kqueue`. Dup the terminal stdin the shell was handed instead: a real tty fd, opened read-write, so it takes writes and kqueue both. The probe becomes `[ ! -t 1 ] && [ -t 0 ]`. Scenario G drops the setsid/python3 detach dance for a plain `< /dev/null`, and G2 no longer needs a script(1) wrapper — the documented pipe form from a terminal is exactly the case now. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 6 ++++- TEST.md | 70 +++++++++++++++++++++------------------------------ claudezero.sh | 12 +++++---- todo.md | 2 +- 4 files changed, 42 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 448ddac..5975572 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ All notable changes to ClaudeZero are documented here. Format follows - `claude`'s TUI goes to fd 4, so `claudezero.sh 2>&1 | tee run.log` captures only ClaudeZero's own output instead of every TUI redraw. Falls back to - stdout when there is no redirect or no controlling terminal. `-h` documents + stdout when there is no redirect or stdin is not a terminal. `-h` documents the Ctrl+C-safe pipe form. - The zero prompt's acquire / validate / re-check steps collapse into one `claim` call, so a failed validation no longer leaves a claim behind until a @@ -51,6 +51,10 @@ All notable changes to ClaudeZero are documented here. Format follows - The script parses under bash 3.2, the `/bin/bash` macOS ships. The zero prompt's heredoc sat inside a command substitution, which bash 3.2 cannot parse, so every macOS run died before doing anything (BUG-024). +- A piped run on macOS reaches the prompt instead of killing `claude` at startup + with `EINVAL … kqueue`. fd 4 is a dup of stdin, not a fresh open of `/dev/tty` + — a descriptor from the clone device cannot be registered with kqueue + (BUG-025). ## [0.0.14] — 2026-07-26 diff --git a/TEST.md b/TEST.md index cfaed11..ed95469 100644 --- a/TEST.md +++ b/TEST.md @@ -574,9 +574,9 @@ echo "G2 timing kept : $(grep -c 'ClaudeZero run loop:' "$TG/bad.log") (want 2 ## Scenario G — 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 output captured to a file there is no -controlling terminal, 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. +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 ```bash @@ -590,36 +590,29 @@ 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 -- '- [ ] G1 x\n' > todo.md; git add -A; git commit -qm init -# run as a session leader so there is genuinely no controlling terminal (macOS has no setsid) -detach() { - if command -v setsid >/dev/null 2>&1; then setsid "$@" - elif command -v python3 >/dev/null 2>&1; then - python3 -c 'import os,sys; os.setsid(); os.execvp(sys.argv[1], sys.argv[1:])' "$@" - else echo "G1 SKIP — neither setsid nor python3 available to drop the controlling terminal" >&2; fi -} -detach env PATH="$TG/bin:$PATH" CLAUDEZERO_MAX_LOOPS=1 \ - timeout 30 bash "$SCRIPT" todo.md -t x > "$TG/run.log" 2>&1 || true +# 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)" ``` -- **G1 PASS** — both counts as stated: with no controlling terminal the `(: >/dev/tty)` probe - fails, fd 4 is a dup of stdout, and no scenario that captures output loses stub-claude bytes. - Detaching explicitly matters — run from a terminal without `detach`, the probe succeeds and - the stub's bytes go to the terminal by design, which is the whole point of the split. +- **G1 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 pty) +### G2 — the split itself (manual, needs a terminal) -Not scripted: it needs a real terminal, and the two `script(1)` implementations take -opposite argument orders. Run one of these by hand in a scratch repo with a real `claude`: +Not scripted: it needs a real terminal and a real `claude`. Since fd 4 is a dup of stdin, no +`script(1)` wrapper is needed — run the documented pipe form by hand in a scratch repo: ```bash -# macOS / BSD script — typescript to /dev/null; script's own stdout is the pipe -script -q /dev/null ./claudezero.sh issues/todo.md 2>&1 | { trap '' INT; tee run.log; } -# util-linux script — command via -c, typescript file last -script -q -c "./claudezero.sh issues/todo.md 2>&1" /dev/null | { trap '' INT; tee run.log; } +./claudezero.sh issues/todo.md 2>&1 | { trap '' INT; tee run.log; } ``` -`script` gives claudezero a pty (so `/dev/tty` opens) while its stdout is the pipe (so fd 1 is -not a tty) — exactly the operator's situation. +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`. @@ -653,14 +646,9 @@ ACT=('drilling the fork-implement-merge kata' 'hauling snow buckets uphill' \ 'starting over on fresh snow' 'carving checkbox after checkbox into ice' 'hunting the next box on the list' \ '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 there is no controlling -# terminal — run detached so the stub's ARGV line lands in the capture file (see Scenario G). -detachH() { - if command -v setsid >/dev/null 2>&1; then setsid "$@" - elif command -v python3 >/dev/null 2>&1; then - python3 -c 'import os,sys; os.setsid(); os.execvp(sys.argv[1], sys.argv[1:])' "$@" - else echo "H SKIP — neither setsid nor python3 available to drop the controlling terminal" >&2; fi -} +# 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). +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) GCH="$(cd "$(git rev-parse --git-common-dir)" && pwd)" @@ -673,7 +661,7 @@ nick_of() { sed -E 's/.*\) (.*) · .*/\1/'; } # nickname out of a `[--name] [. ### H1 — name construction, unsplit argv, restart stability ```bash cd "$TH/repo" -detachH env PATH="$TH/bin:$PATH" CLAUDEZERO_MAX_LOOPS=3 \ +notty env PATH="$TH/bin:$PATH" CLAUDEZERO_MAX_LOOPS=3 \ timeout 90 bash "$SCRIPT" todo.md -t x > "$TH/run.log" 2>&1 || true ID=$(grep -m1 -oE 'instance [0-9A-Za-z]+' "$TH/run.log" | awk '{print $2}') NICK=$(grep -m1 -oE '\[--name\] \[[^]]*\]' "$TH/run.log" | nick_of) @@ -696,7 +684,7 @@ echo "H1 name released : $(ls "$GCH/instance" 2>/dev/null | wc -l | tr -d ' ') ### H2 — loop mode named too; decimal (`$$`-shaped) id picks an activity, not an error ```bash cd "$TH/repo" -detachH env PATH="$TH/bin:$PATH" CLAUDEZERO_MAX_LOOPS=1 \ +notty env PATH="$TH/bin:$PATH" CLAUDEZERO_MAX_LOOPS=1 \ timeout 60 bash "$SCRIPT" -l 'hi' > "$TH/loop.log" 2>&1 || true echo "H2 loop-mode name: $(grep -m1 -oE '\[--name\] \[[^]]*\]' "$TH/loop.log" || echo NONE)" # the $$ fallback id is decimal digits — valid hex, so the same derivation applies with no @@ -713,8 +701,8 @@ echo "H2 a1 vs b2 : $([ "$(dojo_student a1b2c3d4)" != "$(dojo_student b2b2c ### H3 — two instances launched at the same moment draw different nicknames ```bash cd "$TH/repo"; rm -rf "$GCH/instance" -detachH env PATH="$TH/bin:$PATH" CLAUDEZERO_MAX_LOOPS=1 timeout 90 bash "$SCRIPT" todo.md -t x > "$TH/a.log" 2>&1 & -detachH env PATH="$TH/bin:$PATH" CLAUDEZERO_MAX_LOOPS=1 timeout 90 bash "$SCRIPT" todo.md -t x > "$TH/b.log" 2>&1 & +notty env PATH="$TH/bin:$PATH" CLAUDEZERO_MAX_LOOPS=1 timeout 90 bash "$SCRIPT" todo.md -t x > "$TH/a.log" 2>&1 & +notty env PATH="$TH/bin:$PATH" CLAUDEZERO_MAX_LOOPS=1 timeout 90 bash "$SCRIPT" todo.md -t x > "$TH/b.log" 2>&1 & wait NA=$(grep -m1 -oE '\[--name\] \[[^]]*\]' "$TH/a.log" | nick_of) NB=$(grep -m1 -oE '\[--name\] \[[^]]*\]' "$TH/b.log" | nick_of) @@ -728,18 +716,18 @@ echo "H3 differ : $([ -n "$NA" ] && [ "$NA" != "$NB" ] && echo yes || ech ```bash cd "$TH/repo" rm -rf "$GCH/instance"; i=0; for n in "${NICKS[@]}"; do [ "$n" = moss ] || mkpeer "$n" $((i++)); done -detachH env PATH="$TH/bin:$PATH" CLAUDEZERO_MAX_LOOPS=1 timeout 90 bash "$SCRIPT" todo.md -t x > "$TH/c.log" 2>&1 || true +notty env PATH="$TH/bin:$PATH" CLAUDEZERO_MAX_LOOPS=1 timeout 90 bash "$SCRIPT" todo.md -t x > "$TH/c.log" 2>&1 || true echo "H4 fourteen held : $(grep -m1 -oE '\[--name\] \[[^]]*\]' "$TH/c.log" | nick_of) (want moss)" rm -rf "$GCH/instance"; i=0; for n in "${NICKS[@]}"; do mkpeer "$n" $((i++)); done -detachH env PATH="$TH/bin:$PATH" CLAUDEZERO_MAX_LOOPS=1 timeout 90 bash "$SCRIPT" todo.md -t x > "$TH/d.log" 2>&1 || true +notty env PATH="$TH/bin:$PATH" CLAUDEZERO_MAX_LOOPS=1 timeout 90 bash "$SCRIPT" todo.md -t x > "$TH/d.log" 2>&1 || true echo "H4 all fifteen : $(grep -m1 -oE '\[--name\] \[[^]]*\]' "$TH/d.log" | nick_of) (want ' 1')" rm -rf "$GCH/instance"; i=0; for n in "${NICKS[@]}"; do mkpeer "$n" $((i++)); mkpeer "$n 1" $((i++)); done -detachH env PATH="$TH/bin:$PATH" CLAUDEZERO_MAX_LOOPS=1 timeout 90 bash "$SCRIPT" todo.md -t x > "$TH/e.log" 2>&1 || true +notty env PATH="$TH/bin:$PATH" CLAUDEZERO_MAX_LOOPS=1 timeout 90 bash "$SCRIPT" todo.md -t x > "$TH/e.log" 2>&1 || true echo "H4 both levels : $(grep -m1 -oE '\[--name\] \[[^]]*\]' "$TH/e.log" | nick_of) (want ' 2')" # crashed peer: a marker whose pid is dead still names moss — the startup GC unlinks it first rm -rf "$GCH/instance"; mkdir -p "$GCH/instance"; printf '999999\ndead\nmoss\n' > "$GCH/instance/crashed" i=0; for n in "${NICKS[@]}"; do [ "$n" = moss ] || mkpeer "$n" $((i++)); done -detachH env PATH="$TH/bin:$PATH" CLAUDEZERO_MAX_LOOPS=1 timeout 90 bash "$SCRIPT" todo.md -t x > "$TH/f.log" 2>&1 || true +notty env PATH="$TH/bin:$PATH" CLAUDEZERO_MAX_LOOPS=1 timeout 90 bash "$SCRIPT" todo.md -t x > "$TH/f.log" 2>&1 || true echo "H4 crashed freed : $(grep -m1 -oE '\[--name\] \[[^]]*\]' "$TH/f.log" | nick_of) (want moss)" rm -rf "$GCH/instance" ``` diff --git a/claudezero.sh b/claudezero.sh index 35702a7..28ca1fe 100755 --- a/claudezero.sh +++ b/claudezero.sh @@ -105,11 +105,13 @@ run_loop() { # CLAUDEZERO_MAX_LOOPS: exit after N iterations instead of looping until Ctrl+C. 0/unset = # unlimited (normal). Set >0 for tests so the loop self-terminates without a SIGINT. MAX_LOOPS="${CLAUDEZERO_MAX_LOOPS:-0}" -# fd 4 = where claude's own chatter goes. Redirected stdout + a terminal present → claude keeps -# writing to the terminal, so piping claudezero.sh to a log file records the ❄ reports, not the TUI. -# No redirect, or no controlling terminal (tests, CI, nohup) → fd 4 is plain stdout, as today. -# Probe in a subshell: a failed `exec` redirection is shell-fatal, not testable. -if [ ! -t 1 ] && (: >/dev/tty) 2>/dev/null; then exec 4>/dev/tty; else exec 4>&1; fi +# fd 4 = where claude's own chatter goes. Redirected stdout + stdin still on the terminal → claude +# keeps writing to the terminal, so piping claudezero.sh to a log file records the ❄ reports, not +# the TUI. No redirect, or stdin not a terminal (tests, CI, nohup) → fd 4 is plain stdout, as today. +# Dup stdin rather than open /dev/tty: on macOS a descriptor opened from the /dev/tty clone device +# is not kqueue-registrable, and claude dies at startup with `EINVAL … kqueue`. The tty stdin the +# 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 TODOS_BASE=$(read_counter "${TODOS_TIME_FILE:-}") # snapshot: report only THIS run's slice of the shared aggregates diff --git a/todo.md b/todo.md index 4010bf9..af8d578 100644 --- a/todo.md +++ b/todo.md @@ -11,6 +11,6 @@ - [x] BUG-022 Print the `TOTAL` block on solo runs too, singular heading — its absence today is indistinguishable from a fleet sum that matched nothing - [x] BUG-023 Reword the two dojo activities that say `one` checkbox (plus the merge-gate arrival) so a session name reads as a continuing series, not a one-todo run - [x] BUG-024 Take the zero prompt's heredoc out of the command substitution so `claudezero.sh` parses under bash 3.2 — stock macOS `/bin/bash` dies at parse time today and the macOS CI leg is red -- [ ] BUG-025 Source fd 4 from a dup of stdin instead of `/dev/tty` — a descriptor opened from the clone device is not kqueue-registrable on macOS, so a piped run kills claude at startup with `EINVAL … kqueue` +- [x] BUG-025 Source fd 4 from a dup of stdin instead of `/dev/tty` — a descriptor opened from the clone device is not kqueue-registrable on macOS, so a piped run kills claude at startup with `EINVAL … kqueue` - [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 From b0a8be7f9542b5ab1827f8729aeb16bac7ac9830 Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Fri, 31 Jul 2026 13:54:40 +0200 Subject: [PATCH 21/26] docs: record BUG-026 in the changelog and land the branch's UI changes in the readme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The changelog's 0.0.15 section missed BUG-026 — the guard that refuses a zero-mode launch when the todo file is not tracked on the base branch. The readme still showed the pre-branch UI: version 0.0.14 in the sample run, no fleet TOTAL block, and no mention of named claude sessions or the fd-4 split that makes a piped run log only ClaudeZero's reports. Quickstart now also says the todo file has to be committed on the branch. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 4 ++++ README.md | 24 ++++++++++++++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5975572..a0ec4c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,10 @@ All notable changes to ClaudeZero are documented here. Format follows with `EINVAL … kqueue`. fd 4 is a dup of stdin, not a fresh open of `/dev/tty` — a descriptor from the clone device cannot be registered with kqueue (BUG-025). +- A zero-mode launch whose todo file is not tracked on the base branch is + refused at startup. Untracked — never added, or gitignored — means every merge + is refused, so the run would burn tokens on work that can never land + (BUG-026). ## [0.0.14] — 2026-07-26 diff --git a/README.md b/README.md index 778ceba..7e9ab69 100644 --- a/README.md +++ b/README.md @@ -35,11 +35,12 @@ Runs [`claude`](https://claude.com/product/claude-code) on a predefined prompt i - **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 a `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. +- **Named sessions** — every `claude` session is named `() · `, shown in the prompt box, the `/resume` picker and the terminal title. The nickname is a short word no live peer holds, so you can say "kill moss" instead of reading eight hex characters. Both id and nick head the instance's report, and survive context restarts. - **Ctrl+C window** — 5s pause between runs to stop cleanly. ## 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). Then run `claudezero` pointing to your todo-list: +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 @@ -56,7 +57,7 @@ Representative zero-mode run (agent output between the markers elided): ```console $ claudezero todo.md -❄ ClaudeZero 0.0.14 +❄ ClaudeZero 0.0.15 zero mode · base master · fork → implement → commit → merge … claude works a task: forks a worktree, implements, commits, merges, ticks its box … @@ -72,9 +73,18 @@ $ claudezero todo.md Tokens: 5.8M Total in 2.1k · out 84.3k · cache write 312k · cache read 5.4M +----------------------------------------------- +❄ TOTAL (3 instances) + Todos: 41m 12s · 14 completed + + Tokens: 17.4M Total + in 6.3k · out 251.9k · cache write 903k · cache read 16.2M + ❄ ClaudeZero surveys the frozen field, and is proud. ``` +The `TOTAL` block sums every instance of the run on this base branch — a crashed peer counts too, its merged work did land. It prints on solo runs as well, with a singular heading. `ClaudeZero run loop` stays per-instance: parallel wall times overlap, so their sum is not a duration anything took. + ## Install (for the Claude coding agent) Supported on **macOS and Linux** (the script is bash-3.2-safe, so stock macOS `bash` works). @@ -198,6 +208,16 @@ claudezero -h CLAUDEZERO_MAX_LOOPS=3 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: + +```sh +claudezero todo.md 2>&1 | { trap '' INT; tee ../run.log; } +``` + +The `trap` keeps `tee` alive through Ctrl+C, so the final report and the `TOTAL` block land in the file. Without a redirect — or when stdin is not a terminal — the TUI falls back to stdout as before. + ## Cleanup Normal exits tidy up after themselves. But a crash, a `kill`, or `Ctrl+C` mid-task can leave a claim branch and its worktree behind — by design, so the next run can reclaim and finish them. These leftovers are exactly what crash-recovery reattaches to, so only remove them once you've **stopped every instance** and are done zeroing the unchecked todos. -Easiest is to let Claude Code walk the cleanup and confirm each removal with you. From the repo root: +The easiest way is to let Claude Code walk the cleanup and confirm each removal with you. From the repo root: ```sh claude "ClaudeZero left stray git worktrees and branches behind. Clean them up @@ -243,7 +243,7 @@ discards work; (4) after removals, run 'git worktree prune'. Do nothing destructive without my explicit confirmation." ``` -Prefer to do it by hand: +To do it by hand: ```sh git worktree list # find ../ts--task-- @@ -258,7 +258,7 @@ Only delete a branch whose work you've already merged or intend to throw away. ## Tests -End-to-end tests live in [TEST.md](TEST.md) — written to be executed by an LLM agent. Point the coding agent at the file and it runs all scenarios autonomously `claude --permission-mode auto "execute TEST.md and return a report"`. +End-to-end tests live in [TEST.md](TEST.md) — written to be executed by an LLM agent. Point the coding agent at the file and it runs all scenarios autonomously: `claude --permission-mode auto "execute TEST.md and return a report"`. ## Security From f5e7e4b58e33506033c3cac90a7ec55249dc3fec Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Fri, 31 Jul 2026 18:15:17 +0200 Subject: [PATCH 23/26] docs: add ISSUE-028 to the todo list Co-Authored-By: Claude Fable 5 --- todo.md | 1 + 1 file changed, 1 insertion(+) diff --git a/todo.md b/todo.md index af8d578..04b679e 100644 --- a/todo.md +++ b/todo.md @@ -14,3 +14,4 @@ - [x] BUG-025 Source fd 4 from a dup of stdin instead of `/dev/tty` — a descriptor opened from the clone device is not kqueue-registrable on macOS, so a piped run kills claude at startup with `EINVAL … kqueue` - [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 +- [ ] ISSUE-028 Show the script version on its own line below the usage heading in `--help` From d7a0161b1d356371d55669862b1351f9358b03a6 Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Fri, 31 Jul 2026 18:19:47 +0200 Subject: [PATCH 24/26] feat: show the script version below the usage heading in --help Co-Authored-By: Claude Fable 5 --- claudezero.sh | 3 ++- todo.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/claudezero.sh b/claudezero.sh index 7799869..5ef49c0 100755 --- a/claudezero.sh +++ b/claudezero.sh @@ -26,8 +26,9 @@ 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" <<'USAGE' + sed -e "s/@@RESTART_WAIT@@/$RESTART_WAIT/g" -e "s/@@PROG@@/$PROG/g" -e "s/@@VERSION@@/$VERSION/g" <<'USAGE' usage: @@PROG@@ [todo-file-path] [-t|--taskprompt TEXT | -l|--loopprompt TEXT] +version @@VERSION@@ Loops claude to zero a Markdown todo file — fork a worktree per task, implement, commit, merge, restart on fresh context — until every box is checked. Run several diff --git a/todo.md b/todo.md index 04b679e..ae3320f 100644 --- a/todo.md +++ b/todo.md @@ -14,4 +14,4 @@ - [x] BUG-025 Source fd 4 from a dup of stdin instead of `/dev/tty` — a descriptor opened from the clone device is not kqueue-registrable on macOS, so a piped run kills claude at startup with `EINVAL … kqueue` - [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 -- [ ] ISSUE-028 Show the script version on its own line below the usage heading in `--help` +- [x] ISSUE-028 Show the script version on its own line below the usage heading in `--help` From b3e5dd522f37dd860bd3330d27070e51847dcdff Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Fri, 31 Jul 2026 18:23:26 +0200 Subject: [PATCH 25/26] docs: record ISSUE-028 in the changelog Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0ec4c1..3c30060 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ All notable changes to ClaudeZero are documented here. Format follows print confidently wrong money after any model launch. - Per-instance count of todos zeroed, credited at the same point as the ownership time so the two always agree. +- The `-h`/`--help` screen states the script version on its own line below the + `usage:` heading, so a bug report can quote the release without opening the + script or starting a run to read the launch banner (ISSUE-028). - Each `claude` session is named `() · `, visible in the prompt box, `/resume` picker and terminal title. The nickname is a short word no live peer holds, so you can say "kill kit" instead of reading From 8368078ca97921978578340186601a04c5e14b45 Mon Sep 17 00:00:00 2001 From: Ivan Rublev Date: Fri, 31 Jul 2026 20:09:37 +0200 Subject: [PATCH 26/26] Add push in tap repo by default --- .github/workflows/bump-tap.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/bump-tap.yml b/.github/workflows/bump-tap.yml index e8a5061..c0fa0b1 100644 --- a/.github/workflows/bump-tap.yml +++ b/.github/workflows/bump-tap.yml @@ -20,3 +20,4 @@ jobs: tag: ${{ github.ref_name }} revision: ${{ github.sha }} # Opens a PR on the tap by default. Add `push: true` to commit direct. + push: true