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

338 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.
# 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).