Files
reliquary/docs/workpad/plans/2026-10-16-18-gremlins-phase3-contract.md
T
2026-10-16 18:36:24 +02:00

593 lines
67 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.
# Phase 3 contract digest: `spire:gremlins` (rename, platform seam, text, release)
**Written by**: the Athanor, `claude-opus-5-5[1m]`, 2026-10-16 18:31 +0200, at Phase 3 intake. Nothing is built in this turn.
**What this is**: the brief the Phase 3 block owners build against. It pins every point that the build plan, the goal's later decisions and Igor's Phase 3 brief leave open: the rename map, the platform interface, the gate, the N3/N4/flake fixes, the WSL check, the text per file and the release. In the last two phases every Important finding came from a detail a digest left open, so this one tries to leave nothing open. Where a choice was mine, it is marked **(choice)** and listed again in §12 for Igor's check.
**Precedence** (highest first):
1. Igor's Phase 3 brief (2026-10-16, this turn): the six steps, N3, N4, the flaky test, the test cadence, "no push, no merge".
2. The goal `docs/workpad/goals/headless-forks.md` **as on `main`** (the branch copy is older). Its sections "Decided … naming", "… platforms", "Amended … release gating" and "Decided … platform seam" override the build plan where they conflict.
3. Build plan `docs/workpad/plans/2026-10-16-02-headless-forks-build.md` §4, §6 Phase 3, G-text.
4. Plan `docs/workpad/plans/2026-10-16-01-headless-forks.md` §4, §7 Phase 3; design `docs/workpad/designs/2026-10-16-01-headless-forks.md` §2, §7, §8, §9.
5. The Phase 1 and Phase 2 digests (`plans/2026-10-16-12-…-phase1-contract.md`, `plans/2026-10-16-16-…-phase2-contract.md`): they still describe the helper's behaviour, under the old names.
6. This digest's own choices.
If this digest conflicts with a higher source, follow the higher source and say so in your handoff note.
**Standing rules for every child** (pass them on verbatim):
- Read, Write and Edit for files; never `cat`, `sed`, `awk` or heredocs to read or change files, even when auto mode suggests Bash. `git mv` is allowed for the rename.
- Do this yourself; do not dispatch further agents.
- Commits use gitmoji (`docs/workpad/specs/commit-conventions.md`), stage explicit paths only, and carry **no** `Co-Authored-By` or other attribution lines. No push, no merge, no PR.
- Between blocks: `uv run python -m py_compile <file>` plus only the test classes the block touched. The full suite runs once, at phase end (block 3.8).
- No live `claude` calls in Phase 3 (see §12, choice C17).
- Leave no processes running. Check by command line (`gremlins-test-`, `_supervise`, `poscontrol`, `claude-stub`), never by process name.
- Stamp your exact model id in your handoff note. Append the note to the run log `docs/workpad/reports/2026-10-16-08-headless-forks-run.md` under "## Phase 3", in the run-log handoff-note format.
- `docs/workpad/goals/` is read-only.
---
## 1. Blocks
| Block | Owner | Scope | Gate (between blocks) | Commit |
|---|---|---|---|---|
| **3.1** Rename | fresh, `opus` | §2: `forks` → `gremlins` everywhere in the skill, `fork` command → `spawn` with `method: "fork"`, env marker, state dir, kinds, JSON fields, templates, tests. Mechanical; no behaviour change. | py_compile of both .py files; the test classes `PathTests ScrubTests LockTests AtomicTests PidTests ProcessTreeTests InitTests CliTests RuleTests` plus `LaunchTests.test_train_spawn_continue_end_to_end` and `LaunchTests.test_refusals`; G-where; the residue grep of §2.8 | `🚚 gremlins: rename spire:forks to spire:gremlins (spawn, SPIRE_GREMLIN_CHILD)` |
| **3.2** Platform seam and gate | fresh, `opus` | §3: `Platform` interface, `WindowsPlatform`, `PosixPlatform`, the path matrix, the Windows-only gate (§4), JSON shape changes of §3.6. | py_compile; `PathTests PathMatrixTests GateTests PidTests ProcessTreeTests LockTests CliTests SubprocessTests` and `LaunchTests.test_kill_tree test_no_visible_window test_detach_failed`; G-where; the seam grep of §3.7 | `♻️ gremlins: platform seam (WindowsPlatform, PosixPlatform) and path matrix`, then `🚩 gremlins: Windows-only gate in where and init` (two commits allowed) |
| **3.3** N3, N4, kill flake | fresh, `opus` | §5 | py_compile; `InitTests`, the new N4 test, `LaunchTests.test_refusals test_kill_tree test_expire_kills_running_jobs` | `🐛 gremlins: init re-resolves the binary, late rebase of an inactive base, kill verification` |
| **3.4** WSL check | the Athanor, own Bash (a probe, like Phase 0) | §6. Findings to the scratchpad; they feed 3.7. No repo change unless the WSL run finds a seam bug (then a fix commit by a fresh `opus`, logged). | none | none (run-log note only) |
| **3.5** Procedure text | fresh, `opus` | §7.1–§7.4: `gremlins/SKILL.md` (new), `agents/athanor.md`, `implementation/SKILL.md`, `references/run-log.md` | G-text items 1–4, 6 (§9.2) | `🤖 gremlins: SKILL.md, Athanor procedure, implementation skill, run-log template` |
| **3.6** Reference rows | fresh, `sonnet` | §7.5–§7.6: `references/spawning-claude-code.md`, `references/models.md` | every new row has one of the four statuses and a date | `🤖 implementation references: headless sessions and gremlins` |
| **3.7** Platform handoff | fresh, `opus` | §8: `docs/workpad/reports/<YYYY-MM-DD-hh>-gremlins-platform-handoff.md` | none | `📝 Gremlins platform handoff for Linux and macOS` |
| **3.8** README, version, phase tests, release | fresh, `sonnet`; fallout to a fresh `opus` | §9: README row, both manifests 0.3.0, full G-unit once, G-where, G-text in full, the release commit | G-text in full, G-unit, G-where | `🤖 Spire 0.3.0: trained implementer, gremlins per block (spire:gremlins)` with the changelog body |
**Deviations from the build plan's Phase 3 table** (choice C1): the plan had 3.1 text, 3.2 rows, 3.3 release. Igor's brief adds the rename, the seam, the gate, the handoff and three fixes, in that order. Text comes after all code changes, so it is written against the final JSON. 3.3 is split from 3.2 so that the seam refactor lands green on its own before behaviour changes. The full suite runs once, in 3.8, before the release commit. If it is red: one fresh `opus` fallout agent, then one re-run.
Run-log handoff notes: each block owner appends its own. The Athanor writes the 3.4 note and the Phase 3 close.
---
## 2. Rename map (block 3.1)
The goal: a launched worker is a **gremlin**; the command that creates one is **`spawn`**; forking the base's session is one spawn **method**, recorded as `method: "fork"`; the trained session stays the **base**. No compatibility aliases.
**The rule for the word "fork" after the rename**: "fork" survives only for the Claude Code session mechanism (`--fork-session`, "a fork of the base's session", "forked") and as the value of `method`. It never names a command, a job kind, a worker, a field or a file.
### 2.1 Files and paths
| Old | New |
|---|---|
| `plugins/spire/skills/forks/` | `plugins/spire/skills/gremlins/` (`git mv`, so history follows) |
| `forks.py` | `gremlins.py` |
| `tests/test_forks.py` | `tests/test_gremlins.py` |
| `templates/fork.md` | `templates/spawn.md` **(choice C2)**: templates are named after the command, as `train.md` and `rebase.md` are. A later spawn method adds its own template. |
| `templates/train.md`, `templates/rebase.md`, `preamble.md` | same names, text updated (§2.6) |
| State dir `<run log without .md>-forks/` | `<run log without .md>-gremlins/` (`state_dir_for`, `run_for_state`) |
| Files inside the state dir (`registry.json`, `registry.lock`, `ledger.jsonl`, `jobs/`, `briefs/`, `notes/`, `results/`, `logs/`, `raw/`, `mcp-empty.json`, `.gitignore`) | unchanged |
| Gates | G-unit `uv run plugins/spire/skills/gremlins/tests/test_gremlins.py`; G-where `uv run plugins/spire/skills/gremlins/gremlins.py where` |
Delete stale `__pycache__/` folders under the old path if `git mv` leaves them (they are untracked).
### 2.2 Module identifiers (`gremlins.py`)
| Old | New |
|---|---|
| `ForksError` | `GremlinsError` |
| `cmd_fork` | `cmd_spawn` |
| `CHILD_MARKER = "SPIRE_FORKS_CHILD"` | `CHILD_MARKER = "SPIRE_GREMLIN_CHILD"` |
| `SCRUB_PINNED = {…, "SPIRE_FORKS_CHILD": "1"}` | `{…, "SPIRE_GREMLIN_CHILD": "1"}` |
| `spawn_supervisor` | `detach_supervisor` **(choice C3)**: "spawn" now means the command; starting the detached supervisor is "detach". Block 3.2 then moves its body into the platform (§3). |
| `SPAWN_METHOD` | removed in 3.2 (`PLATFORM.detach_method`); in 3.1 rename it `DETACH_METHOD` so 3.1 stays green on its own |
| CIM carrier env vars `SPIRE_FORKS_CMD`, `SPIRE_FORKS_CWD` | `SPIRE_GREMLIN_DETACH_CMD`, `SPIRE_GREMLIN_DETACH_CWD` (in `POWERSHELL_CIM` and its caller) |
| local `forks_` in `phase_boundary` | `spawned_phases` |
| `prog="forks.py"`, `"spire:forks helper …"` | `prog="gremlins.py"`, `"spire:gremlins helper …"` |
| module docstring | rewritten for gremlins |
### 2.3 Commands and flags
| Old | New |
|---|---|
| `fork --base B --block X --brief F [--phase N] [--mode sequential\|parallel] [--paths …] [--max-minutes] [--max-budget-usd] [--force] [--delta-note PATH]…` | `spawn` with the same flags **plus** `--method fork` (choices: `fork`; default `fork`) **(choice C4)**. The flag exists so a later method is an additive change. |
| `fork` in `COMMANDS`, `HANDLERS`, `--help`'s `commands` list | `spawn` |
| `continue --job J` | unchanged; `J` must be a `spawn` job (error text "continue takes a spawn job") |
| `train`, `rebase`, `wait`, `list`, `usage`, `expire`, `kill`, `tick`, `where`, `init`, `_supervise` | unchanged |
| `CHILD_REFUSED` contains `"fork"` | contains `"spawn"` |
| help texts: "Fork a base for one block.", "fork a base that is not active", "the files this fork may touch", "One more turn in a fork …", "working dir of base and forks", "(the phase whose forks it serves)", "(a fork and its continues)" | "Spawn a gremlin from a base for one block.", "spawn from a base that is not active", "the files this gremlin may touch", "One more turn in a gremlin (resume without --fork-session).", "working dir of the base and its gremlins", "(the phase whose gremlins it serves)", "(a spawn and its continues)" |
### 2.4 JSON fields and values
| Where | Old | New |
|---|---|---|
| job entry, result, ledger, `list` jobs | `kind: "fork"` | `kind: "spawn"` |
| job entry, result, ledger, `list` job rows (`LIST_JOB_KEYS`) | (none) | `method`: `"fork"` on `spawn` jobs, `null` on every other kind **(choice C5)**. Always present, so result and ledger keep fixed key sets. `continue` jobs carry `null` (they resume, they do not fork). |
| `spawn` launch return | `{job, base, base_state, warm_until, rebase_due, retrain_due, reasons}` | `{job, base, method, base_state, warm_until, rebase_due, retrain_due, reasons}` |
| base record, `list` base rows (`LIST_BASE_KEYS`) | `forks: [job…]` | `gremlins: [job…]` |
| `CAPPED_KINDS`, `HIT_KINDS`, `USAGE_KINDS`, `REAL_KINDS`, kind checks in `_conclude`, `phase_boundary`, `run_active`, `Supervisor.run` (`hit_written`), `finalize_job` (`head_at_end`) | `"fork"` | `"spawn"` |
| `usage` json `kinds` keys; md phase table `Kind` cells | `fork` | `spawn` |
| `usage` md hit-ratio fallback text | "n/a (no fork, continue or rebase call with a verdict)" | "n/a (no spawn, continue or rebase call with a verdict)" |
| N3 refusal | "`%s` is refused inside a fork (%s is set); a fork may only run where, list, usage and wait" | "`%s` is refused inside a gremlin (%s is set); a gremlin may only run where, list, usage and wait" |
| continue refusals | "the fork is still running", "the fork has no session" | "the gremlin is still running", "the gremlin has no session" |
| spawn refusals | "--mode parallel needs --paths (the files this fork may touch)", "base is not active (use --force to fork it anyway)" | "… (the files this gremlin may touch)", "base is not active (use --force to spawn from it anyway)" |
| `build_launch` refusal message | "forks never writes settings" | "gremlins never writes settings" |
| `preamble.md missing next to forks.py` | | `… next to gremlins.py` |
| reasons, flags, statuses, other keys | unchanged in 3.1 | (3.2 renames `spawn_failed` and `taskkill_rc`, §3.6) |
`SCHEMA` stays `1` (nothing released; no registry from before the rename is read again) **(choice C6)**.
### 2.5 Env vars
| Old | New |
|---|---|
| `SPIRE_FORKS_CHILD` (N3 marker in the child env) | `SPIRE_GREMLIN_CHILD` |
| `SPIRE_FORKS_CMD`, `SPIRE_FORKS_CWD` | `SPIRE_GREMLIN_DETACH_CMD`, `SPIRE_GREMLIN_DETACH_CWD` |
| (new in 3.2) | `SPIRE_GREMLINS_UNSUPPORTED_OK` (§4) |
### 2.6 Prompt text in the skill folder
- `preamble.md` title `# Headless implementer (spire:gremlins)`. Body: "You run headless under `claude -p` in a Spire build: first as the trained base, then as gremlins spawned from it once per block (each a fork of the base's session). Any session of yours may later be resumed or forked again." Keep the other rules; "the forks helper (`forks.py`)" → "the gremlins helper (`gremlins.py`)"; "only when your brief says this fork commits" → "this gremlin commits".
- `templates/spawn.md`: title `# Block ${block} (spire:gremlins)`; "You are a fork of base" → "You are a gremlin spawned from base `${base}` (a fork of its session; run log `${run}`)"; "## Rules for this fork" → "## Rules for this gremlin". The stale-snapshot sentence and every `${…}` placeholder stay.
- `templates/train.md`: "(spire:gremlins)"; "This session is trained once and then forked, once per block; every fork starts from …" → "This session is trained once; then one gremlin per block is spawned from it by forking it, and every gremlin starts from …"; "Forks write handoff notes" → "Gremlins write handoff notes".
- `templates/rebase.md`: "(spire:gremlins)"; "You are a fork of base" → "This session is a fork of base"; "every later fork of this build" → "every later gremlin of this build".
- `commit_rule` strings in `cmd_spawn`: "This gremlin runs in parallel with other gremlins: do NOT commit …" and "This gremlin commits: …".
### 2.7 Tests (`test_gremlins.py`)
- Module variable `forks` → `gremlins`, `_load_forks` → `_load_gremlins`, `FORKS_PY` → `GREMLINS_PY`, `sys.modules["gremlins"]`.
- Temp prefix `forks-test-` → `gremlins-test-` (`Base.setUp`, `tearDownModule`'s PowerShell filter `'gremlins' + '-test-'`, the M8 marker `gremlins-test-poscontrol-%d`).
- Helpers: `fork` → `spawn`, `fork_marked` → `spawn_marked`, `fake_fork` → `fake_spawn`, `FORK_RESULT` → `SPAWN_RESULT`, `SESS_FORK` → `SESS_SPAWN`.
- Test names: `test_train_fork_continue_end_to_end` → `test_train_spawn_continue_end_to_end`, `test_usage_rule_real_fork` → `test_usage_rule_real_spawn`, `test_allowed_inside_a_fork` → `test_allowed_inside_a_gremlin`; any other test name with `fork` the same way.
- Assertions on strings change with the code ("inside a fork" → "inside a gremlin", `"fork"` kinds → `"spawn"`, `forks` key → `gremlins`). **No assertion is dropped or loosened**; every changed assertion line is listed in the handoff note (old → new).
- Add one assertion each for `method`: the `spawn` launch return has `method == "fork"`; its result and ledger line have `method == "fork"`; a train/continue result has `method is None`.
- The stub (`STUB_PY`) keeps `--fork-session` handling (it is the claude flag).
### 2.8 Residue check (3.1 gate)
`git grep -n -i "fork" -- plugins/spire/` after the block. Every remaining hit must fall in one of: the claude flag `--fork-session`; `method` values `"fork"` and the `--method` choices; prose about the session mechanism ("fork of the base's session", "forked"); the Agent-tool fork rows in `implementation/references/spawning-claude-code.md` and `athanor.md`/`implementation/SKILL.md` (those are Phase 3.5/3.6 text, untouched in 3.1). List the remaining hits in the handoff note with their category. `git grep -n "spire:forks\|forks\.py\|SPIRE_FORKS\|skills/forks" -- plugins/` must be empty.
Workpad documents (`docs/workpad/…`) are history: none of them is renamed or edited, including this run log's title.
---
## 3. Platform seam (block 3.2)
All platform-specific code moves behind one interface in `gremlins.py` (one file stays the rule: stdlib only, PEP 723). It lives in one section between the markers `# --- platform seam: begin ---` and `# --- platform seam: end ---`.
### 3.1 Selection
```python
def select_platform() -> "Platform":
return WindowsPlatform() if os.name == "nt" else PosixPlatform()
PLATFORM = select_platform()
```
This is the only `os.name` test in the file. `IS_WINDOWS`, `CREATE_NO_WINDOW`/`_NOWIN` at module level, the module-level `ctypes` block, `_taskkill`, `_powershell`, `find_cygpath`, `cygpath_w`, `_GITBASH_DRIVE` and `POWERSHELL_CIM` all move into the classes. WSL is `PosixPlatform` (it is Linux).
### 3.2 The interface
```python
class Platform:
name: str # "windows" | "posix"
detach_method: str # "cim-hidden" | "setsid"
supported: bool # WindowsPlatform: True. PosixPlatform: False (the 0.3.0 gate, §4)
def describe(self) -> dict
def is_wsl(self) -> bool
@staticmethod
def norm_path(p: str, cwd: str | None = None) -> str
def popen_kwargs(self, new_group: bool = False) -> dict
def detach(self, argv: list[str], cwd: str, log_path: str) -> dict
def proc_ctime(self, pid: int) -> int | None
def epoch_to_ctime(self, t: float | None) -> int | None
def pid_alive(self, pid: int, ctime: int | None = None) -> bool
def process_table(self) -> list[tuple[int, int, str]]
def terminate(self, pid: int) -> bool
def tree_kill(self, pid: int) -> int | None
def restore_no_replace(self, src: str, dst: str) -> bool
def visible_windows(self) -> list[tuple[int, int, str, str]] | None
```
Semantics, per method. "As today" means the current body of the named function, moved without behaviour change.
| Method | `WindowsPlatform` | `PosixPlatform` |
|---|---|---|
| `describe()` | `{"name": "windows", "sys_platform": sys.platform, "wsl": False, "supported": True, "detach": "cim-hidden"}` | `{"name": "posix", "sys_platform": sys.platform, "wsl": self.is_wsl(), "supported": False, "detach": "setsid"}` |
| `is_wsl()` | `False` | `True` if `WSL_DISTRO_NAME` is set, or `/proc/sys/kernel/osrelease` contains `microsoft` (case-insensitive); unreadable → `False` |
| `norm_path(p, cwd)` | the current Windows branch of `norm_path`, including M13's `cygpath -w` for Git Bash rooted non-drive paths, plus the WSL refusal of §3.4. Pure string logic except the cygpath call; must run on any host. `find_cygpath()` and `cygpath_w()` become static methods of this class. | the current POSIX branch, plus the refusals of §3.4. Pure; runs on any host. |
| `popen_kwargs(new_group)` | `{"creationflags": 0x08000000}` (`CREATE_NO_WINDOW`), `new_group` ignored | `{"start_new_session": new_group}` |
| `detach(argv, cwd, log_path)` | as today's CIM branch of `spawn_supervisor` (`powershell` with `CREATE_NO_WINDOW`, `Win32_ProcessStartup.ShowWindow=0`, command line and cwd through `SPIRE_GREMLIN_DETACH_CMD`/`_CWD`); `log_path` unused (the supervisor redirects itself) | as today's POSIX branch (`Popen(argv, cwd=cwd, stdin=DEVNULL, stdout/stderr=log file, start_new_session=True, close_fds=True)`) |
| return of `detach` | `{"ok": True, "method": self.detach_method, "pid", "ctime"}` or `{"ok": False, "method", "detail"}` | same |
| `proc_ctime(pid)` | `OpenProcess` + `GetProcessTimes` (FILETIME ticks), as today | `/proc/<pid>/stat` field 22, as today; `None` without `/proc` |
| `epoch_to_ctime(t)` | FILETIME conversion, as today | `/proc/stat` `btime` × `SC_CLK_TCK`, as today; `None` without `/proc` |
| `pid_alive(pid, ctime)` | as today (access denied → alive, creation time unverifiable) | as today (`os.kill(pid, 0)`; `PermissionError` → alive) |
| `process_table()` | Toolhelp32 snapshot, as today | `/proc` walk, as today; `[]` without `/proc` (macOS: the known gap, §8) |
| `terminate(pid)` | `OpenProcess(PROCESS_TERMINATE …)` + `TerminateProcess`, as today | `os.kill(pid, SIGKILL)`, as today |
| `tree_kill(pid)` | `taskkill /T /F /PID <pid>` (System32 path), returns its exit code; `OSError`/`SubprocessError` → logged, `None` | `os.killpg(pid, SIGKILL)` with `OSError` suppressed; returns `None` |
| `restore_no_replace(src, dst)` | `_retry(lambda: os.rename(src, dst))` (Windows rename never overwrites) | `os.link(src, dst)` then `os.remove(src)` |
| `visible_windows()` | `EnumWindows` + `IsWindowVisible` + `GetWindowThreadProcessId` + class and title, i.e. the test helper `visible_windows()` moved into the helper **(choice C7)**: the user named "the window checks" as part of the seam | `None` (not applicable) |
`ctypes` is loaded lazily inside `WindowsPlatform` (on first use by a process method or `visible_windows`), never at import or in `__init__`. So `WindowsPlatform()` and `PosixPlatform()` can both be constructed on any host, and the path matrix and the gate tests run everywhere.
### 3.3 Module-level functions that stay (neutral code calls these)
These keep their names and signatures, so the cross-platform logic and the existing tests' `mock.patch.object(gremlins, …)` keep working. Each is a thin delegate with no platform branch:
- `norm_path(p, cwd=None, windows=None)`: `windows=True` → `WindowsPlatform.norm_path`, `False` → `PosixPlatform.norm_path`, `None` → `PLATFORM.norm_path`. (The parameter is a selector the tests use; it is not a platform check.)
- `proc_ctime`, `epoch_to_ctime`, `pid_alive`, `process_table` → `PLATFORM.<same>`.
- `pid_record`, `record_alive` unchanged (they call the delegates).
- `terminate(rec)`: `if not record_alive(rec): return True`, then `PLATFORM.terminate(int(rec["pid"]))`.
- `process_tree(rec, alive_until)`: unchanged logic; it must call the **module-level** `process_table`, `proc_ctime`, `pid_alive`, `epoch_to_ctime`, never `PLATFORM.` directly (`ProcessTreeTests` patch the module names).
- `kill_tree(rec, extra, alive_until)`: unchanged logic; the one OS step becomes `rc = PLATFORM.tree_kill(int(r["pid"]))`.
- `_run(...)`: `**PLATFORM.popen_kwargs()` instead of `creationflags=_NOWIN`.
- `detach_supervisor(state, run, job)`: builds `argv = [supervisor_interpreter(), "-I", script_path(), "_supervise", "--run", run, "--job", job]` and returns `PLATFORM.detach(argv, cwd=state, log_path=raw_file(state, job, ".sup.log"))`. Tests patch `gremlins.detach_supervisor`.
- `Supervisor.run`: the claude `Popen` takes `**PLATFORM.popen_kwargs(new_group=True)` (keeps today's POSIX `start_new_session=True` exactly; M14 is documented, not fixed).
- `RegistryLock._restore(aside)`: `return PLATFORM.restore_no_replace(aside, self.path)`.
Neutral already, and staying outside the seam: `_retry`/`PermissionError` retries (harmless on POSIX), `RegistryLock._try_create`'s `getattr(os, "O_BINARY", 0)`, `scrubbed_env` (`os.pathsep`), `_venv_prefixes`, `supervisor_interpreter`, `transcript_path`.
### 3.4 Path-form matrix
New test class `PathMatrixTests`: every row × both classes, pure (no filesystem), `WindowsPlatform.find_cygpath` patched. Windows cwd `G:\dev\proj`; POSIX cwd `/home/u/proj`.
| # | Input | `WindowsPlatform.norm_path` | `PosixPlatform.norm_path` |
|---|---|---|---|
| 1 | `C:\x\y.md` | `C:\x\y.md` | refused **(choice C8)** |
| 2 | `c:/x/y.md` | `C:\x\y.md` | refused |
| 3 | `C:` / `C:x` (drive-relative) | as today (cwd-of-drive semantics via `ntpath`; record what it gives, assert it is absolute with drive `C:`) | refused |
| 4 | `/c/x/y.md` (MINGW) | `C:\x\y.md` | `/c/x/y.md` |
| 5 | `/c` | `C:\` | `/c` |
| 6 | `/tmp/x` | with a fake cygpath: its `-w` output (`C:\Fake\tmp\x`); without cygpath: refused (M13) | `/tmp/x` |
| 7 | `\\srv\share\x\y.md` (UNC) | `\\srv\share\x\y.md` | refused |
| 8 | `//srv/share/x/y.md` | `\\srv\share\x\y.md` | `//srv/share/x/y.md` (`posixpath.normpath` keeps exactly two leading slashes) |
| 9 | `/mnt/c/x/y.md` (WSL) | refused **(choice C9)**: "WSL path form on Windows; pass C:\… or /c/…" | `/mnt/c/x/y.md` |
| 10 | `docs/a.md` | `G:\dev\proj\docs\a.md` | `/home/u/proj/docs/a.md` |
| 11 | `docs\a.md` | `G:\dev\proj\docs\a.md` | refused (a backslash on POSIX is almost always a Windows path) |
| 12 | `\rooted\a` | `G:\rooted\a` (the cwd's drive, as today) | refused |
| 13 | `../z` | `G:\dev\z` | `/home/u/z` |
| 14 | ` G:/x/y.md ` | `G:\x\y.md` | refused |
| 15 | `""` / `" "` | refused | refused |
"Refused" = `GremlinsError`, exit code 2, `payload["error"]` a sentence naming the form, `payload["path"]` the input. The POSIX refusals are: a drive form (`^[A-Za-z]:`), or any backslash. That is the only POSIX behaviour change in this phase, and it exists because the matrix needs a defined answer (a drive path silently joined onto the cwd is today's behaviour and is wrong).
The existing `PathTests` stay (some become redundant; keep them, they are cheap).
### 3.5 Tests that move or change
- `visible_windows()`/`console_windows()` in the tests call `gremlins.PLATFORM.visible_windows()`; `test_no_visible_window` skips when it returns `None` or an empty list.
- Tests that patch `find_cygpath` patch `gremlins.WindowsPlatform.find_cygpath` (a `staticmethod`).
- `test_spawn_failed` becomes `test_detach_failed` (the reason is renamed, §3.6). It patches `detach_supervisor`; the fake return uses `"method": gremlins.PLATFORM.detach_method`, and it asserts `reason == "detach_failed"`.
- `CliTests`/`SubprocessTests` `where` assertions: `out["platform"]["detach"] == "cim-hidden"` on Windows (replaces `out["spawn"]`).
- Tests may still branch on the platform where they test platform behaviour; they use `gremlins.PLATFORM.name == "windows"` instead of their own `os.name` check, except the generated stub wrappers (`.cmd` vs `sh`) and `tearDownModule`'s PowerShell sweep, which may keep `os.name` (test-only code).
### 3.6 JSON shape changes in 3.2
| Where | Old | New | Why |
|---|---|---|---|
| `where` | `"spawn": "cim-hidden"` | removed; `platform.detach` carries it | "spawn" is now a command |
| `where` | `"platform": "win32"` | `"platform": {name, sys_platform, wsl, supported, detach, override}` (`override` per §4) | the gate needs a structured answer |
| `kill`, `expire.killed[]` | `taskkill_rc` | `tree_kill_rc` | platform-neutral key **(choice C10)** |
| reason vocabulary | `spawn_failed` | `detach_failed` | **(choice C10)**: the supervisor's detach failed, for any kind; `spawn_failed` would read as "the spawn command failed" |
### 3.7 Seam check (3.2 gate)
`git grep -n -E "os\.name|sys\.platform|IS_WINDOWS|ctypes|_k32|taskkill|powershell|killpg|setsid|start_new_session|creationflags|cygpath|/proc" -- plugins/spire/skills/gremlins/gremlins.py`: every hit lies between the seam markers, except `sys.platform` inside `describe()` (also inside) and string mentions in docstrings/comments outside, which the handoff note lists. The green Windows suite is the refactor's guard (goal).
---
## 4. Windows-only gate (block 3.2, second commit)
- **Rule**: `PLATFORM.supported` is `True` only for `WindowsPlatform`. Linux, WSL (it is Linux) and macOS are `PosixPlatform`, which is `supported = False` in 0.3.0.
- **Neutral helper** `platform_gate() -> str | None`: `None` when supported or overridden; else the reason: "spire:gremlins 0.3.0 runs on Windows only; this is <sys_platform><, WSL>. Use fresh agents for every block (the 0.2.2 shape); see SKILL.md, Platforms."
- **`where`**: on a closed gate, `ok: false`, the reason joined into `reason`/`error` like the other reasons, exit 2. Everything else in the report is still filled (binary, uv, git, scrub), because the porting Igors need it.
- **`init`**: refuses with exit 2, `{"error": "platform not supported", "reason": <the gate reason>, "platform": PLATFORM.describe()}` before any write **(choice C11)**. The goal names only `where`; refusing `init` makes "fail closed" mean the helper cannot be used, not only that it says so. Every other command needs `init`, so nothing launches on a closed platform.
- **Override (porting aid)** **(choice C12)**: env `SPIRE_GREMLINS_UNSUPPORTED_OK=1` opens the gate for that call. `where` then reports `platform.override: true`, stays `ok: true` if nothing else fails, and adds a `warnings` entry ("platform gate overridden …; this platform is untested"). It exists so the Linux and macOS Igors (and the WSL check) can run the suite and the smoke before they lift the gate in code. It is documented in the handoff report, **not** in SKILL.md.
- **Lifting the gate** is a code change: `PosixPlatform.supported` returns `True` for the tested `sys.platform` values (the handoff report says how).
- **Tests** (`GateTests`): `WindowsPlatform().supported is True`; `PosixPlatform().supported is False` with `sys.platform` patched to `linux` and to `darwin`; with `gremlins.PLATFORM` patched to a `PosixPlatform` whose `is_wsl` returns `True` and with the override env removed: `where --binary <stub>` exits 2, `ok: false`, `reason` names "Windows only" and "WSL", `platform.supported` false; `init` exits 2 and creates no state dir; with the override env: `where` exits 0 with the warning, `init` proceeds. On the real Windows host: `where` has `platform.supported: true`, `override: false`.
- `Base.setUp` sets `SPIRE_GREMLINS_UNSUPPORTED_OK=1` in its patched `os.environ` for every test **(choice C13)**, so the same suite exercises the POSIX code on WSL and on the porting hosts; `GateTests` remove it. On Windows the variable changes nothing.
---
## 5. N3, N4 and the flaky kill test (block 3.3)
### 5.1 N3: `init` re-run and the binary
- On a re-run **without** `--binary`: if the stored `binary_resolved_by` is `"--binary"`, keep the stored path (an explicit pin). Otherwise re-resolve with `resolve_binary(None)` (`$CLAUDE_CODE_EXECPATH`, else PATH) **(choice C14)**. If re-resolution finds nothing, keep the stored path.
- `init` output always carries `warnings: []`. It gets one entry when the binary path changes ("binary changed: <old> → <new> (resolved by <how>); existing bases keep their own binary record, new trainings use the new one") and one when re-resolution failed and the stored path was kept.
- The change shows in `changed_fields` as `launch.binary.*` (it already would). Existing bases are untouched: they keep their own `launch.binary`, so a still-present old binary gives no drift and a vanished one gives `binary_missing` as today.
- Tests (`InitTests`): a first init via `$CLAUDE_CODE_EXECPATH` = stub A, a re-run with the env pointing at stub B and only `--pings off` → path B, warning, `launch.binary.path` in `changed_fields`; a first init with `--binary A`, a re-run with the env at B → path A kept, no warning; the stored file deleted and the env unset, PATH without claude → stored path kept with the "could not re-resolve" warning (then `build_launch` refuses the missing file with exit 2, as today, which is loud).
- `where` is unchanged (it takes no `--run`).
### 5.2 N4: a rebase that ends after its base stopped being active
- In `_rebase_succeeded`: when the old base's status is not `active` at finalise (a `train --supersedes` ended first; or an `expire` raced the kill), create NEW with the old base's status (`superseded` with the old base's `superseded_by` and `superseded_at` = this end time; or `retired` with `retired_at`), not `active` **(choice C15)**. The old base is left untouched (its `superseded_by` is **not** overwritten). The job entry records `rebase_inactive_parent: "<old> was <status> when the rebase ended"`.
- The rebase result keeps `status: ok` (the call worked) and gets the new flag **`parent_inactive`**. Compute it in `_conclude` before the result is built (the base's status is known there).
- So NEW gets no pings (status gate) and needs `--force` to spawn from, like any superseded base.
- Test (`LaunchTests`): train B1; start `rebase --base B1 --as B1r` with a stub `sleep`; `train --base B3 --supersedes B1` that ends `ok` first; when the rebase ends: its result `ok` with `parent_inactive`; B1r `superseded`, `superseded_by == "B3"`; B1 `superseded_by == "B3"` unchanged; `ping_decision` for B1r reads `status:superseded`; `spawn` from B1r without `--force` exits 2.
### 5.3 The flaky `LaunchTests.test_refusals` (`kill` returned `ok: false` under full-suite load)
**Investigation** (bounded: about 15 minutes of wall time):
1. First make the failure speak: `out = self.cmd("kill", "--job", j); self.assertTrue(out["ok"], out)`. (The assertion today discards the survivors.) This adds information, it loosens nothing.
2. Try to reproduce under load: run `LaunchTests.test_refusals` alone 10 times while a CPU load runs (a handful of scratch-dir Python busy loops, recorded by pid and killed afterwards). **Never run two copies of the suite at once**: each `tearDownModule` kills every `gremlins-test-` process, including the other copy's.
3. Classify each survivor from the kill output and a follow-up check of its pid:
- **(a) pid reuse**: the pid now belongs to a process that `OpenProcess` refuses (`ERROR_ACCESS_DENIED`), so `pid_alive` reports it alive with an unverifiable creation time. This is a product defect.
- **(b) slow exit**: the same pid and creation time is still alive after `KILL_VERIFY_S` and gone a little later (exit stalled under load). Timing.
- **(c) unkillable member**: `TerminateProcess` keeps failing for a real tree member. A product defect.
**Fixes, decided now**:
- **Always** (whether or not it reproduces) fix (a) in the neutral `kill_tree` verification: each sweep iteration takes one `process_table()`; a snapshot entry counts as a survivor only if `record_alive(p)` **and** the table still has that pid with the same `name` as in the snapshot. A pid that is gone from the table or now carries another name is dead **(choice C16)**. Reading the code shows (a) is a real false positive, rare or not. Test: a `kill_tree` unit test on a fake table (`ProcessTreeTests` style) where a snapshot pid reports alive (access denied) but the table shows another name → no survivor; same name → survivor.
- If (b) is observed: raise `KILL_VERIFY_S` from 10 to 30 s (the loop exits as soon as everything is dead, so the normal case costs nothing). Keep the test assertion strict.
- If (c) is observed: fix in `WindowsPlatform.terminate`/`tree_kill` with a failing test first, and flag it.
- Record in the handoff note: reproduced or not, the class, the survivors' names, and which fix landed. If nothing reproduces, say so and say that only the (a) guard landed.
---
## 6. WSL check (block 3.4; the Athanor's own Bash)
Purpose (goal): does the helper run under WSL Ubuntu, and does PATH interop resolve `claude` to the Windows exe? WSL stays fail-closed regardless. Findings feed the handoff (3.7). No live `claude` calls; no WSL login is needed.
Traps: Git Bash rewrites `/mnt/…` arguments, so every `wsl` call from Git Bash is prefixed `MSYS_NO_PATHCONV=1`. Never `wsl --shutdown` or `--terminate` (other WSL work on the host would die); the distro was `Stopped` at intake and idles back down by itself. Raw output goes to the session scratchpad `…\scratchpad\gremlins-wsl\` (not in git).
Steps:
1. **Inventory**: `wsl -d Ubuntu-20.04 -- bash -lc 'uname -a; head -3 /etc/os-release; command -v uv python3 python3.10 python3.11 python3.12 git; python3 --version; ls ~/.local/bin ~/.cargo/bin 2>/dev/null'`.
2. **PATH interop**: `bash -lc 'type -a claude claude.exe; echo "$PATH" | tr : "\n" | grep -c ^/mnt/; cat /etc/wsl.conf 2>/dev/null; echo EXECPATH=${CLAUDE_CODE_EXECPATH-unset} WSL_DISTRO_NAME=$WSL_DISTRO_NAME'`. Record what `claude` resolves to (a `/mnt/c/…` Windows shim or `.exe`, a Linux install, or nothing) and whether `$CLAUDE_CODE_EXECPATH` crosses (it should not, without `WSLENV`).
3. **uv / Python ≥ 3.10**: if uv is present, use it. Else, if a `python3.10+` is present, run the files with it directly (the PEP 723 header has no dependencies). Else install uv with the official installer (`curl -LsSf https://astral.sh/uv/install.sh | sh`, to `~/.local/bin`) **only if the Bash permission system lets the call through**. That is a download and execute, so it needs the user's approval through the permission prompt. If it is denied or blocked, record "not run: no Python ≥ 3.10 or uv in WSL; installing needs user approval", and the WSL check ends with steps 1–2.
4. **Copy to ext4**: `cp -r /mnt/g/dev/projects/reliquary/.claude/worktrees/headless-forks/plugins/spire/skills/gremlins ~/gremlins-wsl-check/` (drvfs has different chmod, lock and rename semantics; the copy isolates the helper from them).
5. **Runs**, each to a log file under `/mnt/c/Users/g4b/AppData/Local/Temp/claude/G--dev-projects-reliquary/42c87f82-5542-46aa-8d01-4166e3f0bcfe/scratchpad/gremlins-wsl/`:
- `uv run ~/gremlins-wsl-check/gremlins.py where`: expect exit 2, `ok: false`, the gate reason with "WSL", `platform.wsl: true`. Record the binary block (did it resolve the Windows `claude`, and does `--version` of it run through interop?).
- The same with `SPIRE_GREMLINS_UNSUPPORTED_OK=1`.
- `where` once from the drvfs path (`/mnt/g/…/gremlins.py`), to record any difference.
- The unit suite: `timeout 1200 uv run ~/gremlins-wsl-check/tests/test_gremlins.py -v`. Then `PathMatrixTests` and `GateTests` alone. Record pass/fail/error/skip per class and the first line of each failure, grouped by cause (`/proc`, `setsid` detach, `killpg`/M14, stub wrapper, git missing, chmod, locks, path refusals, timing).
6. **Leftovers**: `ps -eo pid,args | grep -E "gremlins|_supervise|claude-stub" | grep -v grep`; kill only our own by pid. Then `rm -rf ~/gremlins-wsl-check` (our scratch copy).
7. **Findings** → `…\scratchpad\gremlins-wsl\findings.md` (inventory, interop answer, `where` outputs, suite table, failure causes, what would need fixing). The Athanor writes the 3.4 run-log note; 3.7 cites the findings.
If the WSL run shows a seam bug that also matters on Windows, or a crash in neutral code, a fresh `opus` fixes it with a test before 3.5 (logged as a deviation). POSIX-only failures are **not** fixed (the brief: "don't fix POSIX beyond what the seam needs"); they go into the handoff.
---
## 7. Text changes per file (blocks 3.5 and 3.6)
Write against the helper's real output: run `where`, `<command> --help` (one JSON object each) and read `_conclude`, `cmd_wait`, `cmd_list`, `kill_job`. The result fields and vocabularies listed in §7.1 are as built after 3.1–3.3; if the code differs, the code wins and the note says so.
### 7.1 `plugins/spire/skills/gremlins/SKILL.md` (new; 3.5)
Frontmatter (design §2 pattern, renamed):
```yaml
---
name: gremlins
description: Trains one headless implementer (the base) per build and spawns gremlins from it, one per complex block, by forking the base's session (`claude -p --resume --fork-session`). Owns the command line, the prompt-cache bookkeeping and the usage ledger. Windows-only in 0.3.0. Loaded by the Athanor.
allowed-tools: Bash(uv:*)
---
!`uv run "${CLAUDE_SKILL_DIR}/gremlins.py" where`
```
Body, short (target 120–180 lines; the design says "Short"). Sections and the facts each must carry:
1. **What and when.** The Athanor's build path for complex blocks: one base per build (trained early, reads the code, writes the trunk notes), one gremlin per complex block, fresh Agent-tool agents for mechanical or distant blocks. Claude Code only (Codex loads this skill too; it does not apply there). Words: base, gremlin, `spawn` (method `fork`), `continue`, `rebase`, ping.
2. **Platforms.** Windows-tested. On any other platform (Linux, WSL, macOS) `where` reports `ok: false` with the reason and `init` refuses; then use fresh agents for every block (the 0.2.2 shape). Do not try to work around it.
3. **Setup.** The injected `where` gives `script` (use that absolute path in every call) and `ok`. If `ok` is false, see Platforms or the reason. Projects need an allow rule `Bash(uv run:*)` (or `Bash(uv:*)`): the skill's `allowed-tools` did not pre-approve calls in a subagent (P0.8, untrusted dir; Phase 4 re-checks). `$SCRIPTS_UV` only on hosts without `uv` on PATH (A15). `init` once per run, with a full model id. A successor never re-runs `init` to "reset": `list` and adopt. A re-run changes only the flags given, except that it re-resolves the binary unless it was pinned with `--binary` (N3). `--warm-margin-s` must stay below the TTL (at 1 h, at most 1800 takes effect). Pass `--allowed-tools` together with `--tools` (Minor 12).
4. **Commands** table, first column exactly ``| `<name>` |`` for: `where`, `init`, `train`, `spawn`, `continue`, `rebase`, `wait`, `list`, `usage`, `expire`, `kill`, `tick` (G-text 6 compares this set with `--help`'s `commands`). One line each: key flags and the return shape (`spawn` returns `{job, base, method, base_state, warm_until, rebase_due, retrain_due, reasons}`; `train` and `rebase` return `{job, base, session_id}` (+ `parent` for rebase); `continue` returns `{job}`). Every command except `where` takes `--run <run log .md>`. Exit codes 0 / 2 / 3 / 4. stdout is one JSON object (`usage --format md`: one markdown fragment).
5. **Waiting.** Call `wait` from Bash with `timeout: 600000`; never omit it (C2). The Bash tool **backgrounds** a call that passes its timeout instead of killing it (P0.1). Loop `wait --all` until exit 0 or 4. Exit 3 means "call again", and `done` may already hold finished (also failed) results (C4). Pings come back in `pings`, never in `done`. `running[]` rows count `messages`; results and the ledger count `turns` (M16). A failed detach (`detach_failed`) shows up in the next `wait`, not in the launch return.
6. **Results.** The result fields (`job, kind, method, base, block, phase, status, flags, fingerprint, session, parent_session, models_used, tokens{input, output, cache_creation, cache_creation_1h, cache_creation_5m, cache_read}, cost_delta_usd, cache, base_state, wall_s, turns, denials, note, raw, paths, reason, model_requested`). Statuses `ok|failed|timeout|killed|lost`. Reasons: `model_refused, usage_limit, overloaded, prompt_too_long, api_error, is_error, exit_code, no_result, detach_failed, launch_error, supervisor_error` (failed); `kill, expire, orphaned` (killed); `supervisor_dead` (lost); `null` otherwise (`timeout` too). Flags: `stalled, needs_attention, usage_mismatch, ttl_downgrade, cold_start, clock_jump, ping_missed, orphaned, expired, parent_inactive`. A cold base usually shows `partial`, because the shared system prefix stays warm (C5).
7. **Rules (flags, not judgment).**
- `rebase_due` → `rebase --as <new> --phase <n>` before the next spawn; **always pass `--phase`**; `rebase` counts toward `parallel_cap`. `retrain_due` → `train` a new base with `--supersedes <old>`, else the old base keeps getting pings. `binary_missing` → launches refuse; train with the new binary. A cold base is still spawnable; coldness never triggers a re-train.
- Delta: attach handoff notes with `spawn --delta-note PATH` (repeatable). A `## Changes since base` section in a brief holds this gremlin's delta only; no line in it may start with `#` (also not inside fenced code: it ends the section, N1). When rebasing, attach every note written since the base's `trained_at` (shown by `list`), because a note older than that counts as known to the base whether or not it was given to it (N2).
- `continue` sends the brief without a frame: put the commit rule and the note path into it (M16).
- Sequential gremlins one at a time (Minor 13). Parallel only for disjoint blocks, with `--mode parallel --paths …` (N2); parallel gremlins never commit; the Athanor commits each block by the paths its note lists, one at a time.
- Nothing running at hand-back: `list` shows no running job other than pings (kind `ping`; a ping ends within 300 s and is not a leak, Minor 7). Otherwise `kill` it and flag it.
- `kill`: key on `ok` and `status`, not on `killed` or the exit code (M12). `killed: true, status: ok` means the job finished first; `ok: false` lists survivors (exit still 0): flag it.
- The orphan watchdog (N1): a gremlin is killed (`orphaned`) after `orphan_after` (default 1200 s) without an orchestrator call (`wait`, `spawn`, `continue`, `train`, `rebase`, `list`, `usage`). `kill` and `expire` do not count (Phase 1 M3), and a gremlin's own calls never do. So keep looping `wait` while gremlins run.
- Inside a gremlin, `SPIRE_GREMLIN_CHILD=1` is set and `train`, `spawn`, `continue`, `rebase`, `kill`, `expire`, `init`, `tick` refuse (N3); `where`, `list`, `usage`, `wait` work.
- Models: full ids only; `spawn` takes the base's model (no `--model`). Admission is "permissive at `init`, enforced at runtime" (M15): a substituted model ends `failed`/`model_refused`.
- After `claude` exits, its leftover descendants are killed, including daemons a commit may have started (`gpg-agent`, `git fsmonitor--daemon`); they restart on demand (M16).
- A re-train that supersedes a base while a rebase of it is running: the rebase's new base arrives superseded and its result carries `parent_inactive` (N4). Re-train first, or let the rebase finish.
- Never resume or fork a headless session by hand; never call `claude -p` yourself; never `SendMessage` or `TaskStop` a gremlin (`continue` and `kill` cover it).
8. **State.** `<run log without .md>-gremlins/` next to the run log: what is committed at phase close (everything but `raw/` and lock files) and what the Athanor reads (`notes/`, `results/`, `logs/`).
### 7.2 `plugins/spire/agents/athanor.md` (3.5)
Design §8.4 plus build plan A7, A13, A23, plus the goal's decisions:
- **Frontmatter `description`**: keep the furnace sentences and trigger phrases ("Fire up the Athanor", "into the Athanor with it"), the Opus pin, "may use the Agent (Task) tool", "does not plan, attack plans, or review its own output", and the non-build line. Replace "blocks in the foreground" with: "trains a headless base and spawns gremlins for complex blocks, fresh agents for mechanical ones, a cheap gate between blocks, tests once, a run log with usage — and hands back".
- **`skills:`** add `spire:gremlins` (G-text 2).
- **Line 23**: "otherwise orient via direct file reads" → "otherwise orient by reading plans, designs, reports and notes, not code".
- **Intake (step 1)**: read the build plan, design, run log, trunk notes and the last review; check the branch. Remove "the code the phase touches. That reading is your trunk" (G-text 1). Then:
- If the run log header has a `Gremlins:` line, run `list` and adopt the active base. Never re-`init`, never re-train an active base (A7).
- Else run `where`. If `ok`: `init` (full model id = the plan's owner model for complex blocks; effort; the project's agent file if any), then `train` the base with a brief naming the plan, design, the phase's blocks, the areas to read and the trunk-notes path. The base writes the trunk notes. Add `Gremlins: <state dir>` to the run log header.
- If not `ok` (any non-Windows host): fresh agents for every block. A fresh agent reads the code the phase touches and writes the trunk notes **(choice C18)**; the Athanor still does not read code.
- Later phases: `rebase --as B<n+1> --phase <n>` with the delta (handoff notes, review findings, commit range).
- **Step 2 (build the blocks)**: complex block in the trained area → `spawn` from the current base with a delta brief (block id and plan section, the handoff notes since the base as `--delta-note`, open review flags touching the block, the mode, the gate and test commands); then `wait` until done; read `notes/<block>.md` and the result. Mechanical or distant block → fresh Agent-tool agent in the foreground with the trunk notes. A complex block whose owner model differs from the base: a gremlin anyway (logged deviation) or its own base; never a model override (refused). Remove "a fork of yourself …" and the fork-behaviour sentence (G-text 1). Obey `rebase_due`/`retrain_due` as SKILL.md says.
- **Step 3 (gate)**: the plan's command, output to a `.txt` under the state dir's `logs/`. Red → `continue --job <gremlin>` once with the log path; still red → flag it and go on per "what may stay broken".
- **Mechanical check** after each block (replaces "read the fix's diff for loosened assertions"; G-text 1): commit present (`git log --oneline`); the evidence log exists and is not empty; `git diff --numstat <range> -- <test paths>`; removed assertion lines `git diff <range> -- <test paths> | grep -c '^-.*assert'` (pattern `assert` by default, A23). A non-zero count goes to the block's note as a question and into the reviewer flags.
- **Step 4 (tests once)**: the Athanor runs the test command to a file under `logs/` (never through `tail`; the first run after a fresh worktree is a warm-up, rerun once). Fallout → a gremlin from the current base (or `continue` of the block that caused it) with the failing log as the delta. Parallel test runs are off unless the project says they are safe (A23 default).
- **Step 5 (run log)**: block rows from `usage --block <id> --format md`; notes appended from `notes/`; phase close with `usage --phase <n> --format md` (per-kind table, cold starts, hit ratio, priced total) and every flag.
- **Step 6 (hand back)**: `list` shows nothing running except pings. Do not `expire` the base: a review and fix pass within the TTL finds it warm, and later it is cold but still spawnable.
- **Fix pass**: gremlins of the current base with the findings as the delta; `rebase_due` decides whether to rebase first.
- **Dispatching**: add "You call `gremlins.py` from your own Bash, with the `script` path `where` printed; every `wait` with `timeout: 600000`." Keep the `SendMessage` and model rules. Add "never `SendMessage` or `TaskStop` a gremlin".
- **Keepers**, new:
- "You do not read code; the base and its gremlins do. You read plans, notes, helper JSON, `git log --oneline`, `git diff --stat`/`--numstat` and gate summaries."
- "Nothing running at hand-back."
- Commit ownership (plan §4.4 / design §8.3): a sequential gremlin commits its block by explicit paths after its gate passes; parallel gremlins never commit, and the Athanor commits each block by the paths its note lists, one at a time; a fresh agent commits like a sequential gremlin; the Athanor commits the run log and the state dir (except `raw/` and locks) at phase close as `📝`. One committer at a time.
- **"Tools, not the loop"** (line 60, A13): the fail-first test and the look one layer up become things the Athanor **writes into briefs**, not things it does.
- **Deliverable**: add the priced usage totals and the base state (`list`: the active base, its warmth).
- Remove every remaining mention of Agent-tool forks.
### 7.3 `plugins/spire/skills/implementation/SKILL.md` (3.5)
- **Line 40** "Each block commits and adds a short handoff note to the run log" → "Each block commits, and its owner writes a short handoff note (template: `references/run-log.md`): a gremlin writes `notes/<block>.md` and the Athanor appends it to the run log; a fresh agent writes into the run log directly."
- **"Context: fresh, fork, or keep"** (lines 45–49) → heading "Context: fresh, trained, or keep"; keep the first paragraph ("Both extremes have failed …"). Replace "One option is trunk and forks …" with "trained implementer and gremlins": one base per build, trained early (it reads the code and writes the trunk notes), one gremlin per complex block spawned from it with a delta brief, the base rebased at phase boundaries or when the helper says so, fresh agents for mechanical or distant blocks. Mechanics in `spire:gremlins`. Windows-only in 0.3.0; elsewhere fresh agents throughout. No "a fork of the builder" (G-text 3).
- **"Spawning and models"** (line 65ff): add "Gremlins run on the base's full model id; `spawn` takes no model override."
- Line 12 (the inline rule) stays.
### 7.4 `references/run-log.md` (3.5)
- Header block gains `- Gremlins: <state dir> (or "none: <reason>")`. A successor finds the state dir there and adopts it (A7).
- Per-phase block table → `| Block | Owner (model id from modelUsage) | Session | Commit | in | out | cache write | cache read | cost Δ |`, filled from `usage --block <id> --format md` for gremlin blocks. For a fresh-agent block: owner = the stamped model id, Session `—`, the usage cells `—`, and the harness's token total in the handoff note **(choice C19)**.
- Phase close gains the `usage --phase <n> --format md` table and its three lines (cold starts, hit ratio, priced total), plus the base state from `list`.
- Handoff note: same format for everyone. Gremlins write it to `notes/<block>.md`; the Athanor appends it.
- "Trunk notes (written by the Athanor at intake)" → "Trunk notes (written by the base at training; by a fresh agent where gremlins are unavailable)".
### 7.5 `references/spawning-claude-code.md` (3.6)
Statuses only `verified`, `observed`, `assumed`, `unknown`, each with a date (build plan §2). Use the research doc `docs/workpad/research/2026-10-16-00-claude-headless-fork-sessions.md` for claim ids and the preflight report `docs/workpad/reports/2026-10-16-09-headless-forks-preflight.md` for Phase 0.
- **Rules of thumb**: add "Complex blocks go to gremlins via `spire:gremlins`; never resume or fork a headless session by hand."
- **Fork section (Agent tool)**: the rows of design §9's table, with its statuses and dates; the "unverified" row is replaced (that word is outside the vocabulary). Add a lead line: Spire no longer uses Agent-tool forks (goal decision 2026-10-16).
- **Models**: add "Headless `--model` takes full ids, so gremlins pin by full id" (observed 2026-10-16, P0.5).
- **New section "Headless sessions (`claude -p`)"**: every bullet of design §9's list as a row with its research id and date 2026-10-16. Plus, `observed 2026-10-16 (preflight report)`:
- P0.0: `uv run` adds `VIRTUAL_ENV` and a venv `Scripts` PATH head entry and no `UV_*`; stdout stays clean.
- P0.1: no job objects on this host; the Bash tool's stop kills by process tree; a supervisor created through CIM `Win32_Process.Create` survives the Bash call and the subagent's turn; an unhidden CIM spawn opens a visible Windows Terminal window (fix: `ShowWindow=0` plus `CREATE_NO_WINDOW` on every child); CIM children get the registry default user env, not the caller's.
- P0.1(d): the Bash tool backgrounds a call that passes its `timeout`, it does not kill it.
- P0.5: an unlisted model is substituted silently, with a `system/informational` warning.
- P0.8: an `!` injection runs in an Agent-tool subagent's skill preload; the skill's `allowed-tools` did not pre-approve later calls (untrusted dir; Phase 4 re-checks in a trusted project).
### 7.6 `references/models.md` (3.6)
Dated notes (2026-10-16), nothing removed:
- "Context and block size": below the "trunk read at intake of about 100k–250k" line (kept), add that with gremlins the trunk read is the base's training (P0.2 base 182k; smoke base 23.7k), not the Athanor's context (A13).
- "Future work": gremlins report per-call usage (`usage`); Agent-tool subagents still do not.
- Plan mode upgrades Haiku to Sonnet silently; aliases map to versions per CLI release (research F2a–F2c).
- Opus 5.5 may refuse a bare "run this blocking command" brief as pointless; say why it is wanted (observed 2026-10-16, Phase 0 trunk notes).
### 7.7 Routed items checklist (the text owner ticks each one in the handoff note)
| Item | Source | Lands in |
|---|---|---|
| `wait` with `timeout: 600000`; the Bash tool backgrounds past its timeout | C2, P0.1 | SKILL §5, athanor dispatching |
| `kill`: key on `ok` and `status` | Phase 1 M12 | SKILL §7 |
| Windows-tested; POSIX closed | M14, goal | SKILL §2, athanor intake |
| C8 permissive at init, enforced at runtime | M15 | SKILL §7 |
| Post-exit sweep kills daemons | M16 | SKILL §7 |
| `continue` needs commit rule and note path in its brief | M16 | SKILL §7 |
| `kill`/`expire` do not refresh the orchestrator stamp | Phase 1 M3 | SKILL §7 |
| Never re-run `init` to reset; re-run changes only given flags; margin < TTL (≤ 1800 at 1 h) | Phase 2 Minor 8, Minor 2 | SKILL §3, athanor intake |
| Always `--phase` on `rebase`; `rebase` counts toward the cap | flag 23, flag 1a | SKILL §7 |
| `--delta-note`; `## Changes since base` is this gremlin's delta only | I1 | SKILL §7 |
| No `#` lines inside the changes section, fenced code included | N1 | SKILL §7 |
| Attach every note since `trained_at` to a rebase | N2 | SKILL §7 |
| `train --supersedes` on a re-train | Minor 1 | SKILL §7 |
| Pings carry their base's latest phase; a ping at hand-back is not a leak | I2, Minor 7 | SKILL §5, §7 |
| Sequential gremlins one at a time | Minor 13 | SKILL §7 |
| `--allowed-tools` with `--tools` | Minor 12 | SKILL §3 |
| `messages` vs `turns` | M16 | SKILL §5 |
| `Bash(uv run:*)` allow rule | P0.8 | SKILL §3 |
| `detach_failed` only visible in `wait` | Phase 1 review | SKILL §5 |
| N3 binary re-resolution; N4 `parent_inactive` | this phase | SKILL §3, §7 |
---
## 8. Platform handoff report (block 3.7)
File: `docs/workpad/reports/<YYYY-MM-DD-hh>-gremlins-platform-handoff.md` (stamp from `date` when written). Audience: an Igor on native Linux and one on macOS, each starting from a checkout of `headless-forks` (goal "release gating"). Sections:
1. **Status**: 0.3.0 is Windows-tested; the gate (`where` ok:false, `init` refuses) is closed on every other platform; the branch is not merged until each platform is tested and its gate lifted. Branch, head, how to check it out.
2. **The seam**: the `Platform` interface (method list from §3.2), where it lives, what `PosixPlatform` does today per method, and the delegates.
3. **Running the gates there**: uv install, G-unit, G-where, the override env `SPIRE_GREMLINS_UNSUPPORTED_OK=1` (what it does, and that it is a porting aid only), and that the suite sets it itself.
4. **Known POSIX gaps** (with code references):
- **M14**: `claude` runs in its own session (`popen_kwargs(new_group=True)` → `start_new_session`), so `tree_kill` (`killpg(<supervisor>)`) misses it; only the per-pid sweep catches it, and the sweep needs `process_table()`.
- **The `/proc` tree walk**: `process_table`, `proc_ctime` and `epoch_to_ctime` read `/proc`. On macOS all three come back empty or `None`, so `kill` kills only the supervisor and leaves `claude` running, `lost` cannot kill a leftover tree, and pid-reuse checks are pid-only.
- **Dead-root claims (I2)**: a dead root claims children only when `epoch_to_ctime` works (`/proc/stat` `btime`); without it, it claims nothing (safe, but leftovers survive).
- Locks: `restore_no_replace` uses `os.link` (needs hard links on the state dir's filesystem).
- Detach by `setsid`: survival across the Bash call and the subagent's turn is untested on POSIX (the Windows answer was CIM).
- The supervisor interpreter under a uv venv (`sys._base_executable` on POSIX), untested.
- Path refusals of §3.4 on POSIX.
- The env scrub list was built from the Windows Desktop host's vars.
- The transcript slug rule (`transcript_path`) was observed on Windows only.
5. **WSL findings** (from 3.4): inventory, the interop answer (does `claude` resolve to the Windows binary, does `CLAUDE_CODE_EXECPATH` cross), `where` outputs, the suite table, failure causes.
6. **What to test**: the unit suite; the path matrix; a live smoke in a scratch clone (the recipe of 7); kill with a hanging gremlin and a grandchild, checked by pid + start time; the orphan watchdog; `lost`; supervisor survival across the end of the Igor's or Athanor's turn; no stray terminal windows (macOS: none expected).
7. **Smoke recipe**: Phase 1 and 2 smoke steps under the new names, as commands: `init` → `train B1 --phase 1` → `wait` → `spawn P1 --phase 1` (commits) → `wait` → `continue` → `spawn K1` + `kill` → `spawn P2a --phase 2` (expect `rebase_due: phase_boundary`) → `rebase --base B1 --as B2 --phase 2` → `spawn P2b` from B2 (expect `hit` against B2's context) → `init --warm-margin-s 1800` + wait for `expiring` and a ping (or skip) → `usage --phase 1 --format md` → `list` (nothing running). What each step must show; every `wait` with Bash `timeout: 600000`; expected spend about $0.5–1 on a small base.
8. **How to lift the gate**: make `PosixPlatform.supported` true for the tested `sys.platform` (`linux`, `darwin`) with a test; fix the gaps that the smoke shows (at minimum M14 for the platform: run `claude` in the supervisor's process group, or kill both groups; a `ps`-based `process_table` on macOS); update SKILL.md's Platforms line, the handoff report and the changelog; run the full suite and the smoke; record it in a run log. Do not merge until both platforms are done (goal).
9. **Not covered**: Docker (available on the dev host, not used); Rust follow-up (goal "Amended" note, not started).
---
## 9. Release (block 3.8)
### 9.1 Changes
- `plugins/spire/README.md` skills table: one new row `gremlins`: "Trained headless implementer forked per block: the base, its gremlins, cache and usage bookkeeping. Windows-only in 0.3.0. Loaded by the Athanor." Nothing else in the README (A22).
- `plugins/spire/.claude-plugin/plugin.json` and `plugins/spire/.codex-plugin/plugin.json`: `"version": "0.3.0"`. `.codex-plugin` gets only the bump (plan §5).
### 9.2 G-text (updated for the new names)
1. `git 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:gremlins` is in athanor.md's frontmatter `skills:`.
3. `git grep -n "a fork of the builder" -- plugins/spire/skills/implementation/SKILL.md` returns nothing.
4. `gremlins/SKILL.md` contains `600000`, `SPIRE_GREMLIN_CHILD` and `--paths`.
5. Both manifests read `0.3.0`.
6. The set of command names in SKILL.md's Commands table (rows starting ``| `<name>` |``) equals `--help`'s `commands` list (which excludes `_supervise`). Checked with a short Python one-off from the scratchpad, output pasted in the note.
7. G-unit and G-where pass.
8. **New**: `git grep -n "spire:forks\|forks\.py\|SPIRE_FORKS\|skills/forks" -- plugins/` is empty (rename residue).
9. **New**: the seam grep of §3.7.
### 9.3 Phase-end tests
Full G-unit **once** (about 150 s), to a log file in the scratchpad; then G-where. Red → one fresh `opus` fallout agent, then one re-run. Leftover-process check by command line afterwards.
### 9.4 Release commit
Message `🤖 Spire 0.3.0: trained implementer, gremlins per block (spire:gremlins)` **(choice C20)**: the build plan's title with the goal's names. Staged: README, both manifests (and nothing else). Body = the changelog, bullet style as `7c1ed39`, no attribution:
- New skill `spire:gremlins` (`gremlins.py`, uv, stdlib only): train one headless base per build, spawn a gremlin per complex block by forking the base's session; full launch fingerprint; scrubbed env; per-call usage, priced cost delta and ledger; warmth and keep-warm pings; rebase / re-train flags; orphan watchdog; Windows CIM detach with no visible window.
- Windows-only: `where` and `init` fail closed elsewhere; the Athanor falls back to fresh agents. Platform seam (`WindowsPlatform`, `PosixPlatform`) for the Linux and macOS ports; handoff report path.
- Athanor procedure: plan-level Athanor (no code reads), base writes the trunk notes, gremlins for complex blocks, fresh agents for mechanical ones, mechanical diff checks, usage in the run log; the Agent-tool fork path is dropped.
- implementation skill and references: trained implementer and gremlins; run-log table with usage columns; spawning reference rows for headless sessions and the Phase 0 observations; dated model notes.
- Preflight report path; run log path.
- Known follow-ups: Linux/macOS testing and gate lift (handoff report); Igor text (D4: `igor/SKILL.md:66`, `:112`); `## Build` keys (A23); README and `project.md` drift (A22); root `AGENTS.md`; Phase 4 install smoke and Phase 5 trial.
Then a `📝` run-log commit with the 3.8 note and the Phase 3 close. **No merge, no push.** Fix-pass commits after the release commit stay 0.3.0 (A21).
---
## 10. Test cadence and gates per block (summary)
| After | Run |
|---|---|
| 3.1 | py_compile ×2; the classes listed in §1; G-where; residue grep |
| 3.2 | py_compile; the classes listed in §1; G-where; seam grep |
| 3.3 | py_compile; the classes listed in §1 |
| 3.4 | WSL procedure (no Windows test run) |
| 3.5, 3.6, 3.7 | G-text items of their files; no test run (no code change) |
| 3.8 | full G-unit once; G-where; G-text in full |
Between blocks the Athanor also reads each child's diff stat on the test file and counts removed assertion lines (`git diff <range> -- …/tests/ | grep -c '^-.*assert'`); every removed line must be explained in the note as a rename or a specified change.
---
## 11. Not in Phase 3
Fixing POSIX beyond the seam (M14, a macOS process table, POSIX survival): handoff only. The Igor follow-ups (D4). `## Build` project keys (A23). README drift beyond the `gremlins` row and `.claude/spire/project.md` (A22). Live smokes (Phase 4, after install). Merge and push (post-build, user). The deferred Phase 2 Minors 3, 4, 5, 6, 14 (trial tuning).
---
## 12. Open choices made in this digest (for Igor's check)
| Id | Choice | Alternative |
|---|---|---|
| C1 | Eight blocks: rename, seam+gate, fixes, WSL, text, references, handoff, release; text after all code | the build plan's three blocks with the new work folded in |
| C2 | `templates/fork.md` → `templates/spawn.md` | `spawn-fork.md` (per method), or keep `fork.md` |
| C3 | `spawn_supervisor` → `detach_supervisor`; "detach" for starting the supervisor | keep "spawn" for both |
| C4 | `spawn --method fork` flag (only choice, default) | no flag until a second method exists |
| C5 | `method` on every job, result, ledger line (`null` except spawn); job kind `spawn` | kind `gremlin`, or `method` only on spawn jobs |
| C6 | Registry `SCHEMA` stays 1 | bump to 2 |
| C7 | `visible_windows()` moves into the platform (the user listed "the window checks") | keep it test-only |
| C8 | POSIX `norm_path` refuses drive forms and any backslash | silently join (today), or map via `wslpath` on WSL |
| C9 | Windows `norm_path` refuses `/mnt/<d>/…` | map it to `<D>:\…` |
| C10 | `taskkill_rc` → `tree_kill_rc`; reason `spawn_failed` → `detach_failed`; `where.spawn` → `where.platform.detach` | keep the old names |
| C11 | `init` also refuses on a closed platform | gate in `where` only (the goal's letter) |
| C12 | Porting override `SPIRE_GREMLINS_UNSUPPORTED_OK=1` (documented in the handoff, not SKILL.md) | none; porters edit the code to test |
| C13 | The suite sets the override for every test; `GateTests` remove it | run the gated suite as-is on WSL (most tests would fail at `init`) |
| C14 | N3: an `init` re-run re-resolves the binary unless it was pinned with `--binary`; a warning when it changes | `where --run` warns instead |
| C15 | N4: the late rebase's new base takes the old base's inactive status; result flag `parent_inactive` | create nothing; or text only |
| C16 | Kill verification drops snapshot pids whose table entry is gone or renamed (fixes access-denied pid reuse), applied even if the flake does not reproduce | change only the test |
| C17 | No live `claude` calls in Phase 3 (the stub covers argv, env and the real CIM detach; the live smoke is Phase 4) | one small live smoke at phase end (about $0.3) |
| C18 | Fallback where `where` is not ok: a fresh agent writes the trunk notes; the Athanor still reads no code | the Athanor reads code itself (the literal 0.2.2 shape) |
| C19 | Run-log block rows for fresh agents: usage cells `—`, harness token total in the note | a second table for fresh agents |
| C20 | Release title `🤖 Spire 0.3.0: trained implementer, gremlins per block (spire:gremlins)` | the build plan's title with the old name |
| — | An `!` injection whose command exits 2 (`where` on a closed platform) is untested: it may fail the skill load on Linux/macOS. Flagged for Phase 4 and the porting Igors; the text keeps the plain form (design §2). | `… where \|\| true` (may break the `Bash(uv:*)` allow match) |
| — | WSL uv install only through the permission prompt (download and execute); otherwise the WSL check stops at inventory and interop | install without asking (not allowed) |