Files
reliquary/docs/workpad/reports/2026-10-16-22-gremlins-platform-handoff.md

42 KiB
Raw Permalink Blame History

Platform handoff: spire:gremlins on Linux and macOS

Written by: block 3.7 owner, claude-opus-5-5[1m], 2026-10-16 22:15 +0200 (date), from the headless-forks branch at 485cc66. For: an Igor on native Linux and an Igor on macOS. Each starts from a checkout of headless-forks, tests the helper on their platform, fixes it and lifts the fail-closed gate there. The branch is merged and released only after both have done this. Sources: the Phase 3 contract digest (docs/workpad/plans/2026-10-16-18-gremlins-phase3-contract.md, §8 and its final amendment), the run log (docs/workpad/reports/2026-10-16-08-headless-forks-run.md: blocks 1.3, 2.4, 3.2, 3.3, 3.4, 3.4a), the Phase 1 review and re-check (…-16-headless-forks-phase1-review.md M14, …-17-headless-forks-phase1-recheck.md I2), and the code. Line numbers refer to plugins/spire/skills/gremlins/gremlins.py (the helper) and …/tests/test_gremlins.py (the tests) at 485cc66.


1. Status

  • Tested on Windows only. On every other platform the gate is closed: where reports ok: false with the reason and exits 2, init refuses, and on an existing state dir train, spawn, continue, rebase and tick refuse too (exit 2, error: "platform not supported"). wait, list, usage, kill and expire stay open, so you can always stop and inspect what you launched under the override.

  • Release gating (user decision, 2026-10-16). The release is not cut from Windows alone. The work stays on headless-forks; it is pushed but not merged. A Linux Igor and a macOS Igor each test, fix and lift the gate for their platform; only then is the branch merged and released. WSL is fine for development, but it is not native Linux. macOS stays closed until someone has tested on a Mac.

  • The goal file. These decisions live in docs/workpad/goals/headless-forks.md as edited on main (sections "Decided … platforms", "Amended … release gating", "Decided … platform seam"). At the time of writing those edits were not yet committed on main, and the branch copy of the goal is older. This report therefore restates what you need; do not rely on the goal file in your checkout.

  • No live claude call has been made under the new names. Phases 1 and 2 smoked the helper live on Windows under its old name (spire:forks, command fork). Phase 3 renamed it, moved the platform code behind a seam and added the gate, all tested with a stub binary only (digest C17). Phase 4 must smoke the renamed helper on Windows before anything ships. Your smoke (§7) is the first live run on your platform.

  • Branch. headless-forks, remote origin = git.g4b.org:dirigence/reliquary. At the time of writing it had no upstream (not pushed yet). Once the user has pushed it:

    git fetch origin
    git switch headless-forks          # or: git worktree add ../reliquary-hf headless-forks
    git log --oneline -1               # expect the Phase 3 release commit or later
    

    Block 3.8 (README row, manifests, full suite, release commit) comes after this report. Read the run log's Phase 3 close first, for anything that changed after this report was written.

  • Your run log. Record your work in a run log of your own (docs/workpad/reports/<YYYY-MM-DD-hh>-gremlins-<linux|macos>-port-run.md, placement per the workpad skill), with handoff notes in the format of plugins/spire/skills/implementation/references/run-log.md.

2. The seam

All platform-specific code sits in one section of the helper, between # --- platform seam: begin --- (:267) and # --- platform seam: end --- (:734). Outside it, nothing tests os.name or sys.platform, and nothing calls ctypes, killpg or /proc. Block 3.2's seam grep proved this (run log, block 3.2, gate 4):

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

Keep that true when you fix things: every hit must stay between the markers.

Selection. select_platform() (:728) is the only os.name test: WindowsPlatform() on nt, else PosixPlatform(). PLATFORM = select_platform() (:732). WSL, Linux and macOS all get PosixPlatform. Both classes can be built on any host (Windows ctypes is loaded lazily), so the path matrix and the gate tests run everywhere.

The interface (Platform, :277) and what PosixPlatform (:604) does today:

Member PosixPlatform today Line
name, detach_method "posix", "setsid" :607–608
supported property: sys.platform in TESTED_SYS_PLATFORMS; the tuple is empty, so always False :610, :614
describe() shared: {name, sys_platform, wsl, supported, detach} (supported read from the property) :285
is_wsl() WSL_DISTRO_NAME set, or microsoft in /proc/sys/kernel/osrelease; unreadable → False :617
norm_path(p, cwd) posixpath.normpath(join(cwd, p)); refuses empty, a drive form (^[A-Za-z]:) and any backslash :627
popen_kwargs(new_group) {"start_new_session": new_group} :641
detach(argv, cwd, log_path) Popen(argv, cwd, stdin=DEVNULL, stdout/stderr=log, start_new_session=True, close_fds=True); returns {ok, method, pid, ctime} :644
proc_ctime(pid) /proc/<pid>/stat field 22 (clock ticks since boot); None without /proc :656
epoch_to_ctime(t) (t - btime) * SC_CLK_TCK from /proc/stat; None without /proc :665
_running(pid) os.kill(pid, 0); ProcessLookupError → dead; PermissionError → alive :676
pid_alive(pid, ctime) shared: _running, then the creation-time check when both sides are known :313
process_table() /proc walk → [(pid, ppid, comm)]; [] without /proc :685
terminate(pid) os.kill(pid, SIGKILL) :705
tree_kill(pid) os.killpg(pid, SIGKILL), OSError suppressed; returns None :712
restore_no_replace(src, dst) os.link(src, dst) then os.remove(src); OSError → False :719
visible_windows() None (not applicable; the window test skips) :338

