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

67 KiB
Raw Blame History

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

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

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: → (resolved by ); 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):

---
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)