Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions .github/smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -117,4 +117,63 @@ ln -s "$tmp/nowhere" "$tmp/dangling" || fail "ln -s to a missing targ
if [ -e "$tmp/dangling" ]; then fail "-e followed a dangling link (guard would misfire)"; fi
ok "ln -s / -L / -e guard"

# 10. context-rot guard's field extraction / threshold resolution (claudezero.sh's Stop hook).
# `grep -noE` feeds short per-field `LINE:"key":value` lines into the table-matcher awk — never
# the raw record — so this exercises: ENVIRON[] escapes surviving intact (a `-v` hand-off would
# expand `\[1m\]` into the character class `[1m]`, matching bare "1"/"m"); dynamic regex matching
# a version-suffixed family id while a word-suffix tier ("-mini") falls through to the default
# (split(s,a," ") whitespace-run parsing, blank table lines dropped, exercised via CZ_TABLE's own
# leading/trailing blank lines below); and match-based field extraction surviving a `tail -c` cut
# that lands mid-JSON, dropping `model` while the `usage` object (which trails it) survives.
CZT='
\[1m\] 200000
claude-fable-5(-[0-9]|[^A-Za-z0-9-]) 200000
'
guard_verdict() { # $1 = raw bytes: a JSONL record, or a `tail -c`-cropped fragment of one
printf '%s' "$1" \
| grep -noE '"model":"[^"]*"|"input_tokens":[0-9]+|"cache_read_input_tokens":[0-9]+|"cache_creation_input_tokens":[0-9]+|"output_tokens":[0-9]+' \
| CZ_TABLE="$CZT" CZ_DEFAULT=160000 awk '
BEGIN {
def = ENVIRON["CZ_DEFAULT"] + 0
n = split(ENVIRON["CZ_TABLE"], tln, "\n")
for (i = 1; i <= n; i++) {
if (split(tln[i], f, " ") < 2) continue
tn++; pat[tn] = f[1]; thr[tn] = f[2] + 0
}
}
{
if (!match($0, /^[0-9]+:/)) next
L = substr($0, 1, RLENGTH - 1); rest = substr($0, RLENGTH + 1)
if (!match(rest, /^"[a-zA-Z_]+":/)) next
key = substr(rest, 2, RLENGTH - 3); val = substr(rest, RLENGTH + 1)
if ((L, key) in seen) next
seen[L, key] = 1
if (key == "model") { sub(/^"/, "", val); sub(/"$/, "", val); mdl[L] = val; next }
if (key == "output_tokens") { saw_out[L] = 1; next }
tot[L] += val + 0
}
END {
best = ""
for (L in saw_out) { if ((tot[L]+0) > 0 && (best == "" || (L+0) > (best+0))) best = L }
if (best == "") { print 0; exit }
id = mdl[best] " "
th = def
for (i = 1; i <= tn; i++) { if (id ~ pat[i]) { th = thr[i]; break } }
print (((tot[best]+0) >= th) ? 1 : 0)
}'
}

REC_MINI='{"model":"claude-fable-5-mini","input_tokens":9000,"cache_read_input_tokens":170000,"cache_creation_input_tokens":1000,"output_tokens":500}'
[ "$(guard_verdict "$REC_MINI")" = 1 ] || fail "claude-fable-5-mini at 180000: default (160000) should fire; a -v hand-off would corrupt \\[1m\\] into a class matching mini's 'm' and wrongly give 0"

REC_VER='{"model":"claude-fable-5-20260115-v1:0","input_tokens":9000,"cache_read_input_tokens":170000,"cache_creation_input_tokens":1000,"output_tokens":500}'
[ "$(guard_verdict "$REC_VER")" = 0 ] || fail "claude-fable-5-20260115-v1:0 at 180000 should keep the family row (200000), not the default"

REC_FULL='{"model":"claude-fable-5","content":"PADDING","input_tokens":9000,"cache_read_input_tokens":170000,"cache_creation_input_tokens":1000,"output_tokens":500}'
[ "$(guard_verdict "$REC_FULL")" = 0 ] || fail "uncropped record should resolve its family row"
cropped="$(printf '%s' "$REC_FULL" | tail -c 110)"
[ "$(guard_verdict "$cropped")" = 1 ] || fail "a tail -c cut mid-JSON that drops 'model' should degrade to the default, not stay unmatched"