Delegates. Neutral code calls these module-level names, and the tests patch them: proc_ctime (:1070), epoch_to_ctime (:1075), pid_alive (:1085), record_alive (:1090), process_table (:1094), norm_path(p, cwd, windows=None) (:774; windows=True/False selects a class, for the tests). terminate(rec) (:1154) checks record_alive and then calls PLATFORM.terminate. kill_tree (:1161) makes one PLATFORM.tree_kill call, on the first live root (:1175–1179). detach_supervisor (:1745) builds the argv [supervisor_interpreter(), "-I", script, "_supervise", "--run", R, "--job", J] and calls PLATFORM.detach. Supervisor.run starts claude with PLATFORM.popen_kwargs(new_group=True) (:3588–3590). RegistryLock._restore (:941) calls PLATFORM.restore_no_replace. _run (:1208) uses PLATFORM.popen_kwargs().

Neutral code that matters to you: process_tree (:1102), kill_tree (:1161) and snapshot_survivor (:1192), reap_lost (:2047), kill_job (:2076), Supervisor.run (:3571), supervisor_interpreter (:1398), scrubbed_env (:1265), transcript_path (:2782).

The gate (neutral, below the seam): gate_overridden() (:742), platform_report() (:748), platform_gate() (:753, the reason text), require_platform() (:762). cmd_init calls require_platform() as its first line (:2560). The dispatcher refuses GATED_COMMANDS (:124) right after the N3 check (:4125–4126). tick_pass returns at once on a closed gate (:3397). cmd_where (:2320) puts the gate reason first in reasons and still fills the rest of the report.

3. Running the gates there

