67 KiB
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):
- 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".
- The goal
docs/workpad/goals/headless-forks.mdas onmain(the branch copy is older). Its sections "Decided … naming", "… platforms", "Amended … release gating" and "Decided … platform seam" override the build plan where they conflict. - Build plan
docs/workpad/plans/2026-10-16-02-headless-forks-build.md§4, §6 Phase 3, G-text. - Plan
docs/workpad/plans/2026-10-16-01-headless-forks.md§4, §7 Phase 3; designdocs/workpad/designs/2026-10-16-01-headless-forks.md§2, §7, §8, §9. - 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. - 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,awkor heredocs to read or change files, even when auto mode suggests Bash.git mvis 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 noCo-Authored-Byor 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
claudecalls 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.mdunder "## 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.mdtitle# Headless implementer (spire:gremlins). Body: "You run headless underclaude -pin 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_rulestrings incmd_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 markergremlins-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 withforkthe same way. - Assertions on strings change with the code ("inside a fork" → "inside a gremlin",
"fork"kinds →"spawn",forkskey →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: thespawnlaunch return hasmethod == "fork"; its result and ledger line havemethod == "fork"; a train/continue result hasmethod is None. - The stub (
STUB_PY) keeps--fork-sessionhandling (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_aliveunchanged (they call the delegates).terminate(rec):if not record_alive(rec): return True, thenPLATFORM.terminate(int(rec["pid"])).process_tree(rec, alive_until): unchanged logic; it must call the module-levelprocess_table,proc_ctime,pid_alive,epoch_to_ctime, neverPLATFORM.directly (ProcessTreeTestspatch the module names).kill_tree(rec, extra, alive_until): unchanged logic; the one OS step becomesrc = PLATFORM.tree_kill(int(r["pid"]))._run(...):**PLATFORM.popen_kwargs()instead ofcreationflags=_NOWIN.detach_supervisor(state, run, job): buildsargv = [supervisor_interpreter(), "-I", script_path(), "_supervise", "--run", run, "--job", job]and returnsPLATFORM.detach(argv, cwd=state, log_path=raw_file(state, job, ".sup.log")). Tests patchgremlins.detach_supervisor.Supervisor.run: the claudePopentakes**PLATFORM.popen_kwargs(new_group=True)(keeps today's POSIXstart_new_session=Trueexactly; 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 callgremlins.PLATFORM.visible_windows();test_no_visible_windowskips when it returnsNoneor an empty list.- Tests that patch
find_cygpathpatchgremlins.WindowsPlatform.find_cygpath(astaticmethod). test_spawn_failedbecomestest_detach_failed(the reason is renamed, §3.6). It patchesdetach_supervisor; the fake return uses"method": gremlins.PLATFORM.detach_method, and it assertsreason == "detach_failed".CliTests/SubprocessTestswhereassertions:out["platform"]["detach"] == "cim-hidden"on Windows (replacesout["spawn"]).- Tests may still branch on the platform where they test platform behaviour; they use
gremlins.PLATFORM.name == "windows"instead of their ownos.namecheck, except the generated stub wrappers (.cmdvssh) andtearDownModule's PowerShell sweep, which may keepos.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.supportedisTrueonly forWindowsPlatform. Linux, WSL (it is Linux) and macOS arePosixPlatform, which issupported = Falsein 0.3.0. - Neutral helper
platform_gate() -> str | None:Nonewhen 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 intoreason/errorlike 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 onlywhere; refusinginitmakes "fail closed" mean the helper cannot be used, not only that it says so. Every other command needsinit, so nothing launches on a closed platform.- Override (porting aid) (choice C12): env
SPIRE_GREMLINS_UNSUPPORTED_OK=1opens the gate for that call.wherethen reportsplatform.override: true, staysok: trueif nothing else fails, and adds awarningsentry ("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.supportedreturnsTruefor the testedsys.platformvalues (the handoff report says how). - Tests (
GateTests):WindowsPlatform().supported is True;PosixPlatform().supported is Falsewithsys.platformpatched tolinuxand todarwin; withgremlins.PLATFORMpatched to aPosixPlatformwhoseis_wslreturnsTrueand with the override env removed:where --binary <stub>exits 2,ok: false,reasonnames "Windows only" and "WSL",platform.supportedfalse;initexits 2 and creates no state dir; with the override env:whereexits 0 with the warning,initproceeds. On the real Windows host:wherehasplatform.supported: true,override: false. Base.setUpsetsSPIRE_GREMLINS_UNSUPPORTED_OK=1in its patchedos.environfor every test (choice C13), so the same suite exercises the POSIX code on WSL and on the porting hosts;GateTestsremove 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 storedbinary_resolved_byis"--binary", keep the stored path (an explicit pin). Otherwise re-resolve withresolve_binary(None)($CLAUDE_CODE_EXECPATH, else PATH) (choice C14). If re-resolution finds nothing, keep the stored path. initoutput always carrieswarnings: []. 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_fieldsaslaunch.binary.*(it already would). Existing bases are untouched: they keep their ownlaunch.binary, so a still-present old binary gives no drift and a vanished one givesbinary_missingas 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.pathinchanged_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 (thenbuild_launchrefuses the missing file with exit 2, as today, which is loud). whereis 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 notactiveat finalise (atrain --supersedesended first; or anexpireraced the kill), create NEW with the old base's status (supersededwith the old base'ssuperseded_byandsuperseded_at= this end time; orretiredwithretired_at), notactive(choice C15). The old base is left untouched (itssuperseded_byis not overwritten). The job entry recordsrebase_inactive_parent: "<old> was <status> when the rebase ended". - The rebase result keeps
status: ok(the call worked) and gets the new flagparent_inactive. Compute it in_concludebefore the result is built (the base's status is known there). - So NEW gets no pings (status gate) and needs
--forceto spawn from, like any superseded base. - Test (
LaunchTests): train B1; startrebase --base B1 --as B1rwith a stubsleep;train --base B3 --supersedes B1that endsokfirst; when the rebase ends: its resultokwithparent_inactive; B1rsuperseded,superseded_by == "B3"; B1superseded_by == "B3"unchanged;ping_decisionfor B1r readsstatus:superseded;spawnfrom B1r without--forceexits 2.
5.3 The flaky LaunchTests.test_refusals (kill returned ok: false under full-suite load)
Investigation (bounded: about 15 minutes of wall time):
- 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. - Try to reproduce under load: run
LaunchTests.test_refusalsalone 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: eachtearDownModulekills everygremlins-test-process, including the other copy's. - 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
OpenProcessrefuses (ERROR_ACCESS_DENIED), sopid_alivereports 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_Sand gone a little later (exit stalled under load). Timing. - (c) unkillable member:
TerminateProcesskeeps failing for a real tree member. A product defect.
- (a) pid reuse: the pid now belongs to a process that
Fixes, decided now:
- Always (whether or not it reproduces) fix (a) in the neutral
kill_treeverification: each sweep iteration takes oneprocess_table(); a snapshot entry counts as a survivor only ifrecord_alive(p)and the table still has that pid with the samenameas 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: akill_treeunit test on a fake table (ProcessTreeTestsstyle) 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_Sfrom 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_killwith 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:
- 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'. - 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 whatclauderesolves to (a/mnt/c/…Windows shim or.exe, a Linux install, or nothing) and whether$CLAUDE_CODE_EXECPATHcrosses (it should not, withoutWSLENV). - 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. - 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). - 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 Windowsclaude, and does--versionof it run through interop?).- The same with
SPIRE_GREMLINS_UNSUPPORTED_OK=1. whereonce 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. ThenPathMatrixTestsandGateTestsalone. Record pass/fail/error/skip per class and the first line of each failure, grouped by cause (/proc,setsiddetach,killpg/M14, stub wrapper, git missing, chmod, locks, path refusals, timing).
- Leftovers:
ps -eo pid,args | grep -E "gremlins|_supervise|claude-stub" | grep -v grep; kill only our own by pid. Thenrm -rf ~/gremlins-wsl-check(our scratch copy). - Findings →
…\scratchpad\gremlins-wsl\findings.md(inventory, interop answer,whereoutputs, 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:
- 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(methodfork),continue,rebase, ping. - Platforms. Windows-tested. On any other platform (Linux, WSL, macOS)
wherereportsok: falsewith the reason andinitrefuses; then use fresh agents for every block (the 0.2.2 shape). Do not try to work around it. - Setup. The injected
wheregivesscript(use that absolute path in every call) andok. Ifokis false, see Platforms or the reason. Projects need an allow ruleBash(uv run:*)(orBash(uv:*)): the skill'sallowed-toolsdid not pre-approve calls in a subagent (P0.8, untrusted dir; Phase 4 re-checks).$SCRIPTS_UVonly on hosts withoutuvon PATH (A15).initonce per run, with a full model id. A successor never re-runsinitto "reset":listand 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-smust stay below the TTL (at 1 h, at most 1800 takes effect). Pass--allowed-toolstogether with--tools(Minor 12). - 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'scommands). One line each: key flags and the return shape (spawnreturns{job, base, method, base_state, warm_until, rebase_due, retrain_due, reasons};trainandrebasereturn{job, base, session_id}(+parentfor rebase);continuereturns{job}). Every command exceptwheretakes--run <run log .md>. Exit codes 0 / 2 / 3 / 4. stdout is one JSON object (usage --format md: one markdown fragment). - Waiting. Call
waitfrom Bash withtimeout: 600000; never omit it (C2). The Bash tool backgrounds a call that passes its timeout instead of killing it (P0.1). Loopwait --alluntil exit 0 or 4. Exit 3 means "call again", anddonemay already hold finished (also failed) results (C4). Pings come back inpings, never indone.running[]rows countmessages; results and the ledger countturns(M16). A failed detach (detach_failed) shows up in the nextwait, not in the launch return. - 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). Statusesok|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);nullotherwise (timeouttoo). Flags:stalled, needs_attention, usage_mismatch, ttl_downgrade, cold_start, clock_jump, ping_missed, orphaned, expired, parent_inactive. A cold base usually showspartial, because the shared system prefix stays warm (C5). - Rules (flags, not judgment).
rebase_due→rebase --as <new> --phase <n>before the next spawn; always pass--phase;rebasecounts towardparallel_cap.retrain_due→traina 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 basesection 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'strained_at(shown bylist), because a note older than that counts as known to the base whether or not it was given to it (N2). continuesends 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:
listshows no running job other than pings (kindping; a ping ends within 300 s and is not a leak, Minor 7). Otherwisekillit and flag it. kill: key onokandstatus, not onkilledor the exit code (M12).killed: true, status: okmeans the job finished first;ok: falselists survivors (exit still 0): flag it.- The orphan watchdog (N1): a gremlin is killed (
orphaned) afterorphan_after(default 1200 s) without an orchestrator call (wait,spawn,continue,train,rebase,list,usage).killandexpiredo not count (Phase 1 M3), and a gremlin's own calls never do. So keep loopingwaitwhile gremlins run. - Inside a gremlin,
SPIRE_GREMLIN_CHILD=1is set andtrain,spawn,continue,rebase,kill,expire,init,tickrefuse (N3);where,list,usage,waitwork. - Models: full ids only;
spawntakes the base's model (no--model). Admission is "permissive atinit, enforced at runtime" (M15): a substituted model endsfailed/model_refused. - After
claudeexits, 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 -pyourself; neverSendMessageorTaskStopa gremlin (continueandkillcover it).
- State.
<run log without .md>-gremlins/next to the run log: what is committed at phase close (everything butraw/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:addspire: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, runlistand adopt the active base. Never re-init, never re-train an active base (A7). - Else run
where. Ifok:init(full model id = the plan's owner model for complex blocks; effort; the project's agent file if any), thentrainthe 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. AddGremlins: <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).
- If the run log header has a
- Step 2 (build the blocks): complex block in the trained area →
spawnfrom 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); thenwaituntil done; readnotes/<block>.mdand 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). Obeyrebase_due/retrain_dueas SKILL.md says. - Step 3 (gate): the plan's command, output to a
.txtunder the state dir'slogs/. 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 linesgit diff <range> -- <test paths> | grep -c '^-.*assert'(patternassertby 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 throughtail; the first run after a fresh worktree is a warm-up, rerun once). Fallout → a gremlin from the current base (orcontinueof 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 fromnotes/; phase close withusage --phase <n> --format md(per-kind table, cold starts, hit ratio, priced total) and every flag. - Step 6 (hand back):
listshows nothing running except pings. Do notexpirethe 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_duedecides whether to rebase first. - Dispatching: add "You call
gremlins.pyfrom your own Bash, with thescriptpathwhereprinted; everywaitwithtimeout: 600000." Keep theSendMessageand model rules. Add "neverSendMessageorTaskStopa 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/--numstatand 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.
- "You do not read code; the base and its gremlins do. You read plans, notes, helper JSON,
- "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 writesnotes/<block>.mdand 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;
spawntakes 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 fromusage --block <id> --format mdfor 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 mdtable and its three lines (cold starts, hit ratio, priced total), plus the base state fromlist. - 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
--modeltakes 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 runaddsVIRTUAL_ENVand a venvScriptsPATH head entry and noUV_*; 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.Createsurvives the Bash call and the subagent's turn; an unhidden CIM spawn opens a visible Windows Terminal window (fix:ShowWindow=0plusCREATE_NO_WINDOWon 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/informationalwarning. - P0.8: an
!injection runs in an Agent-tool subagent's skill preload; the skill'sallowed-toolsdid not pre-approve later calls (untrusted dir; Phase 4 re-checks in a trusted project).
- P0.0:
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:
- Status: 0.3.0 is Windows-tested; the gate (
whereok:false,initrefuses) 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. - The seam: the
Platforminterface (method list from §3.2), where it lives, whatPosixPlatformdoes today per method, and the delegates. - 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. - Known POSIX gaps (with code references):
- M14:
clauderuns in its own session (popen_kwargs(new_group=True)→start_new_session), sotree_kill(killpg(<supervisor>)) misses it; only the per-pid sweep catches it, and the sweep needsprocess_table(). - The
/proctree walk:process_table,proc_ctimeandepoch_to_ctimeread/proc. On macOS all three come back empty orNone, sokillkills only the supervisor and leavesclauderunning,lostcannot kill a leftover tree, and pid-reuse checks are pid-only. - Dead-root claims (I2): a dead root claims children only when
epoch_to_ctimeworks (/proc/statbtime); without it, it claims nothing (safe, but leftovers survive). - Locks:
restore_no_replaceusesos.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_executableon 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.
- M14:
- WSL findings (from 3.4): inventory, the interop answer (does
clauderesolve to the Windows binary, doesCLAUDE_CODE_EXECPATHcross),whereoutputs, the suite table, failure causes. - 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). - 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(expectrebase_due: phase_boundary) →rebase --base B1 --as B2 --phase 2→spawn P2bfrom B2 (expecthitagainst B2's context) →init --warm-margin-s 1800+ wait forexpiringand a ping (or skip) →usage --phase 1 --format md→list(nothing running). What each step must show; everywaitwith Bashtimeout: 600000; expected spend about $0.5–1 on a small base. - How to lift the gate: make
PosixPlatform.supportedtrue for the testedsys.platform(linux,darwin) with a test; fix the gaps that the smoke shows (at minimum M14 for the platform: runclaudein the supervisor's process group, or kill both groups; aps-basedprocess_tableon 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). - 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.mdskills table: one new rowgremlins: "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.jsonandplugins/spire/.codex-plugin/plugin.json:"version": "0.3.0"..codex-plugingets only the bump (plan §5).
9.2 G-text (updated for the new names)
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.mdreturns nothing.spire:gremlinsis in athanor.md's frontmatterskills:.git grep -n "a fork of the builder" -- plugins/spire/skills/implementation/SKILL.mdreturns nothing.gremlins/SKILL.mdcontains600000,SPIRE_GREMLIN_CHILDand--paths.- Both manifests read
0.3.0. - The set of command names in SKILL.md's Commands table (rows starting
| `<name>` |) equals--help'scommandslist (which excludes_supervise). Checked with a short Python one-off from the scratchpad, output pasted in the note. - G-unit and G-where pass.
- New:
git grep -n "spire:forks\|forks\.py\|SPIRE_FORKS\|skills/forks" -- plugins/is empty (rename residue). - 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:
whereandinitfail 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);## Buildkeys (A23); README andproject.mddrift (A22); rootAGENTS.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) |