diff --git a/.github/smoke.sh b/.github/smoke.sh index 3a829d3..1685508 100755 --- a/.github/smoke.sh +++ b/.github/smoke.sh @@ -67,4 +67,41 @@ 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" + +# 8. 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/.github/workflows/bump-tap.yml b/.github/workflows/bump-tap.yml index 202b65b..c0fa0b1 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,8 @@ 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. + push: true diff --git a/.gitignore b/.gitignore index e4d62f4..2348d8e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ sources/ probity.config.js +issues/ diff --git a/CHANGELOG.md b/CHANGELOG.md index f5a9dca..3c30060 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,65 @@ All notable changes to ClaudeZero are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.0.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. +- 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 + 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 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 + 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 + +- 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. +- 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). +- 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 First public release. Base version — prior `0.0.x` iterations were pre-public diff --git a/README.md b/README.md index 54680ac..90cdd65 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

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

Loops claude until every todo is done, committed, and checked off.
-Restarting it on a fresh context before rot.
+Restarts it on a fresh context before rot.
Spawn many instances to parallelize.

@@ -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, and restarts it on a fresh context before rot sets in. ## Contents @@ -31,21 +31,22 @@ Runs [`claude`](https://claude.com/product/claude-code) on a predefined prompt i ## What it does - **Guides Claude to zero a todo list unattended** — one task at a time until all are completed, committed, and checkmarked. -- **Beats context rot** — session Stop hook SIGTERMs `claude` near ~80% of the context window and restarts clean. Fresh context, no quality decay. +- **Beats context rot** — session Stop hook SIGTERMs `claude` at ~80% of the context window and restarts clean. Fresh context, no quality decay. - **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. +- **Crash resilient** — if `claude` crashes or is killed mid-task, its half-done work isn't lost. The next instance to come by — a peer switching to its next task, or the same loop restarted — reclaims the branch, finishes it, and merges it back. Work only counts as done once it lands on the base branch and its box is checked there. +- **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 ``` -Run that command in multiple parallel terminals to zero todos faster. +Run that command in multiple parallel terminals to work through the todos faster. > ⚠️ ClaudeZero runs `claude` **unattended with permissions auto-approved** and **commits on its own** to the branch you launch it on. Only ever point it at a todo file you wrote or reviewed, on a branch with a clean, committed tree — git is your only undo. @@ -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 … @@ -65,14 +66,25 @@ $ claudezero todo.md … fresh context, next task … -❄ execution time (instance a1b2c3d4) - Todos: 12m 30s - Claude loops: 41m 02s +❄ execution stats (instance a1b2c3d4 · moss) + Todos: 12m 30s · 5 completed ClaudeZero run loop: 48m 15s + 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, since 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). @@ -107,6 +119,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: @@ -118,7 +134,7 @@ GitHub-style Markdown checkboxes, one task per line. Each line carries a **uniqu - [x] SMTH-140 Set up CI pipeline ``` -- [ ] EXAMPLE-1 illustrative example, not task +- [ ] EXAMPLE-1 An illustrative example, not a task ``` ````` @@ -139,7 +155,7 @@ The kata is a strict per-iteration algorithm every instance runs: 2. Judge independence by evidence — blocked only if the body quotably consumes an *unchecked* task's output; adjacency is not a dependency. 3. Claim & re-check — one task per git worktree (branch = claim), then guard against a peer who already landed it. 4. Implement & check off — scoped to that task, tick only its box, commit. -5. Merge serially — on a conflict or over-check, self-heal once, else stop and hand off to user rather than corrupt the base. +5. Merge serially — on a conflict or over-check, self-heal once, else stop and hand off to the user rather than corrupt the base. 6. Repeat — next id; when every box is checked, announce done and stop. ### Closing the loop @@ -186,12 +202,22 @@ claudezero -h **`-l` loop prompt** — skips zeroing entirely when set. Instead of walking a todo file, it loops `claude` on one literal prompt you supply, restarting on fresh context each time. Any `todo-file-path` you pass is ignored. This mode creates no worktree branches, so run just one instance at a time. -**`CLAUDEZERO_MAX_LOOPS`** — cap the number of context reset iterations. Unset or `0` loops forever (until Ctrl+C); set `>0` to exit the script after N restarts of `claude`. +**`CLAUDEZERO_MAX_LOOPS`** — cap the number of context-reset iterations. Unset or `0` loops forever (until Ctrl+C); set `>0` to exit the script after N restarts of `claude`. ```sh 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 @@ -217,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-- @@ -232,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 diff --git a/TEST.md b/TEST.md index e534ac0..ed95469 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. @@ -13,9 +13,13 @@ 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. +- **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 @@ -83,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 @@ -112,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. --- @@ -161,31 +179,39 @@ 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 : $(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-')" ``` - **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.) +- **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). --- ## 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. +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; 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 @@ -194,6 +220,12 @@ 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" ) +# 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 @@ -208,11 +240,24 @@ 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" + +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, 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, B4, and B5 all report PASS (non-zero exit + the expected message, + before any claude launch), and no `*-task-*-task-*` branch exists. B3 proves the + worktree-path assumption is enforced: a subdir launch refuses rather than misfiring + `../ts-*` paths. B4 proves the same for a launch *inside* a leftover claim worktree, where + the base branch would otherwise be poisoned to a peer's claim and both instances would take + the same todo (BUG-014). B5 proves a todo the base branch does not track refuses up front + instead of letting every merge be refused for checking zero boxes (BUG-026). --- @@ -221,7 +266,7 @@ if out=$(timeout 20 bash "$SCRIPT" ../todo.md -t x 2>&1); then rc=0; else rc=$?; 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 @@ -266,10 +311,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. @@ -290,7 +335,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 ``` @@ -374,16 +419,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) @@ -437,6 +487,433 @@ 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. + +--- + +## 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 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 +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 +# 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 stdin not a terminal the `[ -t 0 ]` probe fails, + fd 4 is a dup of stdout, and no scenario that captures output loses stub-claude bytes. + Redirecting stdin explicitly matters — run from a terminal without `< /dev/null`, the probe + succeeds and the stub's bytes go to the terminal by design, which is the whole point of the split. + +### G2 — the split itself (manual, needs a terminal) + +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 +./claudezero.sh issues/todo.md 2>&1 | { trap '' INT; tee run.log; } +``` +Stdin is the terminal (so `[ -t 0 ]` holds) and stdout is the pipe (so fd 1 is not a tty) — +exactly the operator's situation. This is also the macOS regression check this descriptor choice +exists for: a real `claude` must reach its prompt instead of dying with `EINVAL … kqueue`, which +is what a fresh open of `/dev/tty` on fd 4 caused. +- **G2 PASS** — `run.log` holds the `❄` banner, reports, and loop notices and no TUI frames + (`grep -c $'\033' run.log` is `0`), the terminal shows both streams, and pressing Ctrl+C in + the between-runs gap still lands the closing report in `run.log`. + +--- + +## 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 (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 +TH="$TESTROOT/H"; mkdir -p "$TH/repo" "$TH/bin" +cat > "$TH/bin/claude" <<'EOF' +#!/usr/bin/env bash +printf 'ARGV:'; for a in "$@"; do printf ' [%s]' "$a"; done; printf '\n' +exit 0 +EOF +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 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 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 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)" +# 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 +```bash +cd "$TH/repo" +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) +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 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), + `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 +```bash +cd "$TH/repo" +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 +# branch. Drive the real function on such an id. +eval "$(sed -n '/^dojo_student()/,/^}/p' "$SCRIPT")" +echo "H2 decimal id : $(dojo_student 48584); $(dojo_student 90210) (two activities, no error)" +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`). + +### H3 — two instances launched at the same moment draw different nicknames +```bash +cd "$TH/repo"; rm -rf "$GCH/instance" +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) +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 +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 +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 +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 +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" +``` +- **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.) + +--- + +## Scenario I — `zero.sh claim` exit paths `[$TESTROOT/I]` (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 +TI="$TESTROOT/I"; mkdir -p "$TI/repo" "$TI/bin" +printf '#!/usr/bin/env bash\nexit 0\n' > "$TI/bin/claude"; 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 a\n- [ ] I3 b\n- [ ] I4 c\n- [ ] I5 d\n- [ ] I6/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="$TI/bin:$PATH" timeout 30 env CLAUDEZERO_MAX_LOOPS=1 bash "$SCRIPT" todo.md -t x > "$TI/boot.log" 2>&1 || true +cp "$(command -v bash)" "$TI/bin/claude" # ensure_owner walks `ps -o comm=` for an ancestor named claude +cat > "$TI/drive.sh" <<'DRIVE' +set -uo pipefail +cd "$TI/repo" +ZERO="$(cd "$(git rev-parse --git-dir)" && pwd)/zero.sh" +GC="$(cd "$(git rev-parse --git-common-dir)" && pwd)" + +# I1 — free task: exit 0, worktree path on stdout and nothing else +wt=$("$ZERO" claim I1); rc=$? +echo "I1 exit : $rc (want 0)" +echo "I1 branch : $(git -C "$wt" symbolic-ref --short HEAD 2>/dev/null) (want main-task-I1)" +echo "I1 stdout clean : $([ "$(printf '%s' "$wt" | wc -l | tr -d ' ')" = 0 ] && [ "$(printf '%s' "$wt" | wc -w | tr -d ' ')" = 1 ] && echo yes || echo NO)" + +# I2 — already held by a live session: exit 1, no second worktree +before=$(git worktree list | grep -c 'task-I1') +out=$("$ZERO" claim I1 2>&1 >/dev/null); rc=$? +echo "I2 exit : $rc (want 1)" +echo "I2 stderr : $out" +echo "I2 no new worktree : $([ "$(git worktree list | grep -c 'task-I1')" = "$before" ] && echo yes || echo NO)" + +# I3 — a peer landed it on base first: exit 4, and the worktree/branch it briefly held are gone +sed -i'' -e 's/^- \[ \] I3 /- [x] I3 /' todo.md; git commit -qam 'peer landed I3' +out=$("$ZERO" claim I3 2>&1 >/dev/null); rc=$? +echo "I3 exit : $rc (want 4)" +echo "I3 stderr : $out" +echo "I3 worktree gone : $(git worktree list | grep -c 'task-I3') (want 0)" +echo "I3 branch gone : $(git branch --list 'main-task-I3' | wc -l | tr -d ' ') (want 0)" + +# I4 — validation failure, FAULT-INJECTED: acquire hands back a detached worktree. Unreachable in +# normal operation (acquire always lands on the task branch), so inject rather than fabricate. +sed 's|^ setup_exclude "$wt"; claim_owner "$wt"; set_current "$n"; printf| setup_exclude "$wt"; claim_owner "$wt"; set_current "$n"; git -C "$wt" checkout -q --detach; printf|' \ + "$ZERO" > "$TI/zero-bad.sh"; chmod +x "$TI/zero-bad.sh" +out=$("$TI/zero-bad.sh" claim I4 2>&1 >/dev/null); rc=$? +echo "I4 exit : $rc (want 3)" +echo "I4 stderr : $out" +echo "I4 worktree kept : $(git worktree list | grep -c 'task-I4') (want 1 — exit 3 must NOT remove it)" +echo "I4 marker cleared : $(grep -l '^I4$' "$GC"/session/* 2>/dev/null | wc -l | tr -d ' ') (want 0 — no session still names I4)" + +# I5 — the leaked-claim regression: after an exit 3 the session may still claim another task +wt5=$("$ZERO" claim I5); rc=$? +echo "I5 exit : $rc (want 0 — the failed claim did not leak)" +echo "I5 branch : $(git -C "$wt5" symbolic-ref --short HEAD 2>/dev/null) (want main-task-I5)" + +# I6 — the RAW id reaches is_done: an id needing sanitization still matches its todo line +sed -i'' -e 's|^- \[ \] I6/a |- [x] I6/a |' todo.md; git commit -qam 'peer landed I6/a' +out=$("$ZERO" claim 'I6/a' 2>&1 >/dev/null); rc=$? +echo "I6 exit : $rc (want 4 — raw 'I6/a' matched the todo line, the branch used the slug)" + +echo "I7 usage : $("$ZERO" 2>&1 | grep -c 'claim N') (want 1)" +DRIVE +``` + +### Run + assert +```bash +TI="$TI" "$TI/bin/claude" "$TI/drive.sh" +``` +- **I PASS** — every line reports its `want` value. Together they cover the four exit paths, the + single-field stdout the prompt's `wt=$(…)` depends on, the exit-3 claim leak (I4/I5), and the + raw-vs-sanitized id split (I6). + +--- + +## Scenario J — fleet TOTAL on the exit path `[$TESTROOT/J]` (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 +TJ="$TESTROOT/J"; mkdir -p "$TJ/repo" "$TJ/bin" +cat > "$TJ/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 "$TJ/bin/claude" +cd "$TJ/repo" +git init -q -b main; git config user.email t@t.t; git config user.name test +printf -- '- [ ] J1 x\n' > todo.md; git add -A; git commit -qm init +``` + +### J1 — TOTAL equals the sum of the per-instance files +```bash +cd "$TJ/repo" +PATH="$TJ/bin:$PATH" timeout 40 env CLAUDEZERO_MAX_LOOPS=1 bash "$SCRIPT" todo.md -t x > "$TJ/run.log" 2>&1 +echo "J1 exit : $? (want 0 — the report never fails the exit path)" +sed -n '/❄ TOTAL/,$p' "$TJ/run.log" +echo "J1 instances : $(grep -o '❄ TOTAL ([0-9]*' "$TJ/run.log" | tr -dc 0-9) (want 2 — PEER1 + DEADPEER, dead peer counted)" +echo "J1 todos sum : $(sed -n '/❄ TOTAL/,$p' "$TJ/run.log" | grep -oE '2m30s.*3 completed' | head -1) (want 2m30s · 3 completed = 100+50s, 2+1)" +echo "J1 token sum : $(sed -n '/❄ TOTAL/,$p' "$TJ/run.log" | grep -oE 'Tokens: [^ ]+ Total') (want 1.2k = 10+20+248+1000, counted ONCE)" +echo "J1 categories : $(sed -n '/❄ TOTAL/,$p' "$TJ/run.log" | grep -oE 'in 10 · out 20 · cache write 248 · cache read 1.0k') (want that line)" +echo "J1 no registry : $(ls "$TJ/repo/.git" | grep -cE '^(fleet|total)-') (want 0 — glob, no aggregate file)" +``` +- **J1 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`. + +### J2 — solo run prints a singular TOTAL; unreadable peer files degrade, never lie +```bash +cd "$TJ/repo" +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 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)" +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 "$TJ/bin/claude" +PATH="$TJ/bin:$PATH" timeout 40 env CLAUDEZERO_MAX_LOOPS=1 bash "$SCRIPT" todo.md -t x > "$TJ/degrade.log" 2>&1 +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 = 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. + --- ## Run all in parallel (optional) @@ -467,10 +944,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-\|^transcripts-\|^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..5ef49c0 100755 --- a/claudezero.sh +++ b/claudezero.sh @@ -13,16 +13,22 @@ # 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) 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' + 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 @@ -35,6 +41,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,10 +106,17 @@ 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 + 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 -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 +128,13 @@ 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 + CLAUDEZERO_INSTANCE="$INSTANCE_ID" CLAUDEZERO_TRANSCRIPTS="$TRANSCRIPTS_FILE" \ + claude --settings "$STOP_SETTINGS" --permission-mode auto --name "$SESSION_NAME" "$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. 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 @@ -133,7 +149,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 } @@ -186,9 +207,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. @@ -202,6 +225,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 @@ -213,6 +240,10 @@ 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) +# 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" @@ -222,6 +253,13 @@ 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 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. +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' #!/usr/bin/env bash @@ -230,6 +268,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,10 +316,66 @@ 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; } +# 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. +# $1 = transcript-list file, default this instance's — the fleet total passes each peer's in turn. +read_tokens_total() { + 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 < "$tf" | 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 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,18 +393,72 @@ 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) +# 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 · %s)\n' "${INSTANCE_ID:-?}" "${INSTANCE_NICK:-?}" 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 )))" + 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. 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 plural=s + 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" -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") )) + 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 instance%s)\n' "$n" "$plural" + 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 @@ -331,6 +488,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 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' + ) + 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'; } @@ -353,11 +528,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,13 +542,51 @@ cleanup_orphan_time_files() { instance_alive "${pid:-0}" "${st:-}" || rm -f "$m" done fi - for f in "$gc/todos-seconds-$slug-"*; do + 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 + for f in "$gc/transcripts-$slug-"*; do # same rule for the token-accounting lists [ -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" + id="${f##*/transcripts-"$slug"-}" + [ -f "$INSTANCE_DIR/$id" ] && continue + rm -f "$f" + 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 @@ -399,6 +613,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 +630,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 @@ -544,6 +771,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() { @@ -591,8 +845,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 } @@ -662,7 +918,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() { @@ -673,12 +929,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" @@ -724,7 +981,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 <: `. 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 -) + prompt=${prompt%$'\n'} # read keeps the final newline; $(cat) stripped it prompt=${prompt//@@TODO@@/$todo} prompt=${prompt//@@BASE_BRANCH@@/$BASE_BRANCH} prompt=${prompt//@@LOOP_INTERVAL@@/$LOOP_INTERVAL} diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..ae3320f --- /dev/null +++ b/todo.md @@ -0,0 +1,17 @@ +# 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 +- [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 +- [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 +- [x] ISSUE-028 Show the script version on its own line below the usage heading in `--help`