ok "context-rot guard: grep -n -o extraction, ENVIRON[] escapes, dynamic regex, tail -c mid-JSON cut"

echo "SMOKE PASS ($(uname -s), bash $BASH_VERSION)"
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,29 @@ All notable changes to ClaudeZero are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project uses
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.0.17] - 2026-08-04

### Added

- `CLAUDEZERO_DEPENDENCY_WAIT` — ceiling on the wait after a zero-mode session walks the whole
todo list and claims nothing because every unchecked task is dependency-blocked. Without it, a
fully dependency-blocked list looked identical to a genuinely stuck one: a fresh claude session
launched, found the same block, and ended its turn — one session burned per cycle for zero
possible progress. The session now marks the block on its way out, and the shell waits,
comparing a deterministic signature of the todo blob and the ids peers currently hold, instead
of relaunching blindly — stopping the instant a peer merges the blocking task or its holder
dies, or after this ceiling, whichever comes first. Default `10m`; same grammar as
`CLAUDEZERO_WATCHDOG`; `0` relaunches immediately every cycle, same as before this existed
(ISSUE-034).

### Changed

- The context-full restart signal is now computed in ClaudeZero's own Stop hook, from a
threshold table (`CONTEXT_THRESHOLDS`) at the top of `claudezero.sh`, instead of depending on
a third-party `suggest-compact` hook — one prerequisite instead of two. Existing installs need
no action: a still-installed `suggest-compact` hook keeps writing a file nothing reads, so it
is inert, not conflicting, and removing it is optional (ISSUE-035).

## [0.0.16] — 2026-08-03

