📝 Add headless-forks goal, design, plan and build plan; session-harness app report

This commit is contained in:
2026-10-16 08:15:44 +02:00
parent a7ce5db3a0
commit d3736898fc
6 changed files with 1549 additions and 0 deletions
@@ -0,0 +1,257 @@
# 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.
@@ -0,0 +1,337 @@
# Build plan: Trained implementer, forked per block (`spire:forks`)
**Date**: 2026-10-16 01:50 +0200
**Attacker**: model id `claude-opus-5-5[1m]` (stage 4b; did not write the plan, will not build or review it)
**Plan (as written, unchanged)**: [plans/2026-10-16-01-headless-forks.md](./2026-10-16-01-headless-forks.md). Section refs "plan §N" point there.
**Design**: [designs/2026-10-16-01-headless-forks.md](../designs/2026-10-16-01-headless-forks.md) incl. the 01:33 runtime amendment (Python under uv)
**Goal**: [goals/headless-forks.md](../goals/headless-forks.md)
**Evidence**: [research/2026-10-16-00-claude-headless-fork-sessions.md](../research/2026-10-16-00-claude-headless-fork-sessions.md) (claim ids `1a`…`F5b`)
**Filename**: `-02-` as the coordinator named it (workpad rule 1, explicit instruction).
**Build scope**: Phases 0–3. Phase 4 (install and smoke) and Phase 5 (trial) are post-build steps; they need the user.
The plan stands except where this document says **Amended** or **New**. Where both say something, this document wins.
---
## STOP-level findings
**None.** Nothing found blocks the build before it starts. Phase 0 is the stop gate the plan already has, and the findings below go into it or into the contracts.
These came closest:
- **A5 (uv and job objects).** On Windows, `uv run` or the Bash tool may hold the helper in a kill-on-close job object that does not allow breakaway. Both of the plan's P0.1 variants would then fail, and the plan would STOP for a reason that has a known workaround. This plan adds that workaround as a rung before STOP.
- **A9 (untracked inputs).** The goal, design and plan are untracked on `main`, so a build branch would start without them. Igor commits them first (pre-build step B0).
---
## 1. What the attack found
Severity: **Important** changes a contract or the phase structure. **Medium** changes one block. **Minor** is a one-line fix. Each finding names where this plan resolves it.
### Important
- **A1. Forks are orphaned when the Athanor dies.** Supervisors are detached by design, so they outlive the orchestrator. The orchestrator can die in several ways: Igor `TaskStop`s it when a new plan invalidates the run (`plugins/spire/skills/igor/SKILL.md:112`), it crashes, it runs out of context, or the user interrupts it. The forks then keep editing and committing on the branch for up to `--max-minutes` (90). The plan's `lost` rule (plan §3.7) covers a dead *supervisor*, not a dead *orchestrator*. D4 defers Igor's text, so this has to be fixed in the helper. **New invariant N1** (§4): an orphan watchdog.
- **A2. The Bash tool's default timeout is 120 s.** Its maximum is 600 s. `wait --timeout 540` is safe only if the Athanor passes `timeout: 600000` on that Bash call. Neither the design nor the plan says so: design §7 calls 600 s the "ceiling", but it is the maximum, not the default. Without the parameter, every `wait` gets killed at 2 min with no JSON. **§4 C2**, G-text check, P0.1(d).
- **A3. Windows file semantics break the registry design.** `os.replace` raises `PermissionError` while another process has the target open. Here that means `wait`/`list` readers and the supervisors that tick every 60 s. The plan also puts a 30 s heartbeat per job into the registry (plan §3.7), which multiplies writes under one lock. Appends from parallel supervisors to `ledger.jsonl` are not atomic on Windows. Two tickers can each decide to ping the same base. **§4 C3.**
- **A4. P0.6's pass criterion cannot pass as written.** Bases A and B share cwd, tools and system prompt, and P0.2–P0.5 run in the same clone. So the shared system and tool prefix stays warm (`F4d`), and B's fork at 65 min reads that part from cache. It shows `partial`, not a miss. The same fact changes the helper's semantics: a fork of a cold base will usually show `partial`. Separately, P0.6 needs a timed probe that runs for 65 min. It cannot be driven by foreground `sleep` (the harness blocks it), so it depends on P0.1's detach mechanism, yet the plan starts P0.6 first. **Amended P0.6** and the ordering in Phase 0, **§4 C5**.
- **A5. P0.1's escape route is too narrow.** If `uv` (or the Bash tool) puts its child in a job object with `KILL_ON_JOB_CLOSE` and without `BREAKAWAY_OK`, `CREATE_BREAKAWAY_FROM_JOB` fails with access denied. The `--attach` fallback then routes its notification to the session root (`spawning-claude-code.md:29`). There is a standard way out of any job: create the process through WMI/CIM (`Win32_Process.Create`), which starts it outside the caller's job. There is a second problem. If `sys.executable` is a venv launcher (`python.exe` that re-spawns the base interpreter), the supervisor gets an extra layer. **Amended P0.1** ladder, **§4 C6**.
- **A6. Forks can launch forks.** Headless runs load project settings (`5a`). Reliquary allows `Bash(uv:*)` (`.claude/settings.json:32`), and forks have the `Skill` tool, so a fork can load `spire:forks` and run `uv run …/forks.py fork`. The deny rule `Bash(claude:*)` does not cover that, and it does not cover `"$CLAUDE_CODE_EXECPATH" -p` either. **New invariant N3** (§4).
- **A7. The successor Athanor would re-train.** When an Athanor's context is spent, Igor starts a fresh one "from the run log and trunk notes" (`igor/SKILL.md:112`). The new intake says "first phase: `forks init`, `train`" (design §8.2 step 1). It has no rule for an existing state dir, so a successor would init a second time and train a second base. **Block 3.1** adds an adoption rule.
- **A8. The release path is missing a step.** The marketplace is a git source (`.claude/settings.json:2-8`), and `spire` resolves to `./plugins/spire` inside it (`.claude-plugin/marketplace.json`). So 0.3.0 reaches anyone's plugin cache only after the branch is merged and pushed to the remote's default branch. Installing it also switches the Athanor procedure in **every** project at once, before the smoke and the trial. **Post-build steps**, decisions **U1** and **U3**.
- **A9. The build inputs are untracked.** `goals/headless-forks.md`, the design and the plan are `??` in git status, and the session journal is modified. A worktree or branch made now starts without them. **Pre-build step B0.**
### Medium
- **A10. `wait` exit codes are ambiguous.** It is unclear what `wait --all` returns when one job failed and others are still running. The Athanor loops "until exit 0 or 4" (design §7) and could stop looping with jobs running. **§4 C4.**
- **A11. Parallel mode would need a markdown parser.** "Refuses `parallel` mode unless the brief names its file set" (design §3.4) means parsing the brief. **Amended:** `--paths` is required for parallel. The helper stores it, the result echoes it, and the Athanor commits exactly those paths (plan §4.4 becomes mechanical).
- **A12. The helper's git calls can collide with a fork's commit.** The delta header, `--shortstat` and the rebase thresholds call git, and those calls can take `index.lock` opportunistically while a fork commits. **§4 C7:** every helper git call uses `git --no-optional-locks`.
- **A13. Consumers the plan's 3.2/3.3 lists miss:**
- `athanor.md:23` "otherwise orient via direct file reads". It must say plans and docs, not code.
- `athanor.md:60` "a fail-first test inside a block, a look one layer up before fixing" are code-level actions by the Athanor. They become things it writes into briefs.
- `athanor.md:7-10` the frontmatter description ("blocks in the foreground") is what Igor reads to route.
- `implementation/SKILL.md:40` "Each block commits and adds a short handoff note to the run log". Forks write `notes/<block>.md` and the Athanor appends them.
- `models.md:49` "a trunk read at intake of about 100k–250k". That read is now the base's: add a dated note, keep the old line.
- **Block 3.1 / 3.2.**
- **A14. D5 ("refuse at init") needs a rule the plan does not give.** `availableModels` can live in user, project and local settings, and their merge is unknown. Reliquary lists the alias `sonnet` (`.claude/settings.json:64`), and nobody has tested whether that admits `claude-sonnet-5-5`. **Amended P0.5**, **§4 C8.**
### Minor
- **A15. Cut `$SCRIPTS_UV` from the Athanor's calls.** `"$SCRIPTS_UV" run …` does not match a `Bash(uv:*)` allow rule, so it would prompt or be denied behind the closed door, and it is unset on this host. Calls use plain `uv`. `where` reports `SCRIPTS_UV` when it is set, and SKILL.md mentions it only for hosts without `uv` on PATH.
- **A16. Add a `.python-version` case to P0.0.** The Athanor will build in Python projects that carry one. Add a cwd with `.python-version` pinning 3.9 and check that the script's `requires-python >= 3.10` wins.
- **A17. R4 can be partly tested before release.** Precedent: an `!` injection at agent preload already runs on this host. Igor preloads `spire:adepts` (`agents/igor.md:15`), and the journals record its output, "Roster empty at boot" (2026-09-21, -09-29, -10-05, -10-15). That line is `roster.sh:18`'s own output, so the injection ran. What is untested: an **Agent-tool subagent's** preload, `uv`, and whether `allowed-tools` pre-approves the call. **New P0.8**, a headless proxy in the scratch clone. It is informational, not a stop item.
- **A18. Smoke and test details.**
- Check that `kill` "leaves no `claude` process behind" by the recorded PIDs. Checking by name would find the host, which is `claude.exe` too.
- Generate the stub `claude` wrapper at test runtime in a temp dir. `.gitattributes` has no rule for `.cmd`/`.bat`, so nothing of the kind gets committed.
- **A19. PIDs are reused on Windows.** `lost` and orphan detection compare PID plus process start time.
- **A20. G-text checks only one direction.** Add the reverse (every command in `forks.py`'s dispatch is named in SKILL.md), plus checks for the `600000` rule and the `SPIRE_FORKS_CHILD` guard.
- **A21. Fix-pass commits land after the release commit.** The release commit is the last block of Phase 3, but the Phase 3 review and fix pass come after it. That is fine: 0.3.0 is unpublished until it is pushed, and fixes before the push stay 0.3.0 with no version change.
- **A22. Pre-existing drift, not in scope.**
- The README skills table already lacks `adepts`, `curator`, `init`, `project` and `zathras` (`README.md:13-21`). This build adds only `forks`.
- The agent and skill lists in `.claude/spire/project.md:31-32` are stale (no planner/attacker/reviewer; `forks` is missing after this build). That file is project config. Both are follow-ups.
- **A23. The design cites project config keys that do not exist.** It refers to an assertion pattern "from project config" (design §8.2 step 5) and "unless project config says parallel test runs are safe" (§8.3). Neither key exists in the `## Build` template. The defaults go into the procedure text (pattern `assert`; parallel tests off). Adding the keys is a follow-up for `spire:init`/`spire:project`.
---
## 2. Conventions pass
| Rule | Governing source | Plan §1 | Finding / action |
|---|---|---|---|
| Gitmoji `<emoji> <short description>` | `CLAUDE.md:7`, `specs/commit-conventions.md:3-5` | yes | ok |
| `🤖` for agent files incl. skills | `commit-conventions.md:91` | yes | ok. Precedent is split for a new skill with a script: `1c4ec76` used `✨` (adepts + `roster.sh`), the newer `f1de89e` used `🤖` (orbit scripts). Keep `🤖`; `✨` is acceptable for 1.1's first commit. Either way it is not a finding. |
| **No `Co-Authored-By` or other attribution lines** | user policy (coordinator, 2026-10-16); `.claude/settings.json:70` `includeCoAuthoredBy: false`; precedent `7c1ed39` body has none | **missing** | Added to every block below. It applies to commits made by forks and by fresh agents too. |
| Plugin self-contained | `CLAUDE.md:3` | yes | ok |
| Workpad placement and naming; cascade subfolder | `plugins/spire/skills/workpad/SKILL.md:72`, `:80` | yes | ok |
| README lists components | `plugins/spire/CLAUDE.md:1` | yes | Add the `forks` row only (A22). |
| Two manifests carry the version; marketplace files carry none | `.claude-plugin/plugin.json:3`, `.codex-plugin/plugin.json:3`; checked `.claude-plugin/marketplace.json`, `.agents/plugins/marketplace.json` | yes | ok |
| Codex loads `./skills/` wholesale | `.codex-plugin/plugin.json:10` | partly | `forks` reaches Codex automatically and there is no per-skill opt-out. The "Claude Code only" line in SKILL.md is required, not optional. |
| LF endings | `.gitattributes:1-7` | yes | No rule for `.cmd`/`.bat`, so stubs are generated at test runtime (A18). |
| `**/*.log`, `**/*.tmp` ignored | `.gitignore:9-10` | yes | `.txt` for logs (plan). |
| `__pycache__/` ignored | `.gitignore:13` | no | Covers the test import of `forks.py`. No action. |
| Worktrees under `.claude/worktrees/` (ignored) | `.gitignore:7`; Igor: worktree for code runs, `igor/SKILL.md:179` | no | The build runs in `.claude/worktrees/headless-forks`, branch `headless-forks` (B0). |
| Push branches, never open PRs; push only if instructed | `igor/SKILL.md:177`, `athanor.md:55` | partly | The build does not push. Merge and push are post-build (U3). |
| Goals read-only for the Athanor; stage explicit paths | `athanor.md:53`, `:55` | yes | ok |
| Stamp exact model id | `implementation/SKILL.md:68` | yes | ok |
| Spawning statuses: verified / observed / assumed / unknown, dated | `spawning-claude-code.md:5-12` | yes | New rows use only these four. The current `:61` "unverified" is outside that vocabulary, and the design's replacement row fixes it. |
| Model notes dated, not dropped | `models.md:3` | yes | ok (A13: `:49` gets a dated note). |
| uv: PEP 723, `uv run path/to/script.py`, `SCRIPTS_UV` | `project-uv/SKILL.md:21`, `:31` | yes | A15 cuts `$SCRIPTS_UV` from the calls. |
| Skill `!` injection + `allowed-tools` | `adepts/SKILL.md:4`, `:7-12` | yes | Precedent at preload (A17); P0.8. |
| Separation: builder ≠ reviewer; fresh review per phase | `implementation/SKILL.md:22`, `:53` | yes | ok |
---
## 3. Precedent search on the risks
**Dropped** (the repo or this host already does the risky thing):
| Risk | Precedent |
|---|---|
| `${CLAUDE_SKILL_DIR}` (Windows path) inside an `!` injection under Git Bash | `adepts/SKILL.md:12` with `roster.sh`; it ran at Igor's preload in four journals (A17). |
| Nested `claude -p` from a subagent's Bash works at all, including cwd in the session scratchpad | research T1–T7, D1–D3, E1, V1 (all from a subagent in this scratchpad). |
| Running the Desktop-bundled binary via `$CLAUDE_CODE_EXECPATH` from outside its package | research `F5a`, V1. |
| uv-managed Python ≥ 3.10 available offline | `uv python list --only-installed`: 3.10.16, 3.11.11, 3.12.x and 3.13.1 under `%APPDATA%\uv\python` (checked 2026-10-16). |
| `__pycache__` or `.log` files leaking into or hiding from git | `.gitignore:13`, `:9`. |
| Absolute user paths in committed workpad files | the research doc already carries them. |
**Reduced**:
| Risk | Precedent | What remains |
|---|---|---|
| R4 injection + `allowed-tools` | main-thread preload works (A17); reliquary has a project allow rule for `uv` (`settings.json:32`), so pre-approval matters only in other projects | Subagent preload and pre-approval: P0.8 proxy, then Phase 4. |
| D3(b) arm C once 0.3.0 is installed | the plugin cache keeps old versions: `~/.claude/plugins/cache/reliquary/spire/` holds 0.1.0, 0.1.1, 0.2.0 and 0.2.2 | Whether `--plugin-dir` on the 0.2.2 cache dir (or a worktree at `a7ce5db`) runs cleanly beside an installed 0.3.0 is **assumed** (U2). |
| R3 parallel tool-using forks | index-lock races between parallel children happened in the 2026-10-15 experiment (design §8.3, M5), so the risk is real | The mitigation (parallel forks never commit) stays; P0.4 tests it. |
**Kept** (no precedent): R1, R2, R5, R7, the Windows file semantics (A3), contract drift at the Phase 1–2/3 seam, orphans (A1), R11 plan usage, R12 binary drift. See §8.
---
## 4. Contract amendments (on top of plan §4)
The exact form matters for these, so they are stated as invariants. Everything else in plan §4 stands.
- **C1. Phase 1 values for Phase 2 fields.** In Phase 1, `base_state` and `warm_until` are computed with the configured TTL (default 3600 s), with no inference and no pings. `rebase_due` and `retrain_due` are `false` with `reasons: []`. Phase 2 replaces the computation, not the fields.
- **C2. The `wait` call.** SKILL.md states: *call `wait` from Bash with `timeout: 600000`; never omit it.* `wait --timeout` keeps its default of 540 and its maximum of 570.
- **C3. State files on Windows.**
- (a) Every registry write is temp-then-`os.replace`, retried on `PermissionError` (at least 20 tries, 50 ms apart). Readers retry the same way.
- (b) Per-job runtime state (supervisor PID plus start time, claude PID plus start time, `heartbeat_at`, `turns`, `last_event_at`) lives in `jobs/<job>.json`. Its only writer is that job's supervisor. The registry holds config, bases and the job index.
- (c) Ledger appends happen under `registry.lock`.
- (d) A ping is decided and marked `ping_in_flight` on the base under the lock. Other tickers skip a base that is in flight.
- **C4. `wait` exit codes.**
- Exit `3` while the wait condition is unmet. `done[]` may already hold finished results, failed ones included.
- Exit `0` or `4` only when the condition is met: `0` if every job in the condition ended `ok`, else `4`.
- Every result is returned by at least one `wait`. `list` always shows all jobs.
- **C5. Cache verdict and cold start.**
- The verdict is set from the **first** assistant event's `cache_read` against the base's context tokens: `hit` ≥ 0.9, `partial` > 0, `miss` = 0.
- `cold_start` is set when the base was `cold` at launch, **or** when the verdict is not `hit` and the fingerprint is `ok`.
- SKILL.md says that a cold base usually shows `partial`, because the shared system prefix stays warm (`F4d`).
- **C6. How the supervisor is spawned.** The interpreter is `sys._base_executable` when it differs from `sys.executable` (a venv launcher), else `sys.executable`, with `-I`. Stdio goes to files under `raw/`, and no inherited handles remain. The creation method is whichever rung of P0.1 passed. It is one constant in `forks.py`, and `where` reports it.
- **C7. Git calls.** Every git call from the helper is `git --no-optional-locks …`. `where` reports the resolved `git`.
- **C8. Models outside `availableModels` (D5).**
- `init` applies the rule P0.5 observed, against the `availableModels` lists it finds in `~/.claude/settings.json`, `<cwd>/.claude/settings.json` and `<cwd>/.claude/settings.local.json`. Managed settings are out of scope.
- If the id is refused, `init` exits 2. The message names the id and the files that hold lists, and says that the user must add the id.
- At runtime, a refusal in `train` ends as status `failed`, reason `model_refused`. No settings file is ever written.
- **N1. Orphan watchdog.**
- The helper records `last_orchestrator_call_at` on the run whenever `wait`, `fork`, `continue`, `train`, `rebase` or `list` is called.
- A supervisor whose job is still running kills its `claude` tree when no such call has happened for `orphan_after` seconds (`init` default 1200, more than 2 × 570). The job's status becomes `killed`, with flag `orphaned`, and a ledger line is written.
- *Why:* A1. The procedure keeps the Athanor in a `wait` loop whenever a fork runs, so a silence of 20 min means it is gone.
- **N2. Parallel forks need `--paths`.** `fork --mode parallel` requires `--paths <p>…`. The paths are stored in the job and echoed as `paths` in the result. Sequential forks may pass `--paths` too.
- **N3. No forks inside forks.** The child env gains `SPIRE_FORKS_CHILD=1` (on the keep list). Every launching command (`train`, `fork`, `rebase`, `continue`, `tick` pings) exits 2 when that variable is set. `where`, `list`, `usage` and `wait` stay allowed.
- **Usage md columns** (2.3 and `run-log.md`, fixed here): `Block | Owner (model id from modelUsage) | Session | Commit | in | out | cache write | cache read | cost Δ`. `Commit` lists the commits between the fork's launch HEAD and its end, made by a sequential fork. Parallel forks show `—`, and the Athanor fills in the commit.
---
## 5. Decisions applied
The coordinator's defaults:
- **D1** = 0.3.0, both manifests.
- **D2** = the release commit body, no CHANGELOG file.
- **D3** = trial deferred; it needs the user; Phase 5 is out of this build.
- **D4** = the Igor follow-ups are out of this plan and recorded below. N1 makes Igor's `TaskStop` non-fatal in the meantime.
- **D5** = refuse at init with a clear message, no settings changes (C8).
The attacker's defaults, which the build takes unless the user overrides them:
- N1 with a 20 min threshold.
- N2, N3.
- A15: `$SCRIPTS_UV` cut from the calls.
- Worktree `.claude/worktrees/headless-forks` on branch `headless-forks`.
- Live smokes use `claude-opus-5-5` (it is in the clone's `availableModels`).
- The Phase 0 spend trigger in §6.
---
## 6. Build
Gates (run from the worktree root):
- **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. Stdout is exactly one JSON object; uv's own output goes to stderr.
- **G-text**: the list in Phase 3.
Standing rules for every block:
- Commits use gitmoji, explicit paths, and **no `Co-Authored-By` or other attribution lines**.
- No push, no PR.
- `goals/` is read-only.
- Each handoff note stamps its model id.
- Every live test targets a `git clone --local` in the session scratchpad, never the worktree (plan §3.4).
- Owners are fresh Agent-tool agents dispatched by the 0.2.2 Athanor. Unpinned agents get an alias, and each stamps its id (`spawning-claude-code.md:47-52`).
### Pre-build (Igor, before dispatch): B0
1. Commit on `main` by explicit paths: the goal, the design, the plan, this build plan, and the journal. Message: `📝 Plan headless forks (spire:forks): goal, design, plan, build plan`. The other untracked report (`reports/2026-10-16-01-session-harness-app-forms.md`) is Igor's call.
2. Run `git worktree add .claude/worktrees/headless-forks -b headless-forks main`.
3. Dispatch the installed **0.2.2** Athanor (`model` omitted) with this build plan. The worktree path is its checkout, and the run log is `docs/workpad/reports/<YYYY-MM-DD-hh>-headless-forks-run.md` inside it.
### Phase 0: Preflight (STOP gate)
**Owner**: the Athanor itself, from its own Bash (`claude-opus-5-5[1m]`). This is one block, because the process tree is what is under test (plan §3.2). Probes are throwaway and live in the scratchpad; nothing in `plugins/` changes.
**Order** (amended, A4): P0.0, then P0.1, then start P0.6 as a detached timed probe using P0.1's mechanism. Then P0.2–P0.5, P0.7 and P0.8 while it runs, then collect P0.6.
**Changes to plan §7 Phase 0** (rows not named here stand as written):
- **P0.0** adds:
- (c) A cwd with `.python-version` = `3.9` (A16).
- Record `sys.executable` vs `sys._base_executable` (C6), and the resolved `git` and `claude`.
- **P0.1** ladder (A5). Each rung is tried only if the one before it failed:
1. `Popen` with `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_BREAKAWAY_FROM_JOB` from inside `uv run`.
2. The same without breakaway.
3. Creation through WMI/CIM: `powershell -NoProfile -Command "Invoke-CimMethod -ClassName Win32_Process -MethodName Create …"`, capturing the returned PID.
4. `--attach` under Bash `run_in_background: true`, polled from later foreground calls.
Each rung must survive four things: (a) the uv process exiting; (b) the Bash call ending; (c) ≥ 15 min, with output growing in later calls; and (d) a Bash call that the tool kills on timeout (call with `timeout: 5000`). A tree-kill must also leave none of the recorded PIDs alive. Record the rung and its flags. **STOP** only if all four rungs fail.
- **P0.5** adds three model cases for C8:
- a listed full id (`claude-opus-5-5`);
- an unlisted full id whose alias is listed (`claude-sonnet-5-5` vs `sonnet`);
- an unlisted id of an unlisted family if one exists, else skip.
Record the rule.
- **P0.6** (amended, A4). Bases A and B are trained with different training content and the same fingerprint, and run as a detached timed probe. Pass when A's fork after its 50 min ping shows first-event `cache_read` ≥ 0.9 × A's context, **and** B's fork at 65 min shows < 0.5 × B's context. A `partial` on B is the expected result. On fail: pings default off (plan §6).
- **P0.8 (new, informational, A17).** In the scratch clone:
- Remove `Bash(uv:*)` from the clone's `.claude/settings.json` (scratch only).
- Add `.claude/skills/forkprobe/SKILL.md` with `allowed-tools: Bash(uv:*)` and `` !`uv run "${CLAUDE_SKILL_DIR}/probe.py" where` ``.
- Add `.claude/agents/forkprobe.md`, which preloads it.
- Run a headless `claude -p --tools "Task,Bash" --permission-mode default` that dispatches `forkprobe` as an Agent-tool subagent. The subagent quotes the injected text, then runs `uv run …/probe.py ping`.
Record three things: whether the injection ran in the subagent, whether the later call was pre-approved (no entry in `permission_denials`), and the wall time of the injection. On fail, SKILL.md says that projects need a `Bash(uv run:*)` allow rule (plan §6 default).
- **Spend trigger (new).** Sum the per-call `total_cost_usd` deltas (client estimates). If the sum passes **$25** before P0.2 is complete, stop probing and hand back the partial report as `STOP (budget)` for the user.
**Phase 0 done when**: `reports/<stamp>-headless-forks-preflight.md` exists with every row filled. A one-line `GO` or `STOP` heads it, and it lists the defaults Phase 1 must use: scrub additions, the P0.1 rung and flags, the usage rule, the parallel cap, pings, the fingerprint fields kept or dropped, the `availableModels` rule, the injection outcome. Commit it as `📝`. The Athanor hands back.
**Review and decision**: a fresh `reviewer` checks the report's numbers against the raw files in the scratchpad. Igor makes the GO/STOP call. GO continues without the user; STOP goes to the user.
### Phase 1: Helper core
| Block | Owner | Scope | Gate | May stay broken |
|---|---|---|---|---|
| **1.1** Skeleton, state, `where`, `init` | fresh, `opus` | plan §7 1.1 plus: C3(a); `jobs/` dir; C7; C8 in `init`; the `orphan_after` config (N1). `where` reports uv, Python (both executables), the script, `claude`, `git`, the spawn rung, `SCRIPTS_UV` if set, and unknown `CLAUDE_CODE_*` vars. | tests the block owns pass via `uv run …/tests/test_forks.py`; G-where | Launching commands exit 2 `not implemented`. |
| **1.2** Launch and supervise | fresh, `opus` | plan §7 1.2 plus C1, C3(b–d), C4, C5, C6, N1, N2, N3, A19. The stub `claude` is generated at test runtime (A18). It must answer `--version`, replay init/assistant/result, hang, die, emit denials, and refuse a model. Tests also cover: replace while a reader holds the file open (C3a); exit codes in the mixed case (C4); the orphan kill under an injected clock (N1); refusal under `SPIRE_FORKS_CHILD` (N3); `parallel` without `--paths` refused (N2). | G-unit | Warmth inference, pings, `rebase`, `usage --format md` (Phase 2). |
| **1.3** Phase tests, smoke, fallout | the Athanor runs the smoke from its own Bash; any fallout goes to a fresh `opus` agent | G-unit, then the live smoke of plan §7 ("Phase 1 tests and smoke") in the scratch clone with `claude-opus-5-5`. Every `wait` is called with Bash `timeout: 600000`. The `kill` check uses recorded PIDs (A18). | G-unit + the smoke criteria of plan §7 | none |
Commits: `🤖` (1.1 may use `✨`). **Fallbacks**:
- If G-unit is still red after 1.3's fallout agent plus one more fresh fix agent, hand back red and do not start Phase 2.
- If a smoke fork shows a verdict other than `hit` while P0.2 passed, it is a fingerprint bug. One fix, then re-smoke. If it still misses, hand back red with the raw stream paths.
- If the Athanor judges its context has passed about 600k (`models.md:19`, a guesstimate), hand back for a successor started from the run log.
### Phase 2: Bookkeeping
| Block | Owner | Scope | Gate | May stay broken |
|---|---|---|---|---|
| **2.1** Warmth and keep-warm | fresh, `opus` | plan §7 2.1, ping dedupe per C3(d); the cold-start rule per C5 | G-unit | `rebase`, `usage md` |
| **2.2** Rebase and re-train flags | fresh, `opus` | plan §7 2.2. A missing binary (the Desktop path is versioned, `F5a`) gives `retrain_due` with reason `binary_missing`, and launches refuse. | G-unit | `usage md` |
| **2.3** Usage rendering | fresh, `sonnet` | plan §7 2.3 with the fixed column contract (§4) | G-unit | none |
| **2.4** Phase tests, smoke, fallout | the Athanor, plus a fresh `opus` for fallout | the plan §7 "Phase 2 tests and smoke", same Bash rules as 1.3 | G-unit + the smoke criteria | none |
Commits `🤖`. The fallbacks are the same as Phase 1.
### Phase 3: Skill text, procedure text, release
| Block | Owner | Scope | Gate | May stay broken |
|---|---|---|---|---|
| **3.1** Procedure text | fresh, `opus` | `forks/SKILL.md` per plan §7 3.1 (C2 rule, C4 and C5 semantics, N1–N3, the A15 cut, the Claude-Code-only line); `agents/athanor.md` per plan §7 3.2 **plus** A7 (a `Forks:` line in the run log → `list` and adopt, never re-`init`), A13 (`:7-10`, `:23`, `:60`), A23 defaults; `implementation/SKILL.md` per plan §7 3.3 plus A13 (`:40`); `references/run-log.md` (with the fixed columns) | G-text items 1–4, 6, 7 | Versions still at 0.2.2. |
| **3.2** Reference rows | fresh, `sonnet` | `spawning-claude-code.md` per design §9 and plan §7 3.3, including the P0.0 and P0.1 rows marked `observed 2026-10-16 (preflight report)`, and the P0.8 row; `models.md` dated notes, including the A13 note on `:49` | every new row has one of the four statuses and a date | Versions still at 0.2.2. |
| **3.3** README, version, release | fresh, `sonnet` | the README `forks` row; both manifests at `0.3.0`; G-text in full; release commit `🤖 Spire 0.3.0: trained implementer forked per block (spire:forks)` with the changelog as its body (precedent `7c1ed39`): new skill, Athanor procedure change, Agent-tool fork path dropped, references updated, preflight report path, the known follow-ups. No attribution lines. | G-text in full, G-unit, G-where | none |
**G-text**:
1. `grep -n "fork of yourself\|That reading is your trunk\|read the fix's diff\|the code the phase touches" plugins/spire/agents/athanor.md` returns nothing.
2. `spire:forks` appears in athanor.md's frontmatter `skills:`.
3. `grep -n "a fork of the builder" plugins/spire/skills/implementation/SKILL.md` returns nothing.
4. `forks/SKILL.md` contains `600000`, `SPIRE_FORKS_CHILD` and `--paths`.
5. The `version` values in both manifests are equal and read `0.3.0`.
6. Every command name in SKILL.md is in `forks.py`'s dispatch, **and** every name in the dispatch (except `_supervise`) is in SKILL.md.
7. G-unit and G-where still pass.
**Phase 3 review**: a fresh reviewer checks the procedure text against the helper's real JSON and exit codes (run G-where and a stubbed `wait`). This is the Phase 1–2/3 seam. Fix-pass commits that land after the release commit stay 0.3.0 (A21).
---
## 7. Post-build (outside this build; the user is needed)
1. **Merge and push** `headless-forks` into the remote's default branch (U3). Until then nobody can install 0.3.0 (A8).
2. **Phase 4**, plan §7 unchanged, with these entry options (U1): (a) smoke 0.3.0 first with `--plugin-dir <worktree>/plugins/spire` in the scratch clone, with `spire@reliquary` disabled in the clone's `settings.local.json` (**assumed** workable), then update the marketplace; or (b) update the marketplace directly. Option (b) switches every project's Athanor before any smoke.
3. **Phase 5**, the trial, plan §7 unchanged. Entry needs U2.
4. Igor ticks goal boxes; the user decides on the "Proven" box.
---
## 8. Risks for the build
These shape the build. They are not the phase reviewer's scope.
| Risk | Where it bites | Handling |
|---|---|---|
| R1 detach under the uv and Bash job objects | P0.1, then every launch | Four-rung ladder; STOP only if all fail. |
| R2 large-base cache | P0.2 | STOP rule as planned. |
| R5 usage-field rule | P0.3 → 1.2 | 1.2 implements exactly the recorded rule. |
| R7 TTL refresh | P0.6 | Amended criterion; pings off on fail. |
| Windows file semantics | 1.1, 1.2, 2.1 | C3 plus its unit tests. |
| Contract drift between the Phase 1–2 JSON and the Phase 3 text | 3.1 | §4 fixes the seam; G-text 6; the Phase 3 review. |
| Orphaned forks | after the build, wherever 0.3.0 runs | N1; D4 follow-up. |
| R11 plan usage | Phase 0, smokes | Spend trigger; smokes on small bases. |
| R12 binary drift or disappearance (Desktop update) | any run | Version/size/mtime check; `binary_missing` → `retrain_due`. |
| Shipping the procedure change before the trial | post-build install | U1. |
| R4 injection and pre-approval in subagents | 0.3.0 in other projects | P0.8 proxy; fallback text in SKILL.md. |
---
## 9. Open decisions for the user
None of these blocks the build (Phases 0–3). U1–U3 must be settled before Phase 4.
- **U1. How 0.3.0 reaches installs.** Smoke via `--plugin-dir` before the marketplace update (default), or update directly. Updating changes the Athanor in every project before the smoke and the trial.
- **U2. The trial (D3).** (a) Which real multi-block plan. (b) Arm C after install: `--plugin-dir` on the cached 0.2.2 (`~/.claude/plugins/cache/reliquary/spire/0.2.2`, still present) or a worktree at `a7ce5db`; or run arm C before installing 0.3.0.
- **U3. Merge and push.** Who merges `headless-forks` and pushes, and when.
- **U4. `availableModels`.** If P0.5 shows that unlisted full ids are refused, do you want full Sonnet or Haiku ids added to `.claude/settings.json`? Without them, reliquary's forks are Opus-only. This build does not touch settings.
- **U5. Defaults you may override**: the orphan threshold of 20 min (N1), the Phase 0 spend trigger of $25, the worktree and branch name.
---
## 10. Follow-ups (recorded, not built)
- D4: `igor/SKILL.md:66` fork wording; `:112` `TaskStop` of an Athanor should be followed by `forks list/kill` on its run (N1 covers it after 20 min).
- `## Build` keys for the assertion pattern, safe parallel tests, and the default fork owner model (full id), in `spire:init` / `spire:project` (A23).
- README skills table drift; `.claude/spire/project.md:31-32` lists (A22).
- Root `AGENTS.md` still points at `g4b_ai` (plan §5).