45 KiB
Design: Trained implementer, forked per block (spire:forks)
Date: 2026-10-16
Designer: model id claude-opus-5-5 (stage 3; does not plan or build)
Goal: goals/headless-forks.md, including its "Decided 2026-10-16" section
Inputs: research/2026-10-16-00-claude-headless-fork-sessions.md (claim ids such as 3a, F4c refer to it), reports/2026-10-15-23-athanor-trunk-fork-provenance.md, reports/2026-10-15-22-spire-vs-superpowers-plan-and-build-experiment.md §5
Touches: new skill plugins/spire/skills/forks/ (SKILL.md, forks.mjs, preamble.md, templates/), plugins/spire/agents/athanor.md, plugins/spire/skills/implementation/SKILL.md, references/spawning-claude-code.md, references/run-log.md, references/models.md, plugins/spire/.claude-plugin/plugin.json (version)
Status: one approach, for the planner (stage 4) and the attacker (4b)
Marking: Fact means verified in the research (claim id given) or checked by the designer today (claude --help of 2.1.283, node --version, which). Assumed means not verified; the planner's preflight must check it (§10).
1. The shape in one picture
Igor ──Agent──► Athanor (Opus, plan level: plan, design, trunk notes, reports, run log; no code reads)
│
│ Bash (foreground, ≤ 9 min per call)
▼
node forks.mjs train / fork / rebase / continue / wait / usage / list / expire
│ spawns detached supervisor per call
▼
supervisor ──► claude -p --resume <base> --fork-session (headless, scrubbed env,
│ full fingerprint)
└─► registry.json, ledger.jsonl, results/<job>.json (next to the run log)
Base B1 (trained once: reads code, writes trunk notes) ──fork──► F1.1 F1.2 F1.3 … (one per complex block)
└─rebase at phase boundary──► B2 ──fork──► F2.1 …
Mechanical / distant blocks: fresh Agent-tool agents reading the trunk notes (unchanged path).
The Athanor never builds a block itself and never reads code. The helper owns every claude -p command line, the registry, the cache bookkeeping and the usage ledger. The Athanor writes briefs, reads handoff notes and helper JSON, runs mechanical gates and test commands, and keeps the run log.
2. The skill: spire:forks
Name: forks (directory plugins/spire/skills/forks/). Short, says what it does, and lifts to Dirigent as one folder.
Files:
| File | Role |
|---|---|
SKILL.md |
Contract: when to use, commands, the rules the Athanor follows, the JSON it gets back. Short. |
forks.mjs |
The helper. One file, Node built-ins only, no npm install. |
preamble.md |
Appended system prompt for every base (--append-system-prompt-file): role of a trained implementer that will be forked, handoff-note format, "do this yourself; do not dispatch agents", stamp model id. Part of the fingerprint (hashed). |
templates/train.md, templates/fork.md, templates/rebase.md |
Fixed prompt frames the helper wraps around the Athanor's brief. Versioned with the skill. |
Who loads it, when: the Athanor, by frontmatter preload (skills: gets spire:forks), so it is present from intake. The implementation skill names it in "Context" (§7) as the build path for complex blocks. Igor does not load it; Igor only sees its numbers in the run log. Any other agent may load it for a non-build use, but the Athanor procedure is the only contract this design defines.
SKILL.md head (pattern of adepts):
---
name: forks
description: Trains one headless implementer per build and forks it per block via `claude -p --resume --fork-session`. Owns the command line, the cache bookkeeping and the usage ledger. Loaded by the Athanor.
allowed-tools: Bash(node:*)
---
!`node "${CLAUDE_SKILL_DIR}/forks.mjs" where`
where prints the absolute path of forks.mjs, the resolved binary and its version, the node version, and whether the env scrub list matches the host. The Athanor uses the printed path in every later call. Assumed: a preloaded skill's ! injection runs at agent start, and allowed-tools: Bash(node:*) pre-approves the later calls (the adepts skill relies on the same pattern for bash; its own comment calls it novel).
3. The helper forks.mjs
3.1 Runtime choice: Node
Checked on this host today (Git Bash): node v24.8.0 at /c/Program Files/nodejs/node; bash 4.4.23; no jq; no python/python3 (Python 3.7 directory is on PATH but no executable resolves); Strawberry perl present.
- Node, chosen. JSON is native (the whole job is parsing
stream-json,modelUsageand the registry).child_process.spawntakes an explicitenvobject, so the scrub is a dict operation, notenv -uquoting through Git Bash. Detached spawn, kill-tree, atomicrename,open(..., 'wx')lockfiles and path normalisation work on Windows and POSIX. The research already usednodefor JSON here (claim 7). - Assumed about other hosts:
node≥ 18 is on PATH wherever Spire runs (the native Claude Code installer does not need Node, so this is not guaranteed).wherefails loudly with "node not found" and the Athanor falls back to the fresh-agent path, logged. - Rejected: bash + jq (no jq here; JSON in bash is fragile). Python (not on PATH). PowerShell (not on Linux remote hosts; the repo's scripts are bash). Perl (works, but nobody in this repo maintains Perl). The Agent SDK in TypeScript (needs
npm install, spawns the CLI anyway; that is the Dirigent-later path).
3.2 State location: next to the run log, per run
A trained base belongs to one build plan, and the 1 h TTL (F4a) means no base usefully outlives a run. The state lives beside the run log so a successor Athanor and the reviewer find it:
<workpad>/reports/<YYYY-MM-DD-hh>-<slug>-run.md the run log (unchanged name rule)
<workpad>/reports/<YYYY-MM-DD-hh>-<slug>-run-forks/ helper state, owned by forks.mjs
registry.json bases, jobs, config; never hand-edited
ledger.jsonl one line per finished call, append-only
briefs/<job>.md the exact prompt sent (frame + Athanor brief + delta header)
notes/<block>.md handoff notes written by forks (one file each: parallel-safe)
results/<job>.json the per-call summary `wait` returns
logs/ gate and test output written by forks and the Athanor
raw/<job>.jsonl stream-json stdout ┐ gitignored by the helper's own
raw/<job>.err stderr ┘ .gitignore (raw/, *.lock)
Every command takes --run <path to run log .md>; the state dir is derived from it. The run log header gets one line, Forks: <state dir>. This is a dated subfolder for a cascade of related files, as the workpad skill allows.
3.3 Fingerprint
Any difference in model, tools, permission mode or binary loses the cache (3b, 4b, F5b), and flags are not inherited by a fork (4b, 4c). So the Athanor passes launch settings once, at train; fork, rebase, continue and pings read them from the base record. The Athanor cannot get a fork's flags wrong because it never supplies them.
Launch fields (hashed into fingerprint.hash, canonical JSON, sha256):
| Field | Source / default | Why |
|---|---|---|
binary.path |
--binary, else $CLAUDE_CODE_EXECPATH if set, else command -v claude |
Two installs exist (F5a). The Desktop's bundled binary matches the host and runs the newest models: Sonnet 5.5 needs ≥ 2.1.284 (F2c), the PATH install is 2.1.283. |
binary.version, binary.size, binary.mtime |
<binary> --version, stat |
Path alone is not enough: a native install can auto-update in place. Hashing a large exe on every call is too slow; size + mtime + version is the check. |
model |
required, full id (^claude-[a-z0-9-]+(\[1m\])?$); aliases refused |
Aliases move with the CLI version (F2c); plan mode swaps Haiku to Sonnet (F2a). Goal decision: always a full id. Assumed: --model accepts the [1m] suffix. |
effort |
required (--effort), skill default high |
The host passes CLAUDE_EFFORT=medium (F2b) and user settings.json has effortLevel: medium; a user changing effort in the Desktop mid-run would change a fork's effort. Assumed: effort changes the request (thinking settings) and so the cache. |
permission_mode |
default dontAsk |
Never plan (F2a). dontAsk denies anything not pre-allowed (4a) and so needs the explicit list below. |
tools |
default Bash,Read,Edit,Write,Glob,Grep,Skill |
No Task/Agent: forks are leaves, which also removes the depth question (F1c, F1d). Assumed: Skill is a valid --tools name. |
allowed_tools, disallowed_tools |
default: allow = tools; deny = Bash(git push:*), Bash(git rebase:*), Bash(git reset --hard:*), Bash(claude:*), plus project additions |
Same guard rails as today's children; no nested claude from a fork. |
mcp |
default --strict-mcp-config --mcp-config <state>/mcp-empty.json |
Not restored on resume (4c), so it is passed every time. Pins the tool list against claude.ai connectors toggling mid-run. Assumed: strict mode also drops claude.ai account connectors (F3b shows they load otherwise). |
agent |
optional --agent <name>; stored as name + sha256 of the resolved file |
Goal: a project agent file can specialise the implementer. Assumed: --agent resolves project .claude/agents/<name>.md; plugin-namespaced names untested; how the agent body combines with the default system prompt untested. |
append_system_prompt |
preamble.md, stored as sha256 |
Recorded at first request and reused (4c; --system-prompt-snapshot on is the default, designer read it in --help today). Passed identically every time anyway. |
cwd |
--cwd, default the git toplevel; stored normalised |
Forks must run from the base's cwd (F4d); the transcript path derives from it (6d). |
env.pinned |
CLAUDE_CODE_PROMPT_CACHE_TTL=1h, DISABLE_AUTOUPDATER=1 |
TTL constant across base and forks (F4b). Assumed: DISABLE_AUTOUPDATER stops an in-place update of the PATH install mid-run. |
env.scrubbed |
list in §4 |
Runtime fields, taken from the system/init event of every call and compared with the base's: sorted tool names (hash), permissionMode, plugin names with versions, agent names, apiKeySource (assumed field name). A difference sets fingerprint: drift:[fields] on the result. Before every launch the helper re-reads binary.version/size/mtime; a change is a binary drift (rule in §5).
init.model is not recorded as the model. It lies under plan mode (F2a, F2c); the real model comes from modelUsage (§6).
3.4 Commands
All commands print one JSON object on stdout and use exit codes: 0 done/ok, 2 usage or refused, 3 wait timed out with jobs still running, 4 a job failed.
| Command | Inputs | Does | Returns |
|---|---|---|---|
where |
— | Preflight print for SKILL.md. | script path, binary, versions, node, scrub check |
init |
--run, launch fields (§3.3), --parallel-cap (default 2), thresholds (§5) |
Creates the state dir, .gitignore, mcp-empty.json, the config block in registry.json. Idempotent. |
config |
train |
--run, --base <name>, --brief <file>, optional launch overrides (else the init config) |
New session: claude -p --session-id <uuid> with the full fingerprint and templates/train.md around the brief. On finish records base_commit (git rev-parse HEAD), training cost, context size, last hit. |
{job, base, session_id} |
fork |
--run, --base, --block <id>, --phase <n>, --brief <file>, --mode sequential|parallel, --max-minutes (default 90), --max-budget-usd (optional) |
--resume <base session> --fork-session with the base's fingerprint, templates/fork.md + delta header + brief. Refuses a different model or cwd (that is a new base), refuses when running jobs on the run would exceed the parallel cap, refuses parallel mode unless the brief names its file set. |
{job, base, base_state: warm|expiring|cold, warm_until, rebase_due, retrain_due, reasons[]} |
rebase |
--run, --base, --as <new name>, --brief <file> |
A fork whose job is to absorb what changed (templates/rebase.md: read the listed notes and diffs, do not edit code, update the trunk notes, reply READY). Its session becomes base <new name> with parent; the old base is superseded. |
as train |
continue |
--run, --job <fork job>, --brief <file> |
--resume <fork session> without --fork-session: one more turn in that fork (gate failed, test fallout). Same fingerprint. Not a SendMessage resume, so no notification-routing hazard. |
{job} |
wait |
--run, [job…] | --all, --any, --timeout <s> (default 540, max 570) |
Blocks until the condition holds or the timeout. Runs the keep-warm tick (§5) every 30 s while it waits. | {done:[result…], running:[{job, block, elapsed_s, last_event_s_ago, turns}], pings:[…]} |
usage |
--run, --phase, --block, --format json|md |
Aggregates ledger.jsonl. md prints rows for the run log. |
totals and rows (§6) |
list |
--run |
Bases (status, warm state, warm_until, context tokens, training cost, forks, lineage) and running jobs. |
|
expire |
--run, --base <name> | --all |
Marks bases retired: no forks without --force, no pings. Transcripts stay on disk (30 days, 6d). |
|
kill |
--run, --job |
Kills the supervisor and its claude process tree (taskkill /T /F on Windows, process group on POSIX); result killed. |
Internal: _supervise <job> (the detached worker) and tick (one keep-warm pass, also callable by hand).
3.5 Launch and supervision
- Each launching command writes the job record, then spawns
node forks.mjs _supervise <job>detached (detached: true,windowsHide: true, stdio toraw/) and returns at once. The supervisor spawnsclaudewith the scrubbed env,--output-format stream-json --verbose, and the prompt frombriefs/<job>.mdon stdin. - The supervisor reads the stream as it arrives: the
system/initevent gives the session id and the runtime fingerprint; each assistant event bumpsturnsandlast_event_at; the finalresultevent givesusage,modelUsage,total_cost_usd,num_turns,is_error,permission_denials. Assumed: the stream'sresultevent carries the same fields as--output-format json(1averified them forjson). - On exit it writes
results/<job>.json, appends toledger.jsonl, updatesregistry.jsonunder the lock (registry.lock,open wx, stale after 30 s, write-temp-then-rename). - Watchdogs:
--max-minutesexceeded → kill, statustimeout. No stream output for 15 min → status flagstalled(reported bywait, not killed).permission_denialsnon-empty →needs_attention(a deniedgit commitwould otherwise pass silently,4a). - Fork session id: taken from
init. Unknown whether--session-idcombines with--resume --fork-session; if it does, the helper may pre-assign ids, but nothing depends on it. - Fallback if detached children do not survive on this host (R1):
--attachruns the supervisor in the calling process; the Athanor starts it with the Bash tool'srun_in_background: trueand still polls withwait. The Athanor never relies on the background notification.
4. Env scrub and auth
Dropped from the child env (the twelve the research removed in E1 with auth still working, F3a, F3b):
CLAUDE_CODE_MESSAGING_SOCKET, CLAUDE_CODE_MESSAGING_TOKEN, CLAUDE_CODE_HOST_SESSION_ID, CLAUDE_CODE_ENTRYPOINT, CLAUDE_CODE_DESKTOP_APP_VERSION, CLAUDE_CODE_SDK_HAS_HOST_AUTH_REFRESH, CLAUDE_CODE_TERMINAL_MCP_TOOLS, CLAUDE_CODE_REPORT_FINDINGS, CLAUDE_CODE_SESSION_ID, CLAUDE_AGENT_SDK_VERSION, CLAUDE_CODE_ENABLE_ASK_USER_QUESTION_TOOL, CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING.
Also dropped, because they would change the fingerprint or the auth path:
CLAUDE_EFFORT(effort comes from--effort),ANTHROPIC_MODEL,ANTHROPIC_DEFAULT_*_MODEL,ANTHROPIC_SMALL_FAST_MODEL,CLAUDE_CODE_SUBAGENT_MODEL(none present today,F2b; dropped so a future host cannot slip one in).ENABLE_PROMPT_CACHING_1H,FORCE_PROMPT_CACHING_5M,CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL(TTL is pinned, §3.3).ANTHROPIC_API_KEY,ANTHROPIC_AUTH_TOKEN,CLAUDE_CODE_USE_BEDROCK,CLAUDE_CODE_USE_VERTEX: with any of these the run would bill elsewhere and get 5 min TTL (F4a). Goal decision: OAuth.
Kept as in the tested E1 run: CLAUDECODE, CLAUDE_CODE_CHILD_SESSION, AI_AGENT, CLAUDE_CODE_EXECPATH, CLAUDE_PID, CLAUDE_CODE_GIT_BASH_PATH, PATH, HOME/USERPROFILE. Each kept var's role is unverified (F3a); keeping E1's set means the design runs the configuration that was tested.
Auth stays OAuth: no --bare (it refuses OAuth, F3d), no key in the env. The helper checks each init's apiKeySource (assumed field; expected none under OAuth) and the observed TTL (§5): a call that wrote only ephemeral_5m means the account fell out of the 1 h bucket (over plan limit, F4a), and wait reports ttl_downgrade: true. The scrub list lives in one constant in forks.mjs; where reports host vars that look Desktop-specific (CLAUDE_CODE_* not on either list) so a new host variable shows up instead of leaking silently.
Not settled by the scrub: whether the nested process's own messaging pipe can reach the host (F3c). The scrub removes the host socket and token; nothing more is claimed.
5. Keep warm, expire, rebase, re-train: the rule
All inputs come from the registry and the ledger; the Athanor only obeys the flags.
Per base:
ttl = 3600 s if the base's last call wrote ephemeral_1h tokens, or read cache and the
previous known ttl was 3600; 300 s if it wrote only ephemeral_5m; else 3600
(config default for subscription). [F4a, 3c]
last_hit = time of the first assistant event of the most recent call on this base or any of
its forks/pings (the first request reads the base prefix). [F4c]
warm_until = last_hit + ttl
margin = max(120 s, ttl / 10) → 360 s at 1 h, 120 s at 5 min
state = warm if now < warm_until - margin
expiring if now < warm_until
cold otherwise
Assumed: a hit on the base prefix refreshes that prefix's entry (F4c is docs, not tested over time), and only a request that reads exactly the base prefix (a fork's first request) refreshes it; later requests in a fork read longer prefixes. Using the first assistant event, not the call end, is the conservative reading.
Keep warm (pings): a ping is --resume <base> --fork-session, prompt Reply OK, --max-turns 1, same fingerprint, logged as kind ping. Price ratios against the base input price, from Anthropic API pricing (assumed current, not re-checked in the research): cache read 0.1, 1 h write 2.0, 5 min write 1.25. One ping costs about 0.1 × prefix; one cold start about (write − read) × prefix, 1.9 × prefix at 1 h. So pings pay while fewer than break_even = floor((write − read) / read) are needed: 19 at 1 h, 11 at 5 min.
ping base when: base.status == active
and state == expiring
and the run has a running job or a wait call in the last 10 min (the Athanor is in its turn)
and pings since the base's last real call < break_even
The tick runs inside every supervisor (every 60 s) and inside wait (every 30 s). No daemon: when the Athanor has handed back and nothing is running, nobody pings, and the base cools after its TTL. That is intended: between phases the base is rebased anyway.
Expire: a base is retired by expire, superseded by rebase or a re-train. A cold base stays forkable: the history is on disk (2b, 6d), only the cache is lost. Coldness is never a reason to re-train. A fork of a cold base is logged cold_start with its creation tokens.
Rebase due (flag on fork's return; the Athanor rebases before forking):
- phase boundary (procedural: the first fork of a new phase always comes from a rebased base);
- delta since
base_commitabove threshold: more than 6 commits, or more than 1,500 changed lines (git diff --shortstat), or a delta header plus listed notes above about 12k tokens (chars / 4); - binary drift (version, size or mtime changed): the rebase runs under the new binary and becomes the new fingerprint; one cold write, recorded.
Re-train due (fresh train, new lineage):
- the requested model, cwd, tools, permission mode, agent or preamble differs from the base (these define the base; a fork cannot carry a change);
- base context above
max_base_tokens(default 250k), measured from the last assistant message's usage in the base transcript (~/.claude/projects/<cwd slug>/<id>.jsonl,6d; assumed message-levelusageis present in the transcript lines), leaving room under the ~600k comfortable figure (models.md, a guesstimate); - four rebases in one lineage (prefix growth).
Thresholds are starting values in init; the trial (§9) tunes them. They are numbers, not judgment.
6. Usage capture
Per call, from the result event:
- Real model: the keys of
modelUsagewhose counts grew in this call. Notinit.model(F2a,F2c). - Tokens, four columns:
input,output,cache_creation,cache_read, pluscache_creationsplit into1hand5mfromusage.cache_creation. Source: the per-model delta ofmodelUsageagainst the session's totals before the call (modelUsageandtotal_cost_usdare cumulative across the restored session,3d,F4e). Top-levelusageis stored too; the T2 numbers (creation 343 against the base's 8,661) suggest it is per call, so the two must agree; disagreement setsusage_mismatch(assumed semantics for multi-turn calls, R5). - Cost:
cost_delta_usd = total_cost_usd(call) − total_cost_usd(session before call)(3d,F4e). For a fork, "session before" is the base's cumulative total at the fork point; forcontinue, the fork's own last total. Never sumtotal_cost_usdacross calls. These are Claude Code's client-side estimates; under a subscription they are notional, which is still the right unit for comparison. - Cache verdict:
hitifcache_read ≥ 0.9 × base prefix,partialif > 0,missif 0 (a miss on a fork is a fingerprint bug or a cold base;waitsays which). - Also:
wall_s,num_turns,is_error, denials count, fingerprint verdict, binary version.
Ledger line (ledger.jsonl):
{"job":"j0007","kind":"fork","phase":1,"block":"1.3","base":"B1","session":"7ccea6b5-…",
"parent_session":"433df7b7-…","started":"2026-10-16T14:02:11+02:00","ended":"…","wall_s":1310,
"binary":"2.1.299","model_requested":"claude-sonnet-5-5","models_used":["claude-sonnet-5-5"],
"tokens":{"input":5120,"output":38210,"cache_creation":61200,"cache_creation_1h":61200,
"cache_creation_5m":0,"cache_read":2840100},
"cost_delta_usd":1.92,"cost_cumulative_usd":4.37,"turns":57,"cache":"hit","base_state":"warm",
"fingerprint":"ok","denials":0,"status":"ok","note":"notes/1.3.md"}
Into the run log: the Athanor fills each block row from usage --block <id> --format md and pastes usage --phase <n> --format md into the phase close: per-kind totals (train, rebase, fork, continue, ping), cold starts, cache hit ratio, priced total. The ledger file stays the source; the run log carries the copy the reviewer and Igor read. Goal said "writes them to the run log": the helper writes the ledger and renders the rows; the Athanor pastes them, because a script appending to a markdown file the Athanor is also editing would interleave. This is a design decision (see the end).
Agent-tool agents (mechanical blocks) report usage only in their completion result; the Athanor copies what it sees, as today. The root and the Athanor's own tokens are counted from transcripts in the trial (§9), not by the helper.
7. Blocking and polling
The headless process is invisible to Agent-tool tracking (6c), and a background Bash notification from inside a subagent is not something to build on (routing hazard in the spawning reference; unverified for Bash). So:
- Launch returns at once (detached supervisor). Waiting is a foreground Bash call to
wait, which blocks up to 540 s, under the Bash tool's 600 s ceiling (6c). Exit code 3 means "still running, call again". The Athanor loops:wait --all --timeout 540until exit 0 or 4. A 36-minute block costs about fourwaitturns of a plan-level Athanor context, mostly cache reads. - Parallel forks: only for blocks the plan marks disjoint (the implementation skill's existing rule). Launch each with
--mode parallel, thenwait --all. Cap 2 by default (init --parallel-cap): forks on a subscription share plan usage, and an over-limit account drops to 5 min TTL (F4a). Two forks from one base in parallel are verified (2c); tool-using parallel forks in one worktree are not (R3). - Timeouts: per job
--max-minutes(default 90) enforced by the supervisor;--max-budget-usdoptional per call (counts the call's own spend,F4e).stalledis reported, not acted on; the Athanor kills or waits per the procedure (one morewait, thenkilland flag). - Hand back only with nothing running: before the deliverable,
listmust show no running jobs, elsekilland flag. - The Athanor never uses
SendMessageon a fork and never needsTaskStop;continueandkillcover it.
8. Athanor procedure
8.1 What the Athanor holds
The build plan, the design, the run log, trunk notes, handoff notes, review reports, helper JSON, git log --oneline, git diff --stat/--numstat, gate and test summaries. Not source files. Its context stays plan-sized, so claude-opus-5-5[1m] keeps its pin but is no longer what pays for reading code (in the experiment, 53 M of 55.6 M Athanor tokens were cache reads of a 1M-context session that had read the trunk).
8.2 One phase per turn
- Intake. Read plan, design, run log, trunk notes, the last review. Check the branch (
git rev-parse --abbrev-ref HEAD). First phase:forks initwith the launch fields the build plan or project config names (model = the plan's owner model for complex blocks, full id; effort; agent file if the project has one). - Train or rebase.
- First phase:
train --base B1with a brief naming the plan, design, the phase's blocks, the areas to read, and the trunk-notes path. The base reads the code, writes the trunk notes, changes no code, replies READY with a short summary. Trunk notes are now the base's product, not the Athanor's. - Later phases, and whenever
forkreturnsrebase_due:rebase --as B<n+1>with the delta (handoff notes, review findings, commit range).retrain_due:traina new lineage. The helper decides which; the Athanor follows.
- First phase:
- Route blocks. Complex block in the trained area → fork. Mechanical or distant block → fresh Agent-tool agent in the foreground with the trunk notes, as today. A complex block whose owner model differs from the base model either becomes a fork anyway (logged deviation) or gets its own base; it never gets a fork with
--model(that loses the cache,3b, and the helper refuses it). - Fork per block with a delta brief (goal default): block id and plan section, the handoff notes of blocks done since the base, open review flags touching the block, the mode, the gate and test commands. The helper prepends the commit range since
base_commititself. Thenwaituntil done, readnotes/<block>.mdand the result JSON. - Check, mechanically. Commit present (
git log); the evidence log underlogs/exists and is non-empty (experiment proposal 4);git diff --numstaton test paths and a count of removed assertion lines (git diff <range> -- <test paths> | grep -c '^-.*assert', pattern from project config). A non-zero count goes to the fork's note as a question and into the reviewer flags. This replaces the Athanor's former "read the fix's diff for loosened assertions", which was a code read; the fresh phase reviewer still looks for weakened checks. - Gate between blocks: the plan's command, output to
logs/. Red →continue --job <fork>with the log path, once; still red → flag and go on per "what may stay broken". - Tests once at phase end: the Athanor runs the test command to a file under
logs/(never throughtail, experiment incident 4; first run after a fresh worktree is a warm-up, rerun once). Fallout → one fork from the current base (orcontinueof the block that caused it) with the failing log as the delta. - Run log: block rows from
usage, notes appended fromnotes/, phase close with the usage totals and every flag. - Hand back with no running jobs. Do not
expirethe base: if Igor's review and the fix pass come back within the TTL, the base is still warm; if not, it is cold but forkable.
Fix pass (Igor resumes the Athanor with findings): forks of the current base with the findings as the delta; rebase_due decides whether to rebase first.
8.3 Commits and tests: who runs them
The experiment had index-lock races between parallel children and a red commit when the Athanor committed a child's block before calibrating it (M5).
| Case | Who commits | Who runs tests |
|---|---|---|
| Sequential fork (default) | The fork, by explicit paths, after its block gate and the block's named tests pass. Calibrate, then commit. | The fork (block-scoped tests, output to logs/). |
| Parallel forks (disjoint files only) | Nobody inside the forks (no index-lock race). After wait --all, the Athanor runs the gate and each block's named test command, then commits each block by the explicit paths its note lists, one at a time. Mechanical; no code read. |
The Athanor, after all parallel forks finish. Forks run nothing that touches shared state unless project config says parallel test runs are safe. |
| Fresh Agent-tool agent | As a sequential fork. | As a sequential fork. |
| Phase test suite | — | The Athanor, once, to a file. |
Only one committer exists at any moment: sequential forks commit one after another; parallel forks never commit.
8.4 Text changes
agents/athanor.md: frontmatterskills:addsspire:forks. Intake: drop "the code the phase touches … That reading is your trunk"; the Athanor reads plans and reports and trains the base. Step 2: replace "a fork of yourself (it inherits your model and, assumed, your context)" with "a headless fork of the trained implementer (spire:forks)"; fresh agents stay for mechanical or distant blocks. Remove inline building by the Athanor. Keepers: add "you do not read code; the base and its forks do", "nothing running at hand back", "commit rules per §8.3". Replace "read the fix's diff for loosened assertions" with the mechanical check of §8.2 step 5. Deliverable: add the usage totals and base state.skills/implementation/SKILL.md"Context: fresh, fork, or keep": rewrite "trunk and forks" as "trained implementer and forks": one base per build, trained early, forked per complex block with a delta brief, rebased at phase boundaries, fresh agents for mechanical blocks; citespire:forksfor mechanics. The Agent-tool fork path is gone. "Spawning and models": add that headless forks take a full model id. Keep the general "if one agent can do the whole change in one go, do it inline" for non-Athanor use.references/run-log.md: header getsForks: <state dir>; block table becomesBlock | Owner (model id from modelUsage) | Session | Commit | in | out | cache write | cache read | cost Δ; phase close gets theusage --phasetable; trunk notes are written by the base at training.references/models.md: "Future work: orchestrator introspection" gets a dated note that headless forks report per-call usage; add the plan-mode Haiku→Sonnet observation and the alias-by-version note (F2a–F2c).- Out of scope, flagged for Igor:
skills/igor/SKILL.md:66("forking yourself … to brief a new expert") is now close to what the base does; it may want a line.
9. Spawning-reference updates (references/spawning-claude-code.md)
Fork section (Agent tool), rows changed:
| Row today | New text and status |
|---|---|
"fork is actually offered … unverified" |
Not offered on 2026-10-05 or 2026-10-15. Spire no longer uses Agent-tool forks (goal decision 2026-10-16). observed |
| "A fork copies the caller … assumed" | A fork inherits the current conversation only; no parameter names another agent. verified 2026-10-15 (docs sub-agents, provenance §5) |
| "A fork starts with the caller's full transcript … assumed" | Same system prompt, tools, model, history as the forking session. verified 2026-10-15 (docs) |
| "Can a fork fork again? … unknown" | Agent-tool: "A fork can't spawn further forks." verified 2026-10-15 (docs). Headless --fork-session: a fork of a fork works. verified 2026-10-16 (2d, T6) |
| new | Fork mode is on by default in interactive sessions (v2.1.232+), off under -p/SDK unless CLAUDE_CODE_FORK_SUBAGENT=1; with it on, all subagents run in the background and run_in_background is removed. verified 2026-10-15 (docs) |
| new | Agent-tool forks and subagents use the 5 min cache bucket by default; the main conversation (incl. -p) gets 1 h on a subscription within plan usage. verified 2026-10-16 (F4a, docs) |
New section "Headless sessions (claude -p)", rows, each with its research id and date 2026-10-16:
--output-format jsonresult fields;stream-jsoninit fields (1a).--resumeby id across projects;--session-idto choose an id (1b,1d); don't use--continuefor fan-out (1c).--fork-session: new id, copy of history, base unchanged, forks isolated, parallel forks fine, resuming one id in two processes interleaves (2a–2c).- Cache: hits need identical tools, model, system prompt; flags are not inherited by a fork; pass them every time (
3a,3b,4b,4c). Model is restored on resume,--modeloverrides it (4d). The system prompt is recorded once and reused (4c;--system-prompt-snapshot,--help2.1.283). - Cost:
total_cost_usd/modelUsageare cumulative across a resumed session; per-call cost is a difference (3d,F4e). - TTL: 1 h main bucket on subscription, 5 min otherwise; configurable; a hit resets the timer; same cwd for shared prefix (
F4a–F4d). - Permissions under
-p: nothing can prompt; denials are listed and the run continues (4a). init.modelis not the model that ran; plan mode upgrades Haiku to Sonnet; aliases change with CLI version (5d,F2a–F2c).- Discovery: without
--bare, plugins, skills, hooks, MCP and CLAUDE.md load (5a);--bareneeds an API key (5b,F3d). - Nesting: inherits
CLAUDECODE=1, no recursion guard (6a); Agent depth is per process and not passed down; at the limit the Agent tool is simply absent, no error (F1a–F1d). - Tracking: invisible to task notifications,
ListAgents,TaskStop;--bgdoes not combine with-p(6c, the invisibility row stays assumed). - Transcripts per fork under
~/.claude/projects/<cwd slug>/(6d). - Desktop env inheritance and the scrub (
6eas corrected byF3a–F3c). - Two binaries; pin one and a full model id (
F5a,F5b).
Models section: the alias-only row stays for the Agent tool; add "headless --model takes full ids, so headless forks pin by full id".
Rules of thumb: add "complex blocks go to headless forks via spire:forks; never resume or fork a headless session by hand".
10. The success-criteria trial
Preflight first (Phase 0 of the plan, scratch repo, before any Spire text changes ship). Each item maps to a risk in §11:
- Detached supervisor survives the end of the Bash call that started it, from inside an Agent-spawned subagent on this Windows host (R1). If not, test
--attachunderrun_in_background. - A base trained to ≥ 100k tokens with real tool use; three sequential forks that read and write files; each fork's first request hits the base prefix (R2).
- Two parallel tool-using forks on disjoint files in one worktree; no index lock (they don't commit); gate afterwards (R3).
modelUsagedelta equals top-levelusageon a multi-turn fork;stream-jsonresulthas thejsonfields (R5).- A ping after 50 min keeps a fork at 60+ min warm; a fork after 65 min without ping is
cold(R7). This is the only test that needs wall time; it can run beside the others. --effort,--strict-mcp-config,--agent,Skillin--tools,[1m]in--model,apiKeySourcein init: each accepted and visible in init (R8, R9, R15).
Any failure in 1, 2 or 4 stops the plan and goes back to Igor (§11, "what would make this wrong").
The trial (goal: "Proven, not assumed"):
- Plan: one real multi-block plan the user picks, with at least two phases and at least three complex blocks in one area (otherwise forks have nothing to share). Not this skill's own build: the tool under test cannot measure itself.
- Arms: F = Athanor with
spire:forks(this design). C = the current 0.2.2 shape (Athanor reads the trunk, builds or delegates per its current text). Same build plan, same base commit, separate worktrees, same reviewer model and review structure (one fresh Opus reviewer per phase, one fix pass, re-check), Igor as root in both. - Isolation: sealed inputs per arm and a transcript audit with a positive control, as in the 2026-10-15 experiment (§1 there). Arm F's headless transcripts live under the worktree's project slug and are audited too.
- Quality: blind implementation judge (Fable, as before) on the two diffs; reviewer findings by severity per phase; tests and gates.
- Tokens, all four columns, priced, for every agent including the root: Igor, the Athanor, Agent-tool children, every headless call (the ledger, cross-checked against the fork transcripts with copied base history counted once, deduplicated by message id; assumed copied messages keep their ids), reviewers. Price with one published per-model table, written into the report.
- Wall time: from
dateand git, start to last re-check, per arm; Athanor active time per phase. - Mechanism metrics (arm F): cache verdicts per fork, cold starts, pings and their cost, rebases, re-trains, permission denials, fingerprint drifts,
ttl_downgrade. - Report:
reports/<stamp>-headless-forks-trial.md. Thresholds in §5 are tuned from it.
11. Risks and unknowns for the planner and attacker
| # | Risk / unknown | Check |
|---|---|---|
| R1 | Detached children may be killed when the Bash tool call ends (Windows job object). Untested. | Preflight 1; fallback --attach + run_in_background. |
| R2 | Large tool-using bases are untested (research used tiny contexts). Cache behaviour, compaction inside a fork, and whether the recorded system prompt is reused for a 100k+ base. | Preflight 2. |
| R3 | Parallel tool-using forks in one worktree: file collisions, shared test resources. | Preflight 3; parallel only for disjoint files; cap 2. |
| R4 | Permissions. The Athanor's node calls may prompt in the host session (closed door). Forks under dontAsk deny silently. |
allowed-tools on the skill (assumed effective); needs_attention on denials; the user may need an allow rule for node */forks.mjs *. |
| R5 | Top-level usage per call vs. aggregate over turns; stream-json result field parity with json. |
Preflight 4. |
| R6 | --session-id with --fork-session. |
Not depended on. |
| R7 | TTL refresh on hit and the base-prefix refresh semantics are docs-only; cross-cwd forks untested. | Preflight 5; forks always run in the base's cwd. |
| R8 | --effort vs. CLAUDE_EFFORT and effortLevel precedence, and whether effort affects the cache. |
Preflight 6; env var dropped, flag pinned. |
| R9 | Mid-run drift outside the fingerprint: plugin updates, SessionStart hooks injecting context on resume, claude.ai connectors. | Runtime fingerprint compare on every init; --strict-mcp-config. |
| R10 | Whether the nested process's pipe can reach the host (F3c). |
Not claimed; scrub only removes the host socket and token. |
| R11 | Plan usage. Parallel Opus/Sonnet forks consume the subscription; over the limit the TTL drops to 5 min and usage may bill as credits. | ttl_downgrade flag; parallel cap; per-call --max-budget-usd. |
| R12 | Binary changes mid-run (in-place auto-update; a Desktop update replacing the $CLAUDE_CODE_EXECPATH directory). |
Version/size/mtime check before each launch; DISABLE_AUTOUPDATER=1 (assumed honoured); binary drift → rebase. |
| R13 | A fork's recorded system prompt carries the base's git status and CLAUDE.md from training time. | The helper's delta header lists commits since base_commit; the fork template says the snapshot is stale. |
| R14 | Node may be missing on other hosts. | where fails loudly; fresh-agent fallback, logged. |
| R15 | --agent with project and plugin agent files; how the agent body combines with the default system prompt and --append-system-prompt-file; agent tools: frontmatter against --tools. |
Preflight 6. |
| R16 | Safety. Forks hold full Bash under dontAsk; deny patterns are easy to sidestep. |
Same exposure as today's children; project deny rules apply; the brief's scope fence. |
| R17 | Delta briefs may leave forks with a stale view of neighbouring blocks, so they collide or contradict. | The rebase threshold; the trial's quality arm. |
What would make this approach wrong:
- Forks of a large trained base do not hit the cache reliably (R2). Every fork then pays full creation at 2× input price on top of the training, and fresh agents with trunk notes are cheaper. Stop and go back to Igor.
- Headless calls cannot run longer than one Bash call from inside a subagent on this host (R1 and its fallback both fail). The 9-minute ceiling is shorter than real blocks (11–36 min in dirigent).
- The trial shows no quality gain over arm C at higher cost. The goal's intent then still stands, but the evidence says no; that is the user's call.
- Plan-usage policy makes nested headless sessions impractical (R11).
Rejected alternatives
- Agent-tool forks, or the trained implementer forking itself. No fork-source parameter; "a fork can't spawn further forks"; fork mode forces background; depth would sit at the 3-layer limit (provenance §5). The goal drops this path.
- Carry one implementer and resume it with
SendMessage. Accumulates context (the 960k drift) and has the subagent-resume routing hazard. - MCP server now. The goal puts it later; a script is quicker to prove and lifts into Dirigent or an MCP wrapper unchanged in contract.
--bare+ API key. User chose OAuth;--barewould also cost the 1 h TTL.- Background Bash notifications or
Monitoras the wait mechanism. Unverified inside a subagent; polling files withwaitworks for any caller. - A long-lived
--input-format stream-jsonbase process fed per block. It continues one session; it cannot branch it, so forks would still need--resume --fork-session. - Registry in the user's home or the OS temp dir. Survives across runs nobody needs, and the reviewer and a successor Athanor could not find it next to the run log.
Decisions this design made that the goal did not settle
- Skill name
forks; helper in Node (no jq, no Python here). - State per run, in a
-forks/folder beside the run log; raw streams gitignored. - Usage reaches the run log by paste, not by append: the helper writes the ledger and renders markdown rows; the Athanor pastes them. Deviates from the goal's literal "writes them to the run log" to avoid interleaved writes.
- Launch flags supplied once at
init/train; forks take only base, block and brief. - Defaults:
dontAsk, toolsBash,Read,Edit,Write,Glob,Grep,Skill(no Agent),--strict-mcp-configwith no servers, explicit--effort(defaulthigh),CLAUDE_CODE_PROMPT_CACHE_TTL=1h,DISABLE_AUTOUPDATER=1, binary$CLAUDE_CODE_EXECPATHfirst. - Coldness never triggers a re-train; pings only while the Athanor is in its turn, capped at the break-even count; numeric rebase and re-train thresholds as starting values.
- Trunk notes are written by the base at training, not the Athanor.
- The Athanor's diff read for loosened assertions becomes a mechanical count; the phase reviewer keeps the real check.
- Commit ownership: sequential forks commit after calibrating; parallel forks never commit, the Athanor commits them by explicit paths after the gate.
- The Athanor no longer builds blocks inline.
- The trial needs a Phase 0 preflight on a scratch repo; failures in R1, R2 or R5 stop the plan.
Amendment 2026-10-16 01:33 — runtime (user)
The user decided that the helper is Python, run with uv (uv run, single file with PEP 723 inline metadata, standard library preferred), not Node. uv 0.5.13 is installed at ~/.local/bin/uv; python is not on PATH, so it is always invoked through uv run. Follow the conventions in plugins/project-uv/. The rest of the design stands. The planner was told mid-run. Source: session journal.