### Added
Expand Down
35 changes: 26 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Runs [`claude`](https://claude.com/product/claude-code) on a predefined prompt i
- [Quickstart](#quickstart)
- [Install](#install-for-the-claude-coding-agent)
- [Todo file format](#todo-file-format)
- [Context Rot](#context-rot)
- [Loop engineering](#loop-engineering)
- [Usage](#usage)
- [Cleanup](#cleanup)
Expand All @@ -33,7 +34,7 @@ Runs [`claude`](https://claude.com/product/claude-code) on a predefined prompt i
## What it does

- **Guides Claude to zero a todo list unattended** — one task at a time until all are completed, committed, and checkmarked.
- **Beats context rot** — session Stop hook SIGTERMs `claude` at ~80% of the context window and restarts clean. Fresh context, no quality decay.
- **Beats context rot** — session Stop hook SIGTERMs `claude` once its context total crosses a threshold (see [Context rot](#context-rot)) and restarts clean. Fresh context, no quality decay.
- **One task per session** — `claude` exits once it has zeroed a single todo and the script restarts it, so every task runs on a context isolated from the task before it, which cuts token spend (~20% on a working instance). An instance that has nothing to claim waits in the shell without launching `claude` at all, spending nothing.
- **Parallel by default** — run many instances at once; they coordinate via git worktrees, each claiming todos the others haven't taken.
- **Safe merges** — cross-instance merge-back serialized through `flock`; no races, no corrupted base.
Expand Down Expand Up @@ -120,15 +121,9 @@ Supported on **macOS and Linux** (the script is bash-3.2-safe, so stock macOS `b
brew install flock # macOS; Linux ships it in util-linux
```

2. **suggest-compact hook** — the context-full restart signal. Install globally in `~/.claude/settings.json`. Requires `node`.
- Source: https://github.com/affaan-m/ECC — pin commit `7777656` (known-good with this release; later commits may change the state-file name or path).
- ClaudeZero only *reads* the hook's state file — no hook edits needed.
This prerequisite is guard-checked at startup; the script exits with a clear message if it is missing.

**State-file contract.** ClaudeZero couples to one artifact the hook produces: a per-session file at `$TMPDIR/claude-context-bucket-<session_id>` (`<session_id>` is the Claude session id; `$TMPDIR` matches node's `os.tmpdir()`). The hook must create this file once the context bucket crosses its threshold. Content is not parsed — **presence alone is the "context full, restart" signal.** Any hook that writes that file, at that path, on threshold works; the pinned commit is just the version verified to do so.

Both prerequisites are guard-checked at startup; the script exits with a clear message if either is missing.

**Transcript-schema contract.** The token figures in the execution-stats report are a second coupling to Claude Code internals, alongside the state file above. ClaudeZero's own Stop hook records each session's `transcript_path` (a field of the hook payload it already parses `session_id` from), and after `claude` exits the loop reads those session JSONL transcripts and sums the `message.usage` fields of every assistant line: `input_tokens`, `output_tokens`, `cache_creation_input_tokens`, `cache_read_input_tokens` — the four categories Anthropic bills separately. One API request is written as several transcript lines, one per content block, each repeating the same `usage` object verbatim, so records are **deduped by the line's `requestId`**, and only the first (parent) match of each field name on a line is taken: `usage.iterations[]` repeats all four names one level down, and `usage.cache_creation` carries the `ephemeral_5m`/`ephemeral_1h` leaves that already sum into the parent. Transcripts are only ever read, and no schema change can fail a run — any parse miss prints `Tokens: n/a` and the run continues.
**Transcript-schema contract.** ClaudeZero couples to one thing in Claude Code internals: the session transcript's `message.usage` schema. ClaudeZero's own Stop hook records each session's `transcript_path` (a field of the hook payload), and reads it twice, for two different readers. The **context-rot guard** (below) reads the newest usage record on every turn to decide whether to restart. The **token report**, after `claude` exits, reads the whole transcript and sums the `message.usage` fields of every assistant line: `input_tokens`, `output_tokens`, `cache_creation_input_tokens`, `cache_read_input_tokens` — the four categories Anthropic bills separately. One API request is written as several transcript lines, one per content block, each repeating the same `usage` object verbatim, so the token report **dedupes by the line's `requestId`** — a rule that belongs to the token report alone, since the context-rot guard keeps only the latest record regardless of request. Both readers take only the first (parent) match of each field name on a line: `usage.iterations[]` repeats all four names one level down, and `usage.cache_creation` carries the `ephemeral_5m`/`ephemeral_1h` leaves that already sum into the parent. Transcripts are only ever read, and no schema change can fail a run — any parse miss on the token report prints `Tokens: n/a`, and any parse miss on the context-rot guard leaves the session running; either way the run continues.

Two limits: **subagent tokens are invisible** — a session that used the Agent tool writes no `isSidechain` usage lines, so anything the task prompt spawns is missing from the totals, and the size of the under-count is not measurable from inside; and the figures are **per instance run, for this repo only**, unlike whole-machine tools such as `ccusage`, whose denominator is every Claude Code session on the box.

Expand All @@ -149,6 +144,22 @@ GitHub-style Markdown checkboxes, one task per line. Each line carries a **uniqu

`[ ]` = still to do, `[x]` = done (skipped). Before zeroing, the LLM validates the whole file: a task missing an id, or a duplicate id, stops the loop with a report. Checkboxes inside fenced code blocks (```` ``` ````) are ignored.

## Context rot

The session Stop hook computes its own restart signal. Models whose plain id means a 1M window restart at **200.000** tokens; everything else restarts at **160.000**, 80% of an assumed 200k window. Matching is first-match-wins against the model id, falling to the 160.000 default when nothing matches.

Why a threshold below the context window limit at all: **context rot**. A long session accumulates tool output, dead ends and superseded reasoning that stay in the window and compete for attention, so quality decays well before the window fills — ClaudeZero restarts earlier. Which is possible due to amnesia by design, durable external state carrying what mattered forward (see [Closing the loop](#closing-the-loop)). Restarting early is also the cheaper direction, since every turn re-sends the whole context; without a restart the re-orientation to another task costs tokens.

The two numbers rest on different evidence:

- **200.000 for a 1M window**, anchored on Opus 4.8, the only high-confidence figure. Its [system card §8.9](https://www-cdn.anthropic.com/0b4915911bb0d19eca5b5ee635c80fef830a37ea.pdf) reports GraphWalks BFS 85.9 @256k → 68.1 @1M and Parents 99.3 → 83.3, its harness compacts at 200k, and [CodeRabbit](https://www.coderabbit.ai/blog/opus-4-8-release) independently sees it "degrade visibly once context crosses 200k"
- Opus 4.6 and Sonnet 4.6 bracket the same knee on MRCR v2
- Opus 5 and Sonnet 5 publish no depth-resolved eval, yet still compact at 200k, so "holds throughout 1M" is a claim with nothing measuring it
- Fable 5 and Mythos 5 are absent from the evidence entirely — one measured curve, four families inheriting it
- **160.000 for the 200k default** — 80% of the assumed window, and **inference, not measurement**: no 200k model publishes a long-context eval. Haiku 4.5's [system card](https://assets.anthropic.com/m/99128ddd009bdcb/original/Claude-Haiku-4-5-System-Card.pdf) only notes it "frequently encounter[s] physical context-window limits", putting its knee nearer 80–100k — so 160000 is the permissive end, and Haiku 4.5 the standing candidate for its own row.

The model-threshold table is defined as `CONTEXT_THRESHOLDS` in the claudezero script.

## Loop engineering

[Loop engineering](https://claude.com/blog/getting-started-with-loops) shapes an agent's iteration cycle so it gets *better* across turns, not just runs once. It is the outermost of three nested levels — each one only works because the one under it holds:
Expand Down Expand Up @@ -232,6 +243,12 @@ CLAUDEZERO_MAX_LOOPS=3 claudezero todo.md
CLAUDEZERO_WATCHDOG=45m claudezero todo.md
```

**`CLAUDEZERO_DEPENDENCY_WAIT`** — ceiling on the wait after a claude session walks the whole todo list and claims nothing because every unchecked task is dependency-blocked (step 2.a's independence judgment). Default `10m`; same grammar as `CLAUDEZERO_WATCHDOG` (`900`, `90s`, `15m`, `1h`), and `0` relaunches claude immediately every cycle, same as before this existed. Without it, a fully dependency-blocked list looks identical to a genuinely stuck one: a session launches, finds every remaining task blocked, ends its turn, `RESTART_WAIT` ticks down, another launches — same judgment, same nothing-claimed outcome, one claude session burned per cycle for zero possible progress. The session marks the block on its way out (`.git/zero.sh no-claim-mark`); the shell then waits, comparing a deterministic signature (the todo blob's SHA plus the sorted set of ids peers currently hold) instead of relaunching, and stops waiting the instant a peer merges the blocking task or its holder dies — or after this ceiling, whichever comes first, so a session always gets a chance to re-judge the list fresh.

```sh
CLAUDEZERO_DEPENDENCY_WAIT=20m claudezero todo.md
```

**`CLAUDEZERO_LINK`** — comma-separated top-level names symlinked from the repo root into every task worktree. Unset by default. A worktree is a checkout of tracked files only, so anything gitignored is absent there: if your todo lines point at spec files you keep in another git repository — `issues/ISSUE-031.md` holding the acceptance criteria for `- [ ] ISSUE-031 …` — the session never sees them and works from the one-line title alone. Listing the directory here links it in, so the criteria are readable and a tick lands in the real file rather than in a copy the worktree removal deletes. Each linked name is added to `.git/info/exclude`, so it stays out of the session's `git add -A` and out of this repository.

```sh
Expand Down
2 changes: 0 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@ do anything I could do at this terminal."
- **CLAUDE.md steers every run.** The reflection loop lets the agent append to
it; a poisoned CLAUDE.md redirects all future tasks. Review its diffs like
any other code.
- **The suggest-compact hook is third-party** (affaan-m/ECC) and runs in your
Claude session. Audit and pin it; ClaudeZero only reads its state file.

### Blast radius, and how it's bounded

Expand Down
Loading
Loading