Prerequisites.

  • uv, with the official installer (curl -LsSf https://astral.sh/uv/install.sh | sh, which installs to ~/.local/bin) or a package manager (brew install uv on macOS). The helper and the tests are PEP 723 scripts with no dependencies (requires-python >= 3.10); uv run picks or downloads a managed CPython.
  • git.
  • A native claude for your platform, on PATH or given with --binary. Do not use a Windows claude.exe through WSL interop (§4.12).

Gates (from the checkout root):

Gate Command Expect
G-unit uv run plugins/spire/skills/gremlins/tests/test_gremlins.py > /tmp/g-unit.txt 2>&1; echo exit=$? 165 tests at the WSL check. About 150 s on Windows; WSL took 393 s. unittest writes to stderr, so capture both streams.
one class uv run plugins/spire/skills/gremlins/tests/test_gremlins.py GateTests PathMatrixTests names as for unittest (Class or Class.test_name)
G-where uv run plugins/spire/skills/gremlins/gremlins.py where on a closed gate: exit 2, ok: false, reason starts "spire:gremlins is not tested on linux yet; …" (or darwin, or linux, WSL)
compile uv run python -m py_compile plugins/spire/skills/gremlins/gremlins.py exit 0

Run one copy of the suite at a time. On Windows the module-end check (tearDownModule, tests :4217) kills every process whose command line contains gremlins-test-, which would include another copy's. On POSIX that check does nothing yet (§4.11).

The override, SPIRE_GREMLIN_UNSUPPORTED_OK=1. It is a porting aid, documented here and nowhere in SKILL.md. When it is set to exactly 1 on an unsupported platform, the gate is open for that call. where then reports platform.override: true, stays ok: true if nothing else fails, and adds one warning ("platform gate overridden (…=1); this platform is untested, use it only to port and test spire:gremlins"). On a supported platform the variable changes nothing (gate_overridden, :742). Use it to run the smoke before the gate is lifted in code. Remove it from your shell before you check the lifted gate: with the gate lifted, where must say supported: true, override: false without it.

The suite sets the override itself. Base.setUp (tests :80–90) sets SPIRE_GREMLIN_UNSUPPORTED_OK=1 for every test, so the whole suite exercises the POSIX code on a closed platform; GateTests.setUp (tests :1408–1410) removes it again. You do not need it in your shell to run the tests.

What a green suite does and does not prove (C13). A green run on Linux says nothing about the gate, because every test except GateTests runs with the gate overridden. Only GateTests (tests :1405) cover the gate. And GateTests mostly use a fake closed platform (closed_platform(), tests :1387), so they still pass after you lift the gate. The one that pins the real POSIX value is test_supported_flags (tests :1430), which today asserts PosixPlatform().supported is False for linux and darwin. Lifting the gate means changing that test on purpose (§8).

4. Known POSIX gaps, with code references

Each item says what the code does, what goes wrong, and on which platform. "Seen" means observed in the WSL run (block 3.4); "from the code" means read and reasoned, not observed.

4.1 M14: kill misses claude's group (Linux and macOS)

  • Supervisor.run starts claude in its own session (popen_kwargs(new_group=True) → start_new_session=True, :3590, :641). The supervisor is itself a session leader (detach, :649).
  • kill_job (:2076) calls kill_tree(sup, extra=[claude]). kill_tree calls tree_kill on the first live root only (:1175–1179), which is the supervisor: killpg(<supervisor pid>) kills the supervisor's group and does not reach claude's.
  • What saves Linux is the sweep (:1180–1188): every snapshot pid still alive is sent SIGKILL, and the snapshot comes from process_tree, which walks /proc (:1115). Processes claude starts between the snapshot and the kill are not in it.
  • On macOS (§4.2) the snapshot holds only the two roots. The Phase 1 review wrote that kill "leaves claude running". By the current code, kill_job passes the claude record as an extra root, so claude itself is killed by the sweep, by pid only. Its descendants (Bash tool commands, git, gpg-agent) are not found and survive. Check this on the Mac.
  • The supervisor's own kills (timeout :3629, orphan watchdog :3647, model refusal :3626) call kill_tree(<claude>), and there killpg(<claude pid>) does hit claude's group. M14 is about kill, expire and anything else that starts from the supervisor.
  • Fix options (yours to choose, with a test): call tree_kill for every live root, not just the first. That changes neutral code, so it also runs on Windows, where an extra taskkill of an already-dead tree is harmless. Or have PosixPlatform.tree_kill also kill the process groups found in the snapshot. Or start claude in the supervisor's group, but then the supervisor's own killpg(<claude>) no longer works.

4.2 The /proc tree walk (macOS)

  • process_table (:685), proc_ctime (:656) and epoch_to_ctime (:665) read /proc. macOS has none, so they give [], None and None.
  • Consequences on macOS:
    • kill and expire find only the roots (§4.1).
    • The post-exit sweep (:3662, "kill leftovers of claude") finds nothing. claude is dead by then, a dead root without a creation time claims nothing (§4.3), and backgrounded commands and daemons survive.
    • lost (reap_lost, :2047) kills a live claude by its group but cannot find a dead claude's leftovers.
    • Every stored pid has ctime: None (pid_record, :1080), so pid_alive is pid-only. A reused pid is taken for the old process. kill_tree would then killpg and SIGKILL whatever now owns that pid. This happens in reap_lost after a reboot, or in a late kill of a long-dead job. It is the most dangerous macOS gap, because it can hit an unrelated process.
    • Registry-lock staleness (RegistryLock._is_stale, :931) is pid-only too: a lock held by a dead process whose pid was reused waits for LOCK_ABANDONED_S (300 s) before it is broken.
  • Fix direction: a macOS process_table, proc_ctime and epoch_to_ctime inside PosixPlatform, chosen by sys.platform == "darwin". They must give (pid, ppid, name) and a creation time in one unit that epoch_to_ctime can convert to. Two stdlib-only ways: ctypes on libproc (proc_listallpids, proc_pidinfo(PROC_PIDTBSDINFO) gives ppid, comm and start time in seconds plus microseconds, and the process status), or ps -axo pid=,ppid=,lstart=,comm= (whole-second start times, slower). The ProcessTreeTests fake a table (tests :616) and run anywhere; add tests for the real macOS reader.

4.3 Dead-root claims (I2)

  • process_tree (:1102) takes a dead root's direct children only when root_ctime < child_ctime <= epoch_to_ctime(alive_until + 1 s) (:1125–1145). Without a recorded root ctime or a working epoch_to_ctime, a dead root claims nothing (:1127–1128).
  • On Linux this works through /proc/stat btime. On macOS it claims nothing (safe, but leftovers survive), until §4.2 is fixed.
  • Accepted residual on every platform: a child started in the root's last heartbeat interval (up to HEARTBEAT_S = 10 s) is not claimed (re-check report, I2).

4.4 Zombie liveness in pid_alive (Linux; seen on WSL)

  • PosixPlatform._running (:676) is os.kill(pid, 0). That succeeds on a zombie (state Z), so a killed child that its parent has not yet reaped counts as alive.
  • Seen in the WSL suite: LaunchTests.test_kill_tree, test_refusals, test_expire_kills_running_jobs, test_lost, test_orphan_watchdog and test_rebase_counts_toward_cap failed. The tests call the CLI in-process (call, tests :59), so the test process is the supervisor's parent; the killed supervisor stays a zombie, and kill reports ok: false with the supervisor as the only survivor. zombie_probe.py (WSL scratch) confirmed it: kill-0 says alive, /proc state Z, gone after reaping.
  • In production a detached supervisor's launcher (spawn, train, …) exits at once, so the supervisor is reparented and reaped. But from the code:
    • A wait whose tick launches a ping is that ping supervisor's parent for up to 570 s and never reaps it.
    • Inside Supervisor.run, after kill_tree(<claude>) (timeout, orphan, refusal) claude is a zombie until p.wait() (:3660). The verify loop therefore spins for the full KILL_VERIFY_S (10 s, :151) before the supervisor goes on. The result is unaffected; it costs 10 s per such kill.
    • A subreaper that reaps late would make every kill report survivors.
  • Fix (porter's item, not fixed on Windows): treat state Z (and X) as dead in PosixPlatform._running, from /proc/<pid>/stat field 3 on Linux, and from libproc's pbi_status == SZOMB (or ps -o stat=) on macOS. Add a test that kills a child without reaping it and expects pid_alive to say dead.

4.5 test_timeout_and_stalled expects a tree of at least 3 (Linux; seen on WSL)

  • Tests :3186–3194: assertGreaterEqual(len(process_tree(<supervisor>)), 3). On Windows the stub runs as supervisor → cmd.exe (the .cmd wrapper) → python. On Linux the stub's sh wrapper execs python, so the tree is supervisor → python: 2.
  • This expectation is shaped by the platform, not a product bug. Make it 2 on POSIX with a comment (WIN_PLATFORM, tests :41), or count the real members (supervisor, claude record, stub). Do not loosen it to "≥ 1".
  • PosixPlatform.restore_no_replace (:719) uses os.link + os.remove (a put-back that never overwrites). It is used only when a lock that was being broken turns out to be fresh (RegistryLock._break, :945–964).
  • On a filesystem without hard links (some network mounts, exFAT, some container volumes) the put-back fails: the lock stays aside as registry.<hex>.broken.lock, with a diagnostic on stderr. Keep the state dir (it sits next to the run log) on a local filesystem, and say so if you find a case.

4.7 Detach by setsid: survival untested

  • detach (:644) gives the supervisor a new session. Whether it survives (a) the Bash call that launched it returning, (b) that Bash call being stopped (the Bash tool backgrounds a call at its timeout, and a TaskStop kills the call's tree), and (c) the end of the Igor's or the Athanor's turn has never been tested on POSIX.
  • On Windows this took a ladder of probes (P0.1) and ended with CIM Win32_Process.Create. On POSIX the risk is a harness that kills by cgroup or session rather than by process group. Test it (§6, §7 step 15).

4.8 The supervisor interpreter

  • supervisor_interpreter() (:1398) returns sys._base_executable when it differs from sys.executable (a venv launcher); the supervisor runs it with -I. On WSL it resolved to the uv-managed base interpreter, and the in-process launches in the suite worked.
  • Untested: a real detached supervisor under uv run on native Linux and on macOS (a framework Python, brew Python, a uv-managed one). where shows python.supervisor_interpreter; check that it is a real interpreter file that still exists after uv run exits.

4.9 Path refusals on POSIX

  • PosixPlatform.norm_path (:627) refuses a drive form (C:…) and any backslash. This is the only POSIX behaviour change of Phase 3 (digest §3.4). The full matrix (PathMatrixTests, tests :225) passed on WSL.
  • A real POSIX path with a backslash in a file name is refused. That is deliberate, but a porter may meet it.
  • macOS paths are case-insensitive on default APFS; the helper compares paths as given (no normcase on POSIX). That is harmless unless the same dir is passed with different case.

4.10 The env scrub list came from the Windows Desktop host

  • SCRUB_DROP (:90), SCRUB_DROP_PATTERNS (:104), SCRUB_KEEP (:105) and SCRUB_PINNED (:111) were built from the Claude Desktop app's env on Windows (design §4, research E1, P0.0). scrubbed_env (:1265) keeps everything not dropped.
  • On Linux and macOS the host (Desktop app, terminal, IDE) may set other CLAUDE_* or ANTHROPIC_* variables. where lists the ones on no list under scrub.unknown (scrub_unknown, :1284): read it on your host and decide for each one. USERPROFILE and CLAUDE_CODE_GIT_BASH_PATH in the keep list are Windows-only and harmless elsewhere.
  • The override passes the scrub. SPIRE_GREMLIN_UNSUPPORTED_OK is on no list, so it is copied into every job's env file (raw/<job>.env.json). Supervisor.run replaces its own os.environ with that env (:3579–3580), so a supervisor's keep-warm tick (safe_tick, :3658) sees the gate open and pings as the orchestrator would on a porting host. The gremlin's claude also carries it, which is harmless (N3 refuses launches inside a gremlin). On Windows this changes nothing. After the gate is lifted it is moot; until then, expect supervisor pings during long smokes. If you want the supervisor to stay closed, add the variable to SCRUB_DROP.

4.11 The tests' leftover check is Windows-only

  • tearDownModule (tests :4217) returns at once on POSIX (if not IS_WINDOWS …). A supervisor or stub that survives the suite therefore goes unnoticed. Add a POSIX branch: ps -eo pid=,args= (Linux) or ps -axo pid=,command= (macOS), filter on gremlins-test-, kill by pid, and fail the suite. Until then, check by hand after each run: pgrep -af "gremlins-test-|_supervise|claude-stub".

4.12 A Windows claude.exe is accepted under WSL (WSL only; seen)

  • shutil.which("claude") did not pick the Windows binary on the WSL host (it is named claude.exe). But where --binary /mnt/c/Users/…/claude.exe with the override reports ok: true: claude.exe --version runs through interop. The helper would then launch a Windows claude under a Linux supervisor, with Linux paths and a Linux env.
  • If WSL is ever opened: refuse a /mnt/<d>/… binary (or any *.exe) in resolve_binary / binary_info (:1309, :1347) when PLATFORM.is_wsl().

4.13 The transcript slug rule (transcript_path)

  • transcript_path (:2782) assumes ~/.claude/projects/<cwd with every non-alphanumeric replaced by '-'>/<session>.jsonl. This was observed on Windows only (P0.7). It feeds only max_base_tokens (a retrain_due reason); when the file is missing, the base's stored context is used instead.
  • Check after your smoke: ls ~/.claude/projects/ should show the clone's path with / and . replaced by - (for example -tmp-gremlins-smoke-AbCd-clone), and B1's session file in it.

4.14 ! injection with a failing where

  • SKILL.md injects !`uv run "${CLAUDE_SKILL_DIR}/gremlins.py" where`. On a closed platform that command exits 2. Whether Claude Code still loads the skill and shows the report, or fails the skill load, is untested (digest §12). The text tells the Athanor to treat "no report" as ok: false. Load the skill once on your platform with the gate closed and record what happens.

4.15 Cosmetic

  • where joins its reasons with "; " (:2372). The gate reason ends with a period, so the joined text reads …Platforms).; no claude binary ….

5. WSL findings (block 3.4)

Run on 2026-10-16, 21:45–21:56, on helper commit 4d8af02 (after 3.1–3.3), from a copy on ext4 (~/gremlins-wsl-check). Raw logs were in the session scratchpad (…\scratchpad\gremlins-wsl\: where-*.json, suite.txt, matrix-gate.txt, zombie_probe.py) and are not in git; the run log's Block 3.4 note is the record.

Inventory.

Item Value
Distro registered Ubuntu-20.04, actually Ubuntu 22.04.5 LTS (upgraded in place)
Kernel 6.18.54.1-microsoft-standard-WSL2; /proc present; osrelease contains microsoft
WSL_DISTRO_NAME Ubuntu-20.04
uv 0.6.8, already at ~/.local/bin/uv (nothing installed)
Python system python3 3.10.12; uv run used a managed CPython 3.13.2
git 2.34.1

PATH interop.

  • 73 PATH entries under /mnt/ (the Windows PATH appended; no /etc/wsl.conf).
  • claude (no extension): not found. claude.exe resolves to /mnt/c/Users/g4b/.local/bin/claude.exe (the Windows binary, 2.1.283). …/AppData/Roaming/npm is on PATH but holds no claude shim.
  • So where without --binary reports "no claude binary" on this host. With --binary /mnt/c/…/claude.exe and the override, where is ok: true: a Windows binary under a Linux helper (§4.12).
  • CLAUDE_CODE_EXECPATH does not cross (unset in WSL; WSLENV empty).

where outputs.

Case Exit Shape
gate closed, no binary 2 ok: false; platform {name: posix, sys_platform: linux, wsl: true, supported: false, detach: setsid, override: false}; reason = the gate reason, then "no claude binary …"
override, no binary 2 as above without the gate reason; override: true; the override warning
override, --binary the Windows claude.exe 0 ok: true, the override warning
from the drvfs path (/mnt/g/…/gremlins.py) same script is the /mnt/g/… path

python.supervisor_interpreter resolved to the uv-managed base interpreter.

Unit suite (override set by Base.setUp): Ran 165 tests in 393.103s, FAILED (failures=9, skipped=3).

Class Result on WSL
PathTests, PathMatrixTests pass (PathTests.test_state_dir_windows_forms skipped)
GateTests 1 failure: test_closed_gate_on_existing_registry (cause 2)
LaunchTests 7 failures: test_kill_tree, test_refusals, test_expire_kills_running_jobs, test_lost, test_orphan_watchdog, test_rebase_counts_toward_cap (cause 1); test_timeout_and_stalled (cause 4); plus test_delta_tokens_counts_only_what_the_base_does_not_know (cause 3); test_no_visible_window skipped
SubprocessTests test_where_via_uv_host_binary skipped (no host claude)
every other class pass

PathMatrixTests GateTests PathTests alone: 23 tests, 1 failure (cause 2), 1 skip.

Failure causes.

  1. A zombie supervisor counts as alive (6 tests). §4.4. A POSIX item for the porter, not fixed.
  2. GateTests.test_closed_gate_on_existing_registry: its "gate open" setup ran without the override on a non-Windows host. A portable test bug: fixed in block 3.4a (2257e89; the setup init runs under the override env).
  3. test_delta_tokens_counts_only_what_the_base_does_not_know: trained_at has whole seconds, and a fast host put the note and the rebase end in the same second. A timing assumption: fixed in block 3.4a (mtimes and trained_at set explicitly). Both 3.4a fixes passed on WSL afterwards (Ran 9 tests … OK).
  4. test_timeout_and_stalled expects a tree of at least 3 (§4.5). A porter's item.

So on a Linux host like this one, expect 7 failures from the current branch (causes 1 and 4) until you fix them.

Traps found.

  • From Git Bash, wsl -d X -- bash -lc '…' expands $? in an outer shell, so every exit code read 0. wsl -e bash -lc '…' gives the real codes. Prefix every wsl call from Git Bash with MSYS_NO_PATHCONV=1.
  • Never wsl --shutdown or --terminate on a shared host.
  • Leftovers after the run: none (ps by args; no /tmp/gremlins-test-*).

6. What to test

On each platform, in this order. Record each item in your run log as passed, failed (with the fix), or not run (with the reason).

  1. G-where with the gate closed: exit 2, the right sys_platform, wsl: false on native Linux and macOS, the binary block filled with your native claude, and scrub.unknown read (§4.10).
  2. PathMatrixTests and GateTests alone, then the full suite to a log file. Linux: expect the 7 known failures (§5) until fixed. macOS: expect more (everything that needs a process table or creation times). Fix, then rerun.
  3. The skill load with the gate closed (§4.14).
  4. The live smoke in a scratch clone (§7), with the override set.
  5. kill with a hanging gremlin and a grandchild (§7 step 6). Record every pid with its start time before the kill (ps -o pid=,lstart=,args= -p <pid>) and check each pid and start time afterwards. Never check by process name.
  6. The orphan watchdog (§7 step 13): a gremlin with no orchestrator call for longer than orphan_after ends killed/orphaned and its tree is gone.
  7. lost (§7 step 14): kill the supervisor by hand; after LOST_AFTER_S (120 s) list reports lost_now and kills the leftover claude tree.
  8. Supervisor survival (§7 step 15) across (a) the launching Bash call returning, (b) that call being stopped (TaskStop), and (c) the end of the Igor's turn.
  9. No stray windows. Linux desktop: no terminal window opens during a launch. macOS: no Terminal window. None is expected (no console is created), but look once during step 6. test_no_visible_window skips on POSIX (visible_windows() is None).
  10. Zombies: after each kill, ps -o pid,stat,args -p <supervisor pid> must not show a lingering Z whose parent is a long-running process.
  11. The transcript slug (§4.13).
  12. Leftover processes after the suite and after the smoke, by command line: pgrep -af "gremlins-test-|_supervise|claude-stub|gremlins-smoke" gives nothing.

7. Smoke recipe

The Phase 1 and Phase 2 smokes under the new names, plus the platform steps. Run it from the Igor's own Bash tool.

  • Every wait, kill and expire call goes through the Bash tool with timeout: 600000. The Bash tool backgrounds a call that passes its timeout instead of killing it, and you lose the output. wait blocks up to 570 s; kill verifies for up to 10 s per job; expire kills its jobs one after another.
  • Loop wait until exit 0 or 4. Exit 3 means "call again", and done may already hold results.
  • Keep calling wait (or list) while a gremlin runs: the orphan watchdog kills a job after orphan_after (1200 s) without an orchestrator call, and kill and expire do not count.

Setup (the shell state does not persist between Bash calls, so repeat the variable lines in each call, or put them in a small env.sh in the scratch dir and source it):

export SPIRE_GREMLIN_UNSUPPORTED_OK=1                  # only until the gate is lifted in code
CO=/path/to/your/headless-forks/checkout
G="uv run $CO/plugins/spire/skills/gremlins/gremlins.py"
S=/tmp/gremlins-smoke-<stamp>                          # scratch, outside the checkout
R="$S/smoke-run.md"                                    # run log; the state dir is $S/smoke-run-gremlins
mkdir -p "$S" && git clone --local "$CO" "$S/clone" && : > "$R"

Write the briefs as files in $S (brief-*.md). Use a full model id your settings admit; the earlier smokes used claude-opus-5-5.

# Command Must show
1 $G where exit 0; platform {name: posix, sys_platform: linux|darwin, wsl: false, supported: false, override: true}; one warning (the override); binary = your native claude with a version
2 $G init --run "$R" --model claude-opus-5-5 --cwd "$S/clone" exit 0; created: true; config.binary_resolved_by CLAUDE_CODE_EXECPATH or PATH; warnings: []; state dir with registry.json and .gitignore
3 $G train --run "$R" --base B1 --phase 1 --brief "$S/brief-train.md", then $G wait --run "$R" --all {job: j0001, base: B1, session_id}; wait exit 0, status: ok; list: B1 active, base context about 20–30k tokens (Windows: 23,676)
4 $G spawn --run "$R" --base B1 --block P1 --phase 1 --brief "$S/brief-p1.md", then wait --all launch return method: "fork", base_state: warm, rebase_due: false; result ok, cache: hit, fingerprint: ok, models_used = the model, note: notes/P1.md; a new commit in the clone (git -C "$S/clone" log --oneline -1)
5 $G continue --run "$R" --job j0002 --brief "$S/brief-cont.md", then wait --all ok, kind: continue, method: null, cache: hit, a second commit. cost_delta_usd = the raw total_cost_usd difference
6 $G spawn … --block K1 --phase 1 --brief "$S/brief-k1.md"; $G wait --run "$R" <job> --timeout 60 until running[0].messages > 0 and the sleeps run; record the tree (pids + start times; see below); $G kill --run "$R" --job <job>; wait --all wait exit 3 while it runs; kill: ok: true, status: killed, survivors: [], snapshot holds the supervisor, claude, the shell and both sleeps; afterwards every recorded pid + start time is gone; wait exit 4, killed, reason: kill, tokens and cost null. macOS before §4.2: expect the sleeps to survive (record it; it is the bug)
7 $G spawn … --block P2a --phase 2 --brief "$S/brief-p2a.md", then wait --all launch return rebase_due: true, reasons: ["phase_boundary"]; result ok, hit (the flags are advisory)
8 $G rebase --run "$R" --base B1 --as B2 --phase 2 --brief "$S/brief-rebase.md" --delta-note "$S/smoke-run-gremlins/notes/P1.md", then wait --all {job, base: B2, parent: B1, session_id}; result ok, kind: rebase; list: B1 superseded (superseded_by: B2), B2 active, parent: B1, lineage_rebases: 1, context a little larger than B1's
9 $G spawn … --base B2 --block P2b --phase 2 --brief "$S/brief-p2b.md", then wait --all launch return rebase_due: false; result ok, hit against B2's context (first request's cache_read_input_tokens ≈ B2's context_tokens; Windows: 25,277 of 25,279); a commit
10 optional, about 35 min: $G init --run "$R" --warm-margin-s 1800; then $G wait --run "$R" --all --timeout 30 every 5 min or less changed_fields: ["warm_margin_s"] (at a 1 h TTL a margin above 1800 is capped to 1800; 3600 or more is refused); about 30 min after B2's last hit a wait tick launches a ping (list: kind ping, B2 expiring); the next wait returns it in pings, not done: ok, hit; B2 warm again. Skip if time is short and say so
11 $G usage --run "$R" --phase 1 --format md, --phase 2 --format md, --block P1 --format md, $G usage --run "$R" exit 0 each; phase 1 table has train, spawn, continue rows and a total; cache-hit ratio n/n; the priced total; the P1 row's Commit cell is the real commit; json reconciled: []; ledger.jsonl has one line per results/*.json
12 $G list --run "$R", then the pgrep of §6 item 12 nothing running except at most a ping; no process left
13 orphan: $G init --run "$R" --orphan-after 90; spawn O1 (brief-hang.md); one wait <job> --timeout 20 (exit 3); then no helper call for 2 min (watch with ps only); then wait --all; finally $G init --run "$R" --orphan-after 1200 O1 killed, reason: orphaned, flag orphaned; its recorded tree gone
14 lost: spawn L1 (brief-hang.md); wait <job> --timeout 20 (exit 3); read supervisor.pid and claude.pid from $S/smoke-run-gremlins/jobs/<job>.json; kill -9 <supervisor pid>; $G list (still running: heartbeat fresh); after more than 120 s $G list again; wait <job> the second list has lost_now: [<job>]; the claude tree is gone; wait exit 4, lost, reason: supervisor_dead
15 survival: in one Bash call with a short Bash timeout (15000), run $G spawn … --block V1 --brief "$S/brief-sleep240.md" && sleep 120; when the call is backgrounded, stop it with TaskStop; check the V1 supervisor and claude by pid + start time; end your turn (hand back to the user with one line, e.g. "survival check: reply to continue"); in the next turn, wait --all after TaskStop the supervisor and claude still run; after the turn break wait returns V1 ok (its sleep 240 finished)

Briefs (short; each says why, because Opus may refuse a bare "run this blocking command"):

  • brief-train.md: read plugins/spire/README.md, plugins/spire/skills/gremlins/SKILL.md and plugins/spire/agents/athanor.md; change nothing; reply with a five-line summary.
  • brief-p1.md, brief-p2b.md: create smoke/<block>.txt with one line, run git diff --check, commit only that file as smoke <block>, write the handoff note at the path the frame gives.
  • brief-cont.md (a continue gets no frame, so put the rules in): append a second line to smoke/P1.txt, commit only that file as smoke P1 continue, append one line to <state dir>/notes/P1.md.
  • brief-k1.md: "This is a kill test of the harness: the command below is meant to hang until the orchestrator kills it. Run it in the foreground with one Bash call and a Bash timeout of 600000: sh -c 'sleep 900 & exec sleep 901'. Do not use run_in_background." This gives claude → shell → sleep 901, plus sleep 900 as a grandchild that the shell leaves behind.
  • brief-hang.md: as brief-k1.md, with sleep 900 only.
  • brief-sleep240.md: "This tests that the harness survives the end of a turn. Run sleep 240 in the foreground (one Bash call, Bash timeout 300000), then reply DONE. Change nothing."
  • brief-p2a.md: reply DONE; change nothing.
  • brief-rebase.md: absorb the attached note; change nothing.

Recording the tree for step 6. Linux: ps -eo pid=,ppid=,lstart=,args= --forest | grep -A6 _supervise. macOS: ps -axo pid=,ppid=,lstart=,command= and follow the ppids from the supervisor pid in jobs/<job>.json. Afterwards check each pid: ps -o pid=,lstart=,args= -p <pid> must print nothing, or a different start time.

Spend. Phase 1's smoke cost $0.49 and Phase 2's $0.47 (client estimates, claude-opus-5-5, a base of about 24k tokens). This recipe adds a few killed or orphaned gremlins (cents each, no result) and an optional ping or two (under $0.01 each). Expect about $1, between $0.6 and $1.5. Stop and report if it passes $5.

Clean up. $G list shows nothing running, pgrep shows nothing, then rm -rf "$S". The clone holds only smoke commits. Keep ledger.jsonl and the usage outputs in your run log (paste them; do not commit the scratch dir).

8. How to lift the gate

Per platform, after the suite and the smoke pass there. Every place to update:

  1. Code. Add the tested value to PosixPlatform.TESTED_SYS_PLATFORMS (:610): "linux" or "darwin". supported (:614) and describe() read it, so this is the one change. WSL: sys.platform is "linux" there too, so lifting linux also opens WSL. The goal keeps WSL closed unless it passes. If WSL is to stay closed, make supported also require not self.is_wsl(), and refuse a Windows binary under WSL (§4.12). Decide this with the user and write the decision down.
  2. Fixes the smoke showed. At minimum: M14 (§4.1) for your platform; zombie liveness (§4.4) on Linux and macOS; the macOS process table and creation times (§4.2) on macOS; the POSIX leftover check in tearDownModule (§4.11); the tree-size expectation (§4.5). Each comes with a test that fails before the fix. Keep every platform branch inside the seam (§2 grep).
  3. Gate tests (GateTests, tests :1405). In test_supported_flags (tests :1430) assert True for the lifted value and keep False for the platform that is still closed (and for WSL if it stays closed). Extend test_host_where (tests :1463) for your host: where without the override gives ok: true, supported: true, override: false, warnings: []. The fake-platform tests stay as they are.
  4. plugins/spire/skills/gremlins/SKILL.md: the frontmatter description ("Windows-tested; other platforms fail closed until tested (see Platforms)") and the Platforms section (line 22, which names Linux, WSL and macOS as closed).
  5. The README row for gremlins in plugins/spire/README.md (added in block 3.8, same wording as the description).
  6. plugins/spire/agents/athanor.md, the fallback line (line 34, "where not ok (Windows-tested; other platforms fail closed until tested …)"), if its wording changes.
  7. Also check plugins/spire/skills/implementation/SKILL.md line 49 ("It is Windows-tested; other platforms fail closed until tested …") and plugins/spire/skills/implementation/references/spawning-claude-code.md line 75 ("Tested on Windows with claude 2.1.283 and 2.1.299"). Add your platform and claude version there, as an observed <date> row where it is a fact about the harness (the file's status vocabulary: verified, observed, assumed, unknown, each with a date).
  8. This report. Mark your platform done in §1 and add what you found under §4 and §5 (or append a dated section for your platform).
  9. The changelog. There is no CHANGELOG file; the changelog is the body of the release commit (block 3.8, in the style of 7c1ed39). Its line "tested on Windows; Linux and macOS gated pending the platform handoff" goes stale when you lift a gate. Do not rewrite that commit: state the newly tested platform in your lift commit's body, and make sure the commit that finally merges or releases the branch carries the corrected platform line.
  10. Run the full suite and the smoke again with the gate lifted and without the override in the environment. Record both in your run log.
  11. Do not merge. The branch is merged only when both Linux and macOS are done (or the user decides otherwise). Use no version number in any platform wording (digest amendment A7): write "tested on Windows and Linux", never a Spire version next to a platform.

9. Not covered

  • Docker. It is available on the dev host and was not used. A Linux container is closer to native Linux than WSL, but it is not a desktop host (no Claude Code app env, no supervisor survival across a real turn).
  • The Rust follow-up. The user suggested picking up the helper as Rust work as well (goal "Amended … release gating"; docs/workpad/reports/2026-10-16-01-session-harness-app-forms.md). It has not been started; nothing here depends on it.
  • The Windows live smoke under the new names (Phase 4, digest C17). It is separate from yours, and it is also required before anything ships.
  • Codex. The skill does not apply there.
  • Large bases through the helper (the smokes used a base of about 24k tokens; P0.2's 182k base was a hand-run probe), and the live partial-ping case (P0.6).