Files
reliquary/docs/workpad/plans/2026-10-16-01-headless-forks.md
T

258 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Plan: Trained implementer, forked per block (`spire:forks`)
**Date**: 2026-10-16 01:38 +0200
**Planner**: model id `claude-opus-5-5[1m]` (stage 4; does not attack, build or review)
**Goal**: [goals/headless-forks.md](../goals/headless-forks.md), including "Decided 2026-10-16"
**Design**: [designs/2026-10-16-01-headless-forks.md](../designs/2026-10-16-01-headless-forks.md), plus the coordinator's runtime override of 2026-10-16 (Python under `uv run`, not Node; a dated amendment goes into the design file separately)
**Evidence**: [research/2026-10-16-00-claude-headless-fork-sessions.md](../research/2026-10-16-00-claude-headless-fork-sessions.md) (claim ids such as `3a`, `F4c`), [reports/2026-10-15-23-athanor-trunk-fork-provenance.md](../reports/2026-10-15-23-athanor-trunk-fork-provenance.md)
**Filename**: the brief named `-02-`; the hour at writing was 01, so the workpad rule (`plugins/spire/skills/workpad/SKILL.md:72`) gives `2026-10-16-01-headless-forks.md`.
**Next**: attack (stage 4b), which writes the build plan. This plan is not the build plan.
The design stands except for the runtime. Section numbers like "design §5" refer to the design file. Where this plan adds something the design does not have, it says **Planner addition** and gives the reason.
---
## 1. Conventions this plan relies on or could break
| Rule | Where | How the plan meets it |
|---|---|---|
| Gitmoji prefix on every commit, format `<emoji> <short description>` | `CLAUDE.md:7`, `docs/workpad/specs/commit-conventions.md:3-5` | Per-step prefixes in §7. |
| `🤖` for agent files: CLAUDE.md, skills, AGENTS.md | `commit-conventions.md:91` | Everything under `plugins/spire/skills/` and `agents/` is committed as `🤖`, including the skill's script and tests (they live inside a skill). Workpad reports use `📝`. |
| Each plugin is self-contained | `CLAUDE.md:3` | Helper, templates and tests all stay inside `plugins/spire/skills/forks/`. Nothing goes to the repo root. No dependency on `plugins/project-uv/` at runtime. |
| Work on Spire uses Spire; workpad is authoritative for placement | `CLAUDE.md:11`, `:15` | Docs go to `docs/workpad/`. This build runs on the **installed 0.2.2** Athanor shape, because the tool under test cannot build itself (design §10). |
| Workpad naming `YYYY-MM-DD-hh-<slug>` for plans and reports; dated subfolder for a cascade | `plugins/spire/skills/workpad/SKILL.md:72`, `:80` | Run log `reports/<stamp>-headless-forks-run.md`. Preflight report `reports/<stamp>-headless-forks-preflight.md`. The helper's state dir `…-run-forks/` is a cascade folder (design §3.2). |
| Spire README lists components; `plugins/spire/CLAUDE.md` points to it | `plugins/spire/CLAUDE.md:1`, `plugins/spire/README.md:11-21` | Step 3.4 adds the `forks` row. |
| Version lives in **two** manifests, bumped together | `plugins/spire/.claude-plugin/plugin.json:3`, `plugins/spire/.codex-plugin/plugin.json:3`; precedent `7c1ed39`, `f40cbf5` | Step 3.4 bumps both. Marketplace files (`.claude-plugin/marketplace.json`, `.agents/plugins/marketplace.json`) carry no version, so they stay as they are. |
| No CHANGELOG file exists. Release notes live in the release commit body | `git ls-files` (no changelog); `7c1ed39` body | Step 3.4 writes the changelog as the body of the release commit. See open decision D2. |
| LF line endings for `*.md`, `*.py`, `*.json` | `.gitattributes:1-5` | `forks.py`, templates, registry and ledger are written with `\n`. The helper writes its files in binary or `newline="\n"` mode on Windows. |
| `**/*.log` and `**/*.tmp` are gitignored repo-wide | `.gitignore:9-10` | **Planner addition:** evidence and gate output under the state dir's `logs/` uses `.txt`, so it is not hidden silently. Temp-then-rename files may use `.tmp`. |
| uv conventions: PEP 723 scripts run as `uv run path/to/script.py`; `SCRIPTS_UV` names the uv binary; probes are stdlib-only | `plugins/project-uv/skills/project-uv/SKILL.md:21`, `:31`; `scripts/probe.sh:6-11`; `scripts/probe.py:4` | `forks.py` is a single file with a PEP 723 block and stdlib only. It is invoked as `uv run <abs>/forks.py …`. `$SCRIPTS_UV` is honoured as described in §3.1. |
| Skill shell injection plus a pre-approving `allowed-tools`, a pattern its own comment calls novel | `plugins/spire/skills/adepts/SKILL.md:4`, `:7-12` | `forks/SKILL.md` follows it with `Bash(uv:*)`. Phase 4 verifies it, and §6 marks it **Assumed**. |
| Reliquary already allows `Bash(uv:*)`; `rm`/`mv`/`git push` are `ask`; `gh`/`ssh` are denied | `.claude/settings.json:32`, `:53-54` | Under `dontAsk`, forks get `rm`/`mv` denied. Denials surface as `needs_attention` (design §3.5). Briefs tell forks to use `git rm`/`git mv`. |
| Project `availableModels` lists only Opus ids and aliases, no Sonnet or Haiku full id; `effortLevel: medium`; `model: claude-opus-5-5[1m]` | `.claude/settings.json:56-69` | Headless runs load project settings (no `--bare`, `5a`). P0.5 tests whether a full id outside the list is refused. See D5. |
| Athanor: goals are read-only; stage by explicit paths; no PRs; no force-push | `plugins/spire/agents/athanor.md:53`, `:55` | No step edits `goals/`. Igor ticks the goal's boxes. All commits stage explicit paths. |
| Every plan, handoff note and review stamps its exact model id | `plugins/spire/skills/implementation/SKILL.md:68` | Header above. The helper stamps the real model from `modelUsage` (design §6). |
| One phase per turn, cheap gate between blocks, tests once per phase, fresh phase review | `implementation/SKILL.md:36-58`, `athanor.md:27-40` | Phases in §7. |
| Spawning reference: status vocabulary, and "when a claim fails, fix it here with a new date" | `implementation/references/spawning-claude-code.md:5-12` | Step 3.3 updates rows with dated statuses. Old rows are replaced only where design §9 says so. |
| Model notes are added with a date, not silently dropped | `implementation/references/models.md:3` | Step 3.3 appends dated notes. |
| No `## Build` section in `.claude/spire/project.md` | `.claude/spire/project.md` (absent) | Defaults from the implementation skill apply. This plan names its own gate commands. |
| Root `AGENTS.md` still points at `g4b_ai` | `AGENTS.md:9-15` | Stale and Codex-facing. Out of scope (§5). |
---
## 2. Runtime change: Python under uv (coordinator override, 2026-10-16)
This replaces design §3.1. Everything else in design §3 stays.
- **Decision:** the helper is `plugins/spire/skills/forks/forks.py`, a single file, standard library only. *Why:* the user decided it. `python` is not on PATH here, but uv 0.5.13 is (`~/.local/bin/uv`), and it manages CPython 3.9 to 3.13 locally.
- **Decision:** the PEP 723 header has exactly this form. Its exact form is the decision.
```python
# /// script
# requires-python = ">=3.10"
# dependencies = []
# ///
```
*Why:* stdlib-only means `dependencies = []`. The floor is kept low so that any uv-managed Python on this host (3.10 to 3.13) qualifies; 3.9 is past end of life. Nothing needs `tomllib`. Raise the floor only with a stated reason.
- **Invocation contract:** always `uv run <absolute path to forks.py> <command> [args]`, from any cwd. With inline metadata, uv must ignore any `pyproject.toml` in the cwd. P0.0 verifies this.
- **Decision:** the detached supervisor is spawned as `sys.executable <forks.py> _supervise <job>`, not through `uv run` again. *Why:* it skips a second uv resolution and keeps uv's own wrapper process out from between the supervisor and its launcher, which is one fewer layer to detach from (R1).
- **Planner addition, env scrub for uv:** `uv run` changes the environment the helper sees. The child `claude` (and every fork's Bash) must not inherit the helper's Python environment. On top of design §4, the scrub therefore drops `VIRTUAL_ENV` and every `UV_*` variable that uv injects, and removes the PATH entries that point inside `sys.prefix`. P0.0 measures the exact list. *Why:* otherwise a fork running `python` or `uv run pytest` in a Python project would resolve to the helper's interpreter.
- `where` reports the uv version, the Python version and path, the script path, the resolved `claude` binary and its version, and the scrub check. Design §2 had node in this slot.
- `$SCRIPTS_UV` (project-uv contract) is honoured by the **Athanor's calls**: SKILL.md says to use `$SCRIPTS_UV` if it is set, else `uv`. The injection line uses plain `uv`. **Assumed**, see §6.
- Design R14 ("node may be missing") becomes "uv may be missing, or may be unable to provide Python ≥ 3.10 (offline, no managed Python)". `where` fails loudly, and the Athanor falls back to the fresh-agent path and logs it.
---
## 3. Decisions the builder cannot make alone
Design §"Decisions this design made" 1–11 stand, with 1 amended by §2 above. On top of those:
1. **Phase 0 stop rule.** If P0.0 (uv), P0.1 (R1), P0.2 (R2) or P0.3 (R5) fails, the plan stops. The Athanor hands back with `STOP` and the preflight report, and Igor takes it to the user. Nothing in `plugins/` changes before Phase 0 passes. *Why:* design §10 and §11 "what would make this wrong". Adding uv as a stop item follows the coordinator's message.
2. **Phase 0 runs from the Athanor's own Bash, as an Agent-spawned subagent, on this Windows host.** *Why:* R1 is about exactly that configuration. A researcher run from the root would test the wrong process tree.
3. **Phase 0 probes are throwaway** and live in the session scratchpad or a scratch clone, not in `plugins/`. Their results get copied into the preflight report. The Phase 1 builder may reuse their code.
4. **Scratch target for every live test** (P0, the Phase 1/2 smokes, Phase 4) is a `git clone --local` of this repo in the scratchpad. It is never the working checkout. *Why:* forks commit, and P0.2 needs ≥ 100k tokens of real reading, which this repo provides. The clone carries `.claude/settings.json`, so P0.5 tests the real `availableModels` behaviour too.
5. **Tests use a stub `claude`** that replays canned `stream-json` (init, assistant events, result) and can be told to hang, die, or emit denials. No unit test spends tokens. The builder chooses how the stub is made executable on Windows. The helper gains no test-only flags beyond what `--binary` already gives. Time is injected by monkeypatching one clock function in the imported module.
6. **Tests live at `plugins/spire/skills/forks/tests/test_forks.py`**, a PEP 723 script using `unittest`, run as `uv run plugins/spire/skills/forks/tests/test_forks.py`. *Why:* self-contained plugin, and it lifts with the skill into Dirigent.
7. **Planner addition: supervisor liveness.** The supervisor writes a heartbeat (`heartbeat_at` in its job file) at least every 30 s. If `wait` or `list` sees a running job whose heartbeat is older than 120 s and whose supervisor pid is gone, it marks the job `lost` and appends a ledger line with whatever usage was captured. *Why:* the design has no answer for a supervisor killed by a job object or a reboot. Without one, `wait` returns exit 3 forever.
8. **Planner addition: `registry.json` carries `"schema": 1`.** *Why:* the state format is the hand-off point to a later Dirigent or MCP tool.
9. **The release is a minor bump, 0.2.2 → 0.3.0**, in both manifests. Smoke fixes in Phase 4 bump the patch. *Why:* the Athanor's procedure changes incompatibly (no code reads, Agent-tool forks dropped), and the plugin cache is keyed by version. See D1.
10. **Owner models (defaults for the attacker to reshape).** P0: the Athanor itself, `claude-opus-5-5[1m]`. Phases 1–2 (process supervision, Windows, state machine): Opus 5.5. Phase 3 procedure text (athanor.md, implementation SKILL.md, forks SKILL.md): Opus 5.5. Phase 3 reference rows and README: Sonnet is acceptable.
---
## 4. Contracts and invariants
### 4.1 Command surface (design §3.4, unchanged except `where`)
`where`, `init`, `train`, `fork`, `rebase`, `continue`, `wait`, `usage`, `list`, `expire`, `kill`, internal `_supervise` and `tick`. Every command except `where` takes `--run <run log .md>`. Exit codes: `0` ok, `2` usage error or refused, `3` `wait` timed out with jobs still running, `4` a job failed.
**Invariant:** stdout carries exactly one JSON object, or for `usage --format md` exactly one markdown fragment. Diagnostics go to stderr. uv's own output goes to stderr too, and P0.0 confirms it.
**Path contract (planner addition):** `--run`, `--brief` and `--cwd` accept relative, Windows (`G:\…`, `G:/…`) and Git Bash (`/g/…`) forms. The helper normalises them before storing and hashing. *Why:* the Athanor's Bash is Git Bash, while `forks.py` runs on native Windows Python. The transcript slug is derived from the normalised Windows path (`6d`, `7`).
### 4.2 Result object (what `wait` returns per finished job)
This is the contract between Phase 1/2 (helper) and Phase 3 (procedure text). Field names are fixed. Values follow design §6.
```json
{"job":"j0007","kind":"train|fork|rebase|continue|ping","base":"B1","block":"1.3","phase":1,
"status":"ok|failed|timeout|killed|lost",
"flags":["stalled","needs_attention","usage_mismatch","ttl_downgrade","cold_start"],
"fingerprint":"ok|drift:<field>,<field>",
"session":"<uuid>","parent_session":"<uuid>|null",
"models_used":["claude-…"],"tokens":{"input":0,"output":0,"cache_creation":0,
"cache_creation_1h":0,"cache_creation_5m":0,"cache_read":0},
"cost_delta_usd":0.0,"cache":"hit|partial|miss","base_state":"warm|expiring|cold",
"wall_s":0,"turns":0,"denials":0,"note":"notes/1.3.md|null","raw":"raw/j0007.jsonl"}
```
`fork` additionally returns `{job, base, base_state, warm_until, rebase_due, retrain_due, reasons[]}` at launch (design §3.4). The ledger line is design §6's shape plus `status` and `flags` as above.
### 4.3 State machines
```
job: created ─► running ─► ok | failed | timeout | killed | lost
(flags may be set while running: stalled, needs_attention)
base: training ─► active ─► superseded (rebase / re-train) | retired (expire)
warmth (computed, never stored as truth): warm | expiring | cold — design §5
```
**Invariants:**
- Only `train` and `init` accept launch fields. `fork`, `rebase`, `continue` and `ping` read them from the base record. Overrides are refused with exit 2 (design §3.3).
- A `fork` from a base that is not `active` is refused without `--force`. A cold base is forkable. **Coldness never triggers a re-train** (design §5).
- Registry writes happen under `registry.lock` (create-exclusive, stale after 30 s), written to a temp file and then renamed. The ledger is append-only. Nothing else writes either file.
- At most `parallel_cap` jobs of kind `fork`/`continue` run per run (default 2). Pings and the tick do not count toward it.
- `init.model` is never recorded as the model. `models_used` comes from the `modelUsage` delta (design §6).
- `total_cost_usd` is never summed. Cost is always a delta against the session total before the call (`3d`, `F4e`).
- Every launch re-checks binary version, size and mtime. A change sets a `binary` drift and `rebase_due` (design §5).
- The env scrub is one constant (design §4 plus §2 above). `where` lists host `CLAUDE_CODE_*` variables that are on neither the drop list nor the keep list.
### 4.4 Commit ownership (design §8.3, unchanged)
There is one committer at a time. Sequential forks commit their block by explicit paths after their gate passes. Parallel forks never commit; the Athanor commits each block afterwards by the paths its note lists. **Planner addition:** the Athanor commits the run log and the state dir (everything except `raw/` and lock files) by explicit paths at phase close, as `📝`.
---
## 5. Scope fence
Out of scope:
- An MCP server, a Dirigent harness, and token-count access beyond the helper's per-call returns (goal "Later").
- `--bare` and API-key auth (goal decision: OAuth with a scrubbed env).
- Agent-tool forks. They are removed from athanor.md, not kept beside the new path (goal decision).
- Editing `plugins/spire/skills/igor/SKILL.md`. Line 66 ("forking yourself") is flagged by the design. **Planner flag:** line 112 has Igor `TaskStop` an Athanor, which would leave its detached forks running. Both go to Igor as follow-ups, see D4.
- Codex support for `spire:forks`. The skill says it is Claude Code only. `.codex-plugin/plugin.json` gets only the version bump.
- Worktree-per-fork, cross-cwd forks, and auto-tuning of thresholds. The trial tunes them by hand (design §5).
- A CHANGELOG file (see D2), root `AGENTS.md`, other plugins, and the planner, attacker and reviewer agent files.
- Editing `goals/` (Igor's) or the design file (the coordinator is amending it).
- The other criteria of goal `lean-athanor-0.2.1`, beyond what the trial answers.
---
## 6. Assumed
- **Assumed:** the preloaded skill's `!` injection runs at agent start under Windows and resolves `${CLAUDE_SKILL_DIR}`, and `allowed-tools: Bash(uv:*)` pre-approves the Athanor's later `uv run …/forks.py` calls (design §2, R4). Default if not: the Athanor's brief says to rely on the project allow rule (reliquary has one at `.claude/settings.json:32`), and SKILL.md tells users of other projects to add `Bash(uv run:*)`. This is checked in Phase 4, the first point where the installed plugin exists. It is not a Phase 0 stop item.
- **Assumed:** `$SCRIPTS_UV` is honoured in SKILL.md's instructions, not in the injection line.
- **Assumed:** evidence and gate logs under the state dir are committed, as design §3.2 gitignores only `raw/` and locks, and they use `.txt` (because of `.gitignore:9`).
- **Assumed:** Phase 0's real token spend is acceptable: a ≥ 100k base, about 8 forks, and the TTL test. It is notional under the subscription but draws on plan usage (R11).
- **Assumed:** if P0.6 (TTL refresh) fails, pings default to off (`init --pings off`) and the keep-warm rule ships disabled. If P0.4 (parallel) fails, `parallel_cap` defaults to 1.
- **Assumed:** the build runs on a branch named by Igor's brief, default `headless-forks`, not on `main`.
- **Assumed:** after Phase 3 the user updates the installed plugin (marketplace update, reload) before Phase 4. The plan cannot do this itself.
- Design-level assumptions (`[1m]` in `--model`, `Skill` in `--tools`, `apiKeySource`, `--strict-mcp-config` dropping claude.ai connectors, `--agent` resolution, `DISABLE_AUTOUPDATER`, transcript message usage, message ids kept in fork copies) are tested in P0.5/P0.7, except `DISABLE_AUTOUPDATER`, which stays assumed.
---
## 7. Phases
Gates used below:
- **G-unit**: `uv run plugins/spire/skills/forks/tests/test_forks.py` (exit 0).
- **G-where**: `uv run plugins/spire/skills/forks/forks.py where` (exit 0, valid JSON).
- **G-text**: the grep checks listed in Phase 3.
### Phase 0: Preflight (stop gate)
Owner: the Athanor, from its own Bash. Target: the scratch clone (decision 4). Output: `reports/<stamp>-headless-forks-preflight.md` with, per check: command shape, raw numbers, pass or fail, and the Phase 1 default it sets. The raw stream files stay in the scratch dir, and their paths go into the report. Commit the report as `📝` (or `⚗️`).
| Step | Check | Pass criterion | On fail |
|---|---|---|---|
| P0.0 | **uv.** A PEP 723 probe with the §2 header runs via `uv run <abs path>` from the Athanor's Bash. (a) cwd = repo; (b) cwd = a scratch dir holding a `pyproject.toml` with a `[project]` table: uv does not sync or build it. Record the uv version, the Python chosen, first-run and warm latency, whether stdout stays clean, and the env diff between Bash and inside `uv run` (`VIRTUAL_ENV`, `UV_*`, PATH prefix). | Runs in both cwds, stdout holds only the probe's output, and the env diff is recorded. | **STOP** |
| P0.1 | **R1 detach.** From a `uv run` Python process, spawn a detached `sys.executable` supervisor that spawns a short `claude -p` (or a sleeper first, then claude). Windows: try the creation flags with and without breakaway from the job. The supervisor must outlive (a) the uv process and (b) the Bash call, and a later Bash call must see its output growing until it completes. Also test tree-kill. If that fails, run the `--attach` variant under Bash `run_in_background: true` and poll it from later foreground Bash calls for ≥ 15 min. | Either variant survives for ≥ 15 min and completes, and a kill removes the whole tree. Record which variant and which flags. | **STOP** if both fail |
| P0.2 | **R2 large base.** Train a base with real tool use (Read/Grep over the clone) to ≥ 100k context tokens, using the design's default fingerprint: full model id, `--effort`, `dontAsk`, the tool list, strict MCP, `--append-system-prompt-file`, scrubbed env, pinned TTL. Then three **sequential** forks, each reading, editing, gating and committing one small file. | Each fork's **first** assistant event shows `cache_read` ≥ 0.9 × the base's context tokens. Record whether any compaction happened. One re-run is allowed if a probe mistake in the fingerprint is found and named. | **STOP** |
| P0.3 | **R5 usage.** For a fork with ≥ 5 turns: the `stream-json` `result` event has `usage`, `modelUsage`, `total_cost_usd`, `num_turns`, `is_error`, `permission_denials`. Compare the `modelUsage` delta (vs. the base's cumulative), the top-level `usage`, and the sum of per-assistant-event usage. | A reproducible rule gives the four per-call columns (plus the 1h/5m split) and the cost delta. Record the rule; Phase 1 implements exactly it. | **STOP** if the fields are missing or no consistent rule exists |
| P0.4 | **R3 parallel.** Two tool-using forks in parallel on disjoint files, no commits, then the Athanor gates and commits both. | No `index.lock` error, both edits intact, both first requests hit. | `parallel_cap` default 1 |
| P0.5 | **Flags (R8, R9, R15) and models.** Each must be accepted and visible in `init` or behaviour: `--effort` with `CLAUDE_EFFORT` scrubbed (and whether a different effort misses the cache); `--strict-mcp-config` with an empty config drops `mcp__claude_ai_*` tools; `Skill` in `--tools`; `[1m]` in `--model`; `apiKeySource` present (record its value); `--agent <name>` resolves a project `.claude/agents/<name>.md` together with `--append-system-prompt-file` (a fork quotes one marker string from each); agent `tools:` vs `--tools`; a full Sonnet id **not** in the clone's `availableModels` (`.claude/settings.json:56-67`). | Each item recorded as accepted, refused or ignored, with the default it sets. | Per item: drop the flag or field from the fingerprint and record it. The `availableModels` result feeds D5. |
| P0.6 | **R7 TTL.** Base A: ping at ~50 min, fork at ~65 min. Base B: no ping, fork at ~65 min. Start it first and run it beside P0.2–P0.5. The Athanor keeps polling until it finishes. | A's fork hits and B's misses. | Pings default off |
| P0.7 | **Transcript facts (informational).** Message-level `usage` present in `~/.claude/projects/<slug>/<id>.jsonl` lines (for `max_base_tokens`); copied base messages keep their ids in fork transcripts (for trial dedupe); `--session-id` combined with `--resume --fork-session` (R6). | Recorded. | `max_base_tokens` falls back to the last result's context estimate; the trial dedupes by content hash |
**Phase 0 complete when:** the report exists with every row filled and a one-line verdict, `GO` or `STOP`, at the top, and it lists the defaults Phase 1 must use (scrub additions, detach variant and flags, usage rule, parallel cap, pings, fingerprint fields kept or dropped). Phase review: a fresh reviewer checks the report's numbers against the raw files, and Igor makes the GO/STOP call.
### Phase 1: Helper core (lifecycle, fingerprint, usage)
Owner: Opus 5.5. Files: `plugins/spire/skills/forks/forks.py`, `preamble.md`, `templates/train.md`, `templates/fork.md`, `tests/test_forks.py` (plus the stub). Commits: `🤖`.
- **1.1 Skeleton and state.** PEP 723 header, command dispatch, exit codes, the stdout invariant, path normalisation (§4.1), state-dir derivation from `--run`, `registry.json` (`schema: 1`) with lock and atomic write, the state dir's own `.gitignore` (`raw/`, `*.lock`), `mcp-empty.json`, and the `where` and `init` commands. *Done when:* G-where passes on this host, and the unit tests cover path forms, lock contention and stale-lock recovery, init idempotency, and exit 2 on bad input.
- **1.2 Launch and supervise.** Env scrub (design §4 plus §2 plus P0.0's list), the fingerprint (build, hash, launch-time binary re-check, runtime compare against `init` → `drift`), `train`, `fork` (delta header: commits since `base_commit` plus `--shortstat`, and the refusals in design §3.4), `continue`, `_supervise` (the detach variant P0.1 chose, stream parsing, heartbeat, watchdogs `timeout`/`stalled`/`needs_attention`, `lost` per decision 7), results and ledger with the usage rule from P0.3, `wait` (without the tick), `list`, `expire`, `kill` (tree). `preamble.md` and the two templates, per design §2 (stale-snapshot sentence in `fork.md`, R13). *Done when:* the unit tests against the stub cover every job status and flag, the cost delta for fork and continue, the cache verdict, a refusal for each override, the parallel-cap refusal, `lost` detection, and `wait` exit codes 0, 3 and 4.
- **Phase 1 tests and smoke.** G-unit green. Live smoke in the scratch clone: `init` → `train` a small base → two sequential forks that commit → one `continue` → one `kill` of a started fork → `wait` loops → `list`. *Phase 1 complete when:* both forks report `cache: hit` and `fingerprint: ok`, `models_used` equals the requested id, ledger lines match the result objects, `kill` leaves no `claude` process behind, and the smoke's results and ledger are appended to the run log.
### Phase 2: Bookkeeping (warmth, rebase, usage rendering)
Owner: Opus 5.5. Same files plus `templates/rebase.md`. Commits: `🤖`.
- **2.1 Warmth and keep-warm.** TTL inference, `last_hit`, `warm_until`, margin, state (design §5); the tick inside each supervisor (60 s) and inside `wait` (30 s); the ping rule with its activity condition and break-even cap; `ttl_downgrade`; `cold_start`; `init --pings on|off` defaulting per P0.6. *Done when:* clock-injected unit tests cover the warm/expiring/cold boundaries at 1 h and 5 min, ping eligibility (including no pings without activity in the last 10 min and the cap at 19 and 11), and TTL inference from 1h-only, 5m-only and read-only calls.
- **2.2 Rebase and re-train flags.** The `rebase` command (fork → new base with `parent`, old base `superseded`), `rebase_due` (phase boundary via `--phase` change, commit and line thresholds, delta-size estimate, binary drift), `retrain_due` (fingerprint-defining field differs, `max_base_tokens` per P0.7, four rebases in a lineage), thresholds from `init`. *Done when:* unit tests trigger each reason separately, and `fork`'s launch return lists the right `reasons[]`.
- **2.3 Usage rendering.** `usage --block|--phase --format json|md`. The md block row has exactly the columns of the new run-log block table (step 3.3); the phase table gives per-kind totals, cold starts, cache-hit ratio and a priced total. *Done when:* a unit test checks the md against a fixed ledger fixture.
- **Phase 2 tests and smoke.** G-unit green. Live: in the scratch clone, train → fork (phase 1) → fork with `--phase 2` returns `rebase_due` → `rebase --as B2` → fork from B2 hits B2's prefix; let one base sit into `expiring` and observe a ping when pings are on; `usage --phase 1 --format md` renders. *Phase 2 complete when:* all of that is in the run log with the helper's JSON.
### Phase 3: Skill and procedure text, release
Commits: `🤖`. The release commit carries the changelog body (step 3.4).
- **3.1 `forks/SKILL.md`.** Frontmatter per design §2 with `allowed-tools: Bash(uv:*)` and the injection `` !`uv run "${CLAUDE_SKILL_DIR}/forks.py" where` ``. Body: when to use (the Athanor, complex blocks), the command contract (§4.1), the result object (§4.2), the rules the Athanor obeys (flags, not judgment; nothing running at hand-back; never resume or fork a headless session by hand; `$SCRIPTS_UV`), the fallback when `where` fails, and a Claude-Code-only note. Short: the design says "Short". *Done when:* every command and result field named matches `forks.py --help` and §4.2.
- **3.2 `agents/athanor.md`.** All of design §8.4's athanor bullets: `skills:` gains `spire:forks`; intake no longer reads code and trains the base instead (the base writes the trunk notes); step 2 routes complex blocks to headless forks and mechanical or distant ones to fresh agents; no inline building; keepers gain "you do not read code", "nothing running at hand-back" and the commit table (§4.4); "read the fix's diff for loosened assertions" (`athanor.md:34`) becomes the mechanical check of design §8.2 step 5; the deliverable gains usage totals and base state. The Agent-tool fork path is removed. *Done when:* G-text passes (below).
- **3.3 Implementation skill and references.** `implementation/SKILL.md:45-49` rewritten as "trained implementer and forks" (design §8.4), and line 65ff gains "headless forks take a full model id". Line 12's inline rule stays for non-Athanor use. `references/spawning-claude-code.md`: Fork-table rows and the new "Headless sessions (`claude -p`)" section exactly per design §9, each row with research id and date 2026-10-16, plus the models and rules-of-thumb lines, plus two rows from Phase 0 (detach behaviour on Windows from P0.1, uv env injection from P0.0) marked `observed 2026-10-16 (preflight report)`. `references/run-log.md`: the `Forks:` header line, the new block table `Block | Owner (model id from modelUsage) | Session | Commit | in | out | cache write | cache read | cost Δ`, the phase-close usage table, and "Trunk notes (written by the base at training)". `references/models.md`: dated notes per design §8.4. *Done when:* G-text passes, and every new spawning row has a status and a date.
- **3.4 README, version, release.** `plugins/spire/README.md` skills table gains `forks`. Both manifests go to `0.3.0` (D1). Release commit `🤖 Spire 0.3.0: trained implementer forked per block (spire:forks)`, whose body is the changelog, as in `7c1ed39`: new skill, Athanor procedure change, Agent-tool fork dropped, references updated, preflight report path. *Done when:* both manifests show the same version and the README row exists.
- **G-text** (run at phase end):
- `grep -n "fork of yourself\|That reading is your trunk\|read the fix's diff" plugins/spire/agents/athanor.md` returns nothing;
- `grep -n "spire:forks" plugins/spire/agents/athanor.md` hits in the frontmatter;
- `grep -n "a fork of the builder" plugins/spire/skills/implementation/SKILL.md` returns nothing;
- the `version` values in both manifests are equal;
- every command name in `forks/SKILL.md` appears in `forks.py`;
- G-unit and G-where still pass.
*Phase 3 complete when:* G-text is green and a fresh reviewer has checked the procedure text against the helper's actual JSON and exit codes. The review explicitly covers contract drift between Phases 1–2 and Phase 3.
### Phase 4: Install and smoke (needs the user to update the plugin)
Entry: the user has updated and reloaded Spire 0.3.0. Owner: Igor dispatches a fresh 0.3.0 Athanor on a toy plan in the scratch clone: 2 phases, 3 complex blocks in one area plus 1 mechanical block.
- **4.1** The Athanor's start shows the `where` injection output, and its first `forks` calls run without a permission prompt (the R4 **Assumed** above). Record the outcome in the spawning reference only if it differs from the assumption.
- **4.2** The procedure is followed: the base writes the trunk notes; complex blocks go to forks with delta briefs; the mechanical block goes to a fresh agent; phase 2 starts from a rebased base; the run log carries `usage` rows; `list` shows nothing running at hand-back. The Athanor's transcript shows no `Read` of source files under the clone's `plugins/`.
- *Phase 4 complete when:* 4.1 and 4.2 are recorded in `reports/<stamp>-headless-forks-smoke.md`. Fixes go in as patch versions (0.3.1 and up), both manifests, `🤖`/`🐛`.
### Phase 5: Trial (goal "Proven, not assumed"), Igor-owned
Per design §10 "The trial": arms F and C, sealed inputs, a transcript audit with a positive control, a blind judge, four token columns priced for every agent including the root, wall time from `date` and git, and the arm F mechanism metrics. Output: `reports/<stamp>-headless-forks-trial.md`. Thresholds in design §5 are retuned from it, as a patch release. Entry needs D3. *Complete when:* the report exists and Igor has put the result in front of the user. Whether the goal box gets ticked is the user's call.
---
## 8. Risks the attacker should test this plan against
- **R1 under uv.** On Windows, `uv run` may hold its child in a job object, and so may the Bash tool. Breakaway may be refused. P0.1 is designed around this, but a variant that survives the Bash call and then dies at the end of the subagent's turn would be fatal only for hand-backs with running jobs, which the procedure forbids anyway.
- **The env bleed from `uv run`** (§2) also reaches the Athanor's gate commands if they are run through the helper. They are not: the Athanor runs gates in its own Bash.
- **The Phase 3 text and the Phase 1–2 JSON** have different owners. §4.2 is the fixed seam. The Phase 3 review checks it.
- **`availableModels`** (`.claude/settings.json:56-67`) may refuse the owner model a plan names for forks, in this repo and others. D5.
- **The arm C contamination** in the trial (D3).
---
## 9. Open decisions (not settled by the goal, the design or the coordinator)
- **D1. Version number.** Default `0.3.0` (decision 9). Alternative `0.2.3`. The goal says "version TBD".
- **D2. Changelog form.** No CHANGELOG file exists. The default follows precedent: the release commit body (`7c1ed39`). The alternative is a new `plugins/spire/CHANGELOG.md` starting at 0.3.0.
- **D3. Trial set-up.** (a) Which real multi-block plan; the design says the user picks. (b) How arm C gets the 0.2.2 shape once 0.3.0 is installed, because the preloaded skills would be 0.3.0. Default: run arm C **before** the user installs 0.3.0 (between Phase 3 and Phase 4), and arm F after Phase 4. That means the trial plan must be picked before Phase 4.
- **D4. Igor follow-ups.** `igor/SKILL.md:66` (fork wording) and `:112` (`TaskStop` of an Athanor orphans detached forks; it would need "then run `forks list`/`kill` on its run"). Default: out of this plan, a one-line follow-up for Igor after Phase 4.
- **D5. Models outside `availableModels`.** If P0.5 shows that a full id not in the project's list is refused, should the helper refuse at `init` with a clear message (default), and should the user add full Sonnet or Haiku ids to `.claude/settings.json`? The plan does not touch user or project settings.