251 lines
46 KiB
Markdown
251 lines
46 KiB
Markdown
# Run log — Trained implementer, forked per block (`spire:forks`)
|
||
|
||
- Plan: `docs/workpad/plans/2026-10-16-01-headless-forks.md` · Build plan: `docs/workpad/plans/2026-10-16-02-headless-forks-build.md`
|
||
- Branch: `headless-forks` (worktree `.claude/worktrees/headless-forks`) · Start commit: `d373689`
|
||
- Trunk notes: `docs/workpad/reports/2026-10-16-08-headless-forks-trunk-notes.md`
|
||
- Athanor: `claude-opus-5-5[1m]` (installed Spire 0.2.2 shape) · Session journal: Igor's, `docs/workpad/journals/2026-10-15-21-grotto-work.md` (not touched by the Athanor)
|
||
- Forks: none yet (the helper does not exist until Phase 1)
|
||
|
||
## Phase 0 — Preflight (STOP gate)
|
||
|
||
| Block | Owner (model id) | Commit | Tokens (if known) |
|
||
|---|---|---|---|
|
||
| 0 Preflight probes and report | the Athanor, `claude-opus-5-5[1m]`, own Bash | `7421d3a` (+ this hash fill-in) | probe calls: $5.68 over 45 calls (client estimate, sum of per-call deltas); the Athanor's own tokens unknown |
|
||
|
||
### Block 0 — Preflight probes and report
|
||
|
||
- Model: `claude-opus-5-5[1m]`
|
||
- Start / end: 2026-10-16 08:16:26 +0200 / 2026-10-16 12:16 +0200 (last probe 10:13:01; `date` jumped from 10:13 to 12:16 across two consecutive commands, cause unknown, likely host suspend)
|
||
- Done: Worktree `.claude/worktrees/headless-forks` created on new branch `headless-forks` from `d373689`. Throwaway probes written and run from the Athanor's own Bash in the session scratchpad (`…\scratchpad\headless-forks-p0\`, not in git): P0.0–P0.8 in the build plan's amended order (P0.0, P0.1, start P0.6 detached via the P0.1 rung, P0.2–P0.5, P0.7, P0.8 while it ran, then collect P0.6). Preflight report `docs/workpad/reports/2026-10-16-09-headless-forks-preflight.md` with verdict, per-item results and the Phase 1 defaults. Nothing in `plugins/` changed.
|
||
- Deviations from plan:
|
||
- P0.6 was started once P0.1's rung 3 had passed (a), (b) and (d), before (c)'s 15 minutes were up, so the TTL clock started earlier. (c) passed later at 08:39–08:45; had it failed, P0.6 would have died with it and been restarted.
|
||
- The scratch clone is `git clone --local --no-hardlinks` (hardlinks fail from `G:` to `C:`). Same content, same `.claude/settings.json`.
|
||
- P0.5's model cases ran first in a copy of the project settings (`$P0\p05-cwd`), which turned out to be an **untrusted workspace** (project `permissions.allow` ignored). To get a trusted `availableModels` reading, five tool-less "Reply OK" calls ran with cwd = `G:\dev\projects\reliquary` (trusted) and one in the worktree (trust check). They cannot edit anything (`--tools ""`), but they are live calls outside the scratch clone, against plan decision 4's letter. They left transcripts under the reliquary project slug in `~/.claude/projects/`. The unlisted-family case was run through `--settings '{"availableModels":[…]}'` instead, because reliquary lists all four family aliases.
|
||
- P0.5's `--agent` test and P0.8 ran in separate scratch dirs (`p05-cwd`, `p08-cwd`) instead of the clone, so adding an agent and a skill could not change P0.2's prefix while it ran.
|
||
- P0.1 (d): the Bash tool no longer kills a call at its timeout; it backgrounds it. (d) was produced with `TaskStop` on the backgrounded call, which kills the process tree. Recorded in the report.
|
||
- The P0.5 effort-cache case ran inside the P0.2 probe (a `--effort medium` fork of the P0.2 base) instead of a separate base.
|
||
- Flagged for review:
|
||
- The P0.1 verdict rests on the strict reading of (d) (launcher still alive when the Bash call is stopped). Rung 1 survives when the launcher has already exited (`r1-dprime`). Rung 3 is the ladder's answer; the reviewer may want to weigh rung 1 plus an exit-first launcher against rung 3's CIM env handling.
|
||
- `r3-main` and `r3-tkill` ran PATH `claude` 2.1.283 (CIM env lacked `CLAUDE_CODE_EXECPATH`); survival evidence is unaffected, the binary is.
|
||
- P0.8 ran in an untrusted workspace; the pre-approval "no" may differ in a trusted project.
|
||
- Effort: a different effort did not miss the cache; whether `--effort` took effect is unobservable.
|
||
- A fork that backgrounds a Bash command can leave an orphan process after `claude` exits (seen once on 2.1.283).
|
||
- P0.6: the 50-min ping read only 0.916 of A's context (re-created 7,463 tokens, about the size of the shared system/tool prefix). Still a hit by C5 and the later fork read 1.0004; cause not isolated.
|
||
- Visible windows (coordinator's message 09:52, user report): every rung-3 supervisor before the fix (P0.1 `r3-*`, the P0.2 and P0.6 probes) opened a visible console window (Windows Terminal) on the user's desktop. "No visible window" was added to P0.1's pass criteria and the surviving variant retested with `Win32_ProcessStartup.ShowWindow=0` for the supervisor and `CREATE_NO_WINDOW` for every child: no window in 26 checks (plus 3 on an informational Popen `CREATE_NO_WINDOW` variant), including during `claude` → `bash` → `PING`; (a)–(d) and the tree-kill pass again. A positive control (`r3-wincontrol`, 15 s) showed the detector does see the old window. Recorded in the preflight report §P0.1.
|
||
- `$P0\raw\p01\` was not cleaned between runs; `r3-kill2` finished unobserved during a 20-minute `git clone` (08:24–08:45).
|
||
- Left for later: the Phase 1 defaults listed at the top of the preflight report. Survival of a rung-3 supervisor across the Athanor's hand-back is untested (Phase 1 smoke).
|
||
- Expected broken: nothing (no product code yet).
|
||
- Commits: `7421d3a` (report, run log, trunk notes), then a one-line follow-up filling in this hash. Probe commits in the scratch clone only (`64dbefa`, `9bfefb7`, `bb5514d`, `0f72edb`, `9d3ddc1`), not in this repo.
|
||
|
||
### Phase 0 close
|
||
|
||
- Gate: none named for Phase 0 (probes only, no code in the repo). Every STOP item checked against its criterion in the report.
|
||
- Tests: not applicable (no product code). Probe results and raw paths in the report.
|
||
- Flags for the reviewer:
|
||
- Block 0: every deviation and flag above, one by one.
|
||
- Block 0: check the report's numbers against `$P0\raw\` (summaries `*.summary.json`, `p03-usage.json`, `p02\state.json`, `p06\state.json`, `p07\p07.json`, `p08\p08.json`, `p01\<tag>\check-*.json`).
|
||
- Not run / untested: survival past the end of the Athanor's turn; what effort changes; a trusted-workspace P0.8.
|
||
- Spend: $5.68 client estimate over 45 calls (trigger $25 not hit), plus three killed calls without a result (cents).
|
||
- Leftover processes: none. Swept at 10:13–12:16 by command line (`headless-forks-p0`, `p01_sup`, `p01_sleeper`, `p06_ttl`, `p02_base`) and by name (`PING`, `sleep`); the only hits were the Athanor's own Bash. One orphan was killed by hand earlier (`sleep.exe` pid 31372, from `r3-kill`).
|
||
- Open questions (for Igor and the user; none blocks Phase 1):
|
||
- U4 is moot for reliquary: the `sonnet`/`haiku` aliases in `availableModels` admit their full ids. An unlisted model is substituted silently, not refused; C8's runtime `model_refused` must key on the `system/informational` warning or a `modelUsage` mismatch.
|
||
- The Bash tool backgrounds instead of killing at its timeout. C2's `timeout: 600000` rule stays, but A2's failure mode ("killed with no JSON") is now "backgrounded, notification to the subagent". Does the Phase 3 text want a line on it?
|
||
- Rung 3 adds a PowerShell dependency (about 0.3 s per launch) and an env hand-off file. Rung 1 with a launcher that exits first would avoid both but dies if its launcher is in a stopped tree (e.g. pings launched from `wait`). The plan's ladder picks rung 3; the reviewer may weigh it.
|
||
- P0.8: injection works in a subagent preload, pre-approval does not (untrusted dir). SKILL.md needs the allow-rule note; the trusted-project case is for Phase 4.
|
||
- Forks that background a Bash command leave orphans after `claude` exits. Suggested default: a preamble line forbidding `run_in_background` in forks; Phase 1 decides.
|
||
- Verdict: **GO** (see the preflight report header).
|
||
- Status: provisional until review, fix pass and re-check close. Igor makes the GO/STOP call.
|
||
- Igor, 2026-10-16 ~12:18: Phase 0 accepted, GO, no separate review; the Phase 0 deviations go to the Phase 1 reviewer as handoff flags. Coordinator decisions: rung 3 hidden is the launch default (rung 1 only with a documented clean case); the fork preamble forbids `run_in_background`; `kill`/`expire` also sweep the fork's process tree; the model check keys on the warning event / `modelUsage` mismatch; "no visible window" is a permanent acceptance criterion in unit and smoke gates where testable; the binary is always the fingerprinted absolute path, and the env passed via CIM carries `CLAUDE_CODE_EXECPATH` or the explicit binary path.
|
||
|
||
## Phase 1 — Helper core
|
||
|
||
Intake 2026-10-16 12:18 +0200, Athanor `claude-opus-5-5[1m]`. Start commit `83f6bac`.
|
||
|
||
| Block | Owner (model id) | Commit | Tokens (if known) |
|
||
|---|---|---|---|
|
||
| 1.1 Skeleton, state, `where`, `init` | fresh general-purpose agent, `opus` alias, stamped `claude-opus-5-5[1m]` | `d36ac2f`, `85d1e14` | 211,075 subagent tokens (harness total) |
|
||
| 1.2 Launch and supervise | fresh general-purpose agent, `opus` alias, stamped `claude-opus-5-5[1m]` | `009a252`, `8f2c7d3` | 337,053 subagent tokens (harness total); harness duration 11,929 s |
|
||
| 1.3 Phase tests, smoke | the Athanor, `claude-opus-5-5[1m]`, own Bash; no fallout agent needed | this run-log commit | smoke calls $0.49 client estimate (+ the killed fork, no result) |
|
||
|
||
Gate after 1.1 (Athanor, 12:29): G-unit `Ran 52 tests … OK`, exit 0; G-where exit 0, one JSON object. `--effort` values checked against `claude --help` 2.1.299 (low, medium, high, xhigh, max): match.
|
||
|
||
### Block 1.1 — Skeleton, state, where, init
|
||
|
||
- Model: `claude-opus-5-5[1m]`
|
||
- Start / end: Fri Oct 16 12:20:48 +0200 2026 / Fri Oct 16 12:28:49 +0200 2026 (`date`; tests and gates finished by the end time, the note and commit followed)
|
||
- Done: New `plugins/spire/skills/forks/forks.py` (plan §2 PEP 723 header, stdlib only, LF): argparse dispatch for all 13 commands; exit codes 0/2/3/4 as constants; stdout = exactly one JSON object per call (argparse errors, `--help` and uncaught exceptions included); path normalisation (relative, `G:\`, `G:/`, `/g/…`, `/c/…`; POSIX `abspath`); state dir `<run without .md>-forks` with `.gitignore` (`raw/`, `*.lock`), `mcp-empty.json`, `jobs/ briefs/ notes/ results/ logs/ raw/`; `registry.json` `"schema": 1`; `RegistryLock` (create-exclusive, stale after 30 s, token-checked release); atomic temp-then-`os.replace` with `PermissionError` retries (40 × 50 ms) for writers and readers; `ledger_append` (caller holds the lock); `_now()` / `_iso()`; `pid_record` / `pid_alive` / `proc_ctime` (Windows `OpenProcess` + `GetProcessTimes`, POSIX `/proc/<pid>/stat`); `git()` always `git --no-optional-locks`; scrub constants (`SCRUB_DROP`, `SCRUB_DROP_PATTERNS`, `SCRUB_KEEP`, `SCRUB_PINNED` + `CLAUDE_CODE_EXECPATH`) and `scrubbed_env(binary)`; `supervisor_interpreter()` (C6); `SPAWN_METHOD` (`cim-hidden` / `setsid`); `where`; `init` (all launch fields and defaults, fingerprint sha256 of canonical JSON, config extras incl. `orphan_after` 1200 and `pings` on, idempotency, C8 admission, all validation before any write). Launching and Phase 1 commands return `{"error":"not implemented","block":"1.2"}`, Phase 2 ones `{"phase":2}`, exit 2. New `preamble.md` (digest content, short). New `tests/test_forks.py` (PEP 723 + `unittest`, imports `forks.py` by path, 52 tests: path forms, state-dir derivation, scrub, lock contention in-process and across processes, stale-lock recovery by `at`, by mtime and under an injected `_now`, safe release after takeover, replace while a reader holds the target (retried, `attempts > 1` asserted on Windows), registry schema, ledger, pid + ctime, `init` defaults / idempotency / change before and after a fake base / bad model ids / `plan` / unknown mode and effort / missing binary / agent, C8 exact / `[1m]` strip / family alias / no list / unlisted → exit 2 naming the files with settings untouched, `where` via `uv run` with a stub and with the host binary, error-case stdout invariant via `uv run`, unknown command, not implemented, `--help`, internal error, N3 guard). Stub binary generated at test runtime in `tempfile` (`.cmd` on Windows); `USER_SETTINGS_PATH` monkeypatched to a temp file, so no test reads `~/.claude`.
|
||
- Gates:
|
||
- G-unit `uv run plugins/spire/skills/forks/tests/test_forks.py` → exit 0, `Ran 52 tests in 3.173s` / `OK`.
|
||
- G-where `uv run plugins/spire/skills/forks/forks.py where` → exit 0; stdout 1 line, a JSON object, `ok: true` (binary `CLAUDE_CODE_EXECPATH` 2.1.299, Python 3.13.1, uv 0.5.13, git 2.34.1.windows.1, `spawn: cim-hidden`). uv's `Reading inline script metadata…` line goes to stderr.
|
||
- Deviations from plan (digest):
|
||
- Argument sets for the 1.2 commands are already declared in argparse from the digest (`train --base --brief [--block --phase]`, `fork --base --block --brief [--phase --mode --paths --max-minutes --max-budget-usd --force]`, `continue --job --brief [--max-minutes]`, `wait [jobs] --all --any --timeout`, `expire --base|--all`, `kill --job`, `_supervise --job`). 1.2 may change them.
|
||
- The N3 guard is already in the dispatch for `train`, `fork`, `continue`, `rebase` (exit 2 before "not implemented" when `SPIRE_FORKS_CHILD` is set); `tick` left for Phase 2.
|
||
- `--help` returns `{"help": …, "commands": […]}` on stdout with exit 0 (keeps the invariant). Unknown extra arguments are a usage error (exit 2), except on the Phase 2 stubs, which tolerate them until Phase 2.
|
||
- Uncaught exceptions: exit 2, `{"error":"internal error","type","detail"}`, traceback on stderr (the digest defines no code for it).
|
||
- `init` takes more flags than the digest lists: `--ttl-s`, `--stall-after-s`, and one per threshold (`--rebase-commits`, `--rebase-lines`, `--rebase-delta-tokens`, `--max-base-tokens`, `--max-rebases`). Thresholds sit nested under `config.thresholds`. Config also carries `binary_resolved_by` and `model_admission` (how, not what; ignored by the change comparison).
|
||
- `where` takes an optional `--binary` (tests use a stub) and also reports `python.supervisor_interpreter`, `platform`, `scrub.drop_patterns`; on failure it carries both `reason` and `error`.
|
||
- `preamble.md` adds two lines beyond the digest: "nobody can answer a question mid-run" and "never push, rebase or reset" (mirrors the deny list).
|
||
- Flagged for review:
|
||
- `--effort` is restricted to `low|medium|high|xhigh|max` and `--permission-mode` to `default|acceptEdits|dontAsk|bypassPermissions|auto` (`plan` refused). Both sets are assumed, not checked against `claude --help`.
|
||
- Fingerprint launch dict: `model, effort, permission_mode, tools, allowed_tools, disallowed_tools, mcp {strict, config path}, append_system_prompt {path, sha256}, agent {name, path, sha256} | null, cwd, binary {path, version, size, mtime}, env.pinned` (only the two TTL/autoupdater vars; `SPIRE_FORKS_CHILD` and `CLAUDE_CODE_EXECPATH` are added at launch by `scrubbed_env`, outside the hash). The mcp config path is per run, so the hash differs between runs by construction.
|
||
- `--allowed-tools` / `--disallowed-tools` are repeatable lists and each value is also split on commas outside parentheses (`Bash(a,b)` stays one rule).
|
||
- Stale-lock breaking re-reads the lock and removes it only if unchanged; two breakers racing still have a narrow window. Release removes the lock only if its token is still ours. Lock wait is 45 s (longer than the 30 s stale age).
|
||
- C8: an unreadable or invalid settings file is skipped with a stderr note; `availableModels: []` counts as a list that admits nothing.
|
||
- Host env has `ANTHROPIC_BASE_URL`; it is on neither list, so it is kept for the child and listed under `scrub.unknown` by `where` (the P0 probes kept it too and auth worked). The coordinator may want it on a list.
|
||
- `pid_alive` treats `ERROR_ACCESS_DENIED` from `OpenProcess` as alive with an unverifiable creation time.
|
||
- Git Bash paths whose first segment is not a single letter (`/tmp/x`) are treated as rooted on the cwd's drive, not mapped to Git's install dir.
|
||
- The model regex is the digest's; it admits ids like `claude-sonnet` with no version.
|
||
- Left for later: everything the digest assigns to 1.2 (templates, launch, supervise, results, ledger lines, `wait`/`list`/`expire`/`kill`, no-visible-window test, `run.last_orchestrator_call_at`); Phase 2 commands.
|
||
- Expected broken: `train`, `fork`, `continue`, `wait`, `list`, `expire`, `kill`, `_supervise` exit 2 "not implemented" (block 1.2); `rebase`, `usage`, `tick` (Phase 2).
|
||
- Commits: `d36ac2f` (code, tests, preamble, this note), then a one-line follow-up filling in this hash.
|
||
|
||
### Block 1.2 — Launch and supervise
|
||
|
||
- Model: `claude-opus-5-5[1m]`
|
||
- Start / end: Fri Oct 16 12:31:01 WEDT 2026 / Fri Oct 16 15:48:45 WEDT 2026 (`date`; the host clock read 12:31 at my first call and 15:42 at the first manual run, so the span includes a jump like the one Phase 0 saw; the gates ran 15:47:38–15:48:30)
|
||
- Done: `forks.py` gains `train`, `fork`, `continue`, `wait`, `list`, `expire`, `kill`, `_supervise`. Launch writes the job (registry index, status `created`) under the lock, `briefs/<job>.md` (template + brief) and `raw/<job>.env.json` (scrubbed env with `CLAUDE_CODE_EXECPATH` = stored binary and `SPIRE_FORKS_CHILD=1`), then spawns `<base interp> -I forks.py _supervise --run --job` through `powershell` (`CREATE_NO_WINDOW`) → CIM `Win32_Process.Create` with `ShowWindow=0`, command line and cwd passed in `SPIRE_FORKS_CMD`/`SPIRE_FORKS_CWD`; `ReturnValue` ≠ 0 ends the job `failed`/`spawn_failed`. The supervisor redirects its stdio to `raw/<job>.sup.log`, starts `claude` with exactly the env file and `CREATE_NO_WINDOW` (stored absolute binary path, full fingerprinted command line every call, `--session-id` on train and fork, `--resume … --fork-session` on fork, `--resume` only on continue, `--max-budget-usd` passed through), feeds the prompt on stdin, parses the stream (`StreamState`: init, message ids, first/last assistant usage, `system/informational` restriction warning, result), and is the only writer of `jobs/<job>.json` (heartbeat every 10 s). Watchdogs: `max_minutes` → kill tree, `timeout`; `stall_after_s` of no stdout → flag `stalled`; N1 orphan check every 1 s → kill tree, `killed` + `orphaned`; restriction warning → kill tree, `failed`/`model_refused`. Finalising (`finalize_job`, once, under the lock): `results/<job>.json` (plan §4.2 fields + `paths`, `reason`, `model_requested`), ledger line (design §6 + `status`, `flags`, `reason`), registry update (status, cumulative cost and `modelUsage` for the session, base `last_hit`; on train: base `active` with `base_commit`, context tokens, training cost, cumulative `modelUsage`, runtime fingerprint). Usage rule, `usage_mismatch`, cache verdict, `cold_start`, C1 warmth, fingerprint `ok|drift:<fields>` (launch: `binary`, `append_system_prompt`, `agent`; runtime: `tools`, `permissionMode`, `plugins`, `agents`, `apiKeySource` against the base's init). `wait` per C4, `list` (all bases with warmth, all jobs, running info), `lost` reaper (heartbeat > 120 s and supervisor pid + ctime dead), `kill` (Toolhelp32 tree snapshot with ctimes → `taskkill /T /F` → sweep with `TerminateProcess` by pid + ctime → verify up to 10 s), `expire` (retire, kill running jobs of the base, flag `expired`). Refusals: N2, N3, cap, launch-field overrides on fork/continue (each field accepted by the parser only to be refused), base not active without `--force`, continue of a running or non-fork job, missing binary (`reason: binary_missing`), base name taken. New `templates/train.md` and `templates/fork.md` (stale-snapshot sentence, delta header from `git --no-optional-locks log --oneline <base_commit>..HEAD` and `diff --shortstat <base_commit>`, commit rule per mode, note path, logs dir). `preamble.md` unchanged. Tests: 17 new (69 total): a generated stream stub `claude` (`stub/claude.cmd` → base interpreter `-I stub.py`, behaviour per call from `@stub …` lines in the brief, sessions kept beside it so resume/fork report cumulative `modelUsage` and `total_cost_usd`), real CIM launches with the stub as `--binary`, `RuleTests` on the real P0.2 seq-1 numbers, and a module-end check that no process whose command line contains the tests' temp prefix survives.
|
||
- Gates:
|
||
- G-unit `uv run plugins/spire/skills/forks/tests/test_forks.py` → exit 0, `Ran 69 tests in 52.001s` / `OK` (suite wall 52 s; two earlier full runs at 68 tests: 51.7 s each, OK).
|
||
- G-where `uv run plugins/spire/skills/forks/forks.py where` → exit 0, stdout one line, a JSON object, `ok: true`, `spawn: cim-hidden`, binary 2.1.299.
|
||
- Leftover processes after the gates: none. Checked by command line (`forks-test-`, `_supervise`, my manual scratch run `b12-manual`) via `Get-CimInstance Win32_Process`, not by name; also asserted by the suite's `tearDownModule`.
|
||
- Manual check (scratch run in the session scratchpad, stub binary): the supervisor's parent is `WmiPrvSE.exe`; its tree is python → conhost, cmd (claude) → conhost, python stub → python grandchild; `kill` snapshot 6 pids, `taskkill_rc` 0, no survivors.
|
||
- Deviations from plan:
|
||
- `fork --max-minutes` and `continue --max-minutes` take a positive float (the tests use 0.06 min); `init --max-minutes` stays an integer.
|
||
- `fork`/`continue` also run the `lost` reaper before the cap check (the digest names `wait` and `list`), so a dead supervisor cannot block the cap.
|
||
- The `lost` reaper kills the job's leftover `claude` tree (from the job file) before marking it `lost`; the digest only says to mark it.
|
||
- On the restriction warning the supervisor kills `claude` at once instead of letting the substituted model finish (saves the spend; the result still reads `failed`/`model_refused`).
|
||
- After `claude` exits, the supervisor sweeps any descendants of it that are still alive (the "backgrounded `sleep 240`" trap); logged in `sup.log`.
|
||
- `train` takes no launch-field overrides (the design's table allows them; the plan's invariant and the digest do not); they are usage errors there.
|
||
- Flagged for review (semantics the digest left open):
|
||
- `wait --all` = every job not yet *settled*; a job is settled when it was in the condition of a `wait` that returned 0 or 4. `done` holds only results not delivered before. So the mixed case gives 3 (failed result in `done`), then 4 when the rest finish (the failed job stays in the condition without being returned again), then 0 on an empty set. Explicit job lists use the same delivery marks. `--any`: met when any member has finished (or the set is empty); exit 4 if any finished member is not `ok`. `wait` with neither jobs nor `--all` is a usage error; `--timeout` > 570 is refused (exit 2).
|
||
- `reason` vocabulary: `model_refused`, `is_error`, `exit_code`, `no_result`, `spawn_failed`, `launch_error`, `supervisor_error` (failed); `kill`, `expire`, `orphaned` (killed); `supervisor_dead` (lost). `timeout` has `reason: null` (the digest: null unless failed/killed/lost). Details go to the registry's `reason_detail` (shown by `list`), not into the result or ledger.
|
||
- Precedence when deciding a finished call: model refused > no result (`exit_code` if non-zero, else `no_result`) > `is_error` > non-zero exit. A model is "refused" when the warning appeared or the requested id (with `[1m]`) is not among the `modelUsage` keys that grew; extra models do not count.
|
||
- Without a `result` event (killed, lost, died): `tokens` are all `null`, `cost_delta_usd` `null`, `models_used` `[]`, `turns` = distinct assistant message ids seen; cache verdict still from the first assistant event if one arrived.
|
||
- `last_hit` for a train is the time of its **last** assistant event (the full prefix is written there); for fork/continue the first, written to the base as soon as it arrives. A continue refreshes its base's `last_hit`.
|
||
- `base_state` is `null` for train jobs; `cold_start` is only computed for fork/continue (a train is always cold).
|
||
- `continue` sends the brief as given (no template; there is no `templates/continue.md`). Its `parent_session` is the fork's parent (the base session); its cost and `modelUsage` "before" are the fork session's last cumulative totals (updated by every continue), falling back to the base's when the fork has none (a killed fork: the delta then includes the killed fork's spend).
|
||
- `note` = `notes/<block>.md` (block sanitised) if that file exists when the job ends, else `null`; `fork.md` tells the fork that path. Train jobs have `note: null`.
|
||
- A failed training leaves the base `failed`; `train` may reuse a name only when that base is `failed`.
|
||
- `kill` of a job that is no longer running exits 0 with `killed: false` and its status; survivors after the 10 s sweep give `ok: false` (exit still 0).
|
||
- A `kill` that lands while the supervisor holds the registry lock to finalise leaves a stale lock (broken after 30 s) and, in a narrow window, a duplicate ledger line. `kill` marks `kill_requested` first; a supervisor that finalises after that records `killed` unless the call ended `ok`.
|
||
- `wait` updates `last_orchestrator_call_at` at its start and end only (a 570 s wait stays under the 1200 s `orphan_after`).
|
||
- Stall is measured on any stdout line, not only parsed JSON events.
|
||
- `process_tree` follows parent pids through dead processes (so orphans of a dead `claude` are found) but stops at a node whose pid now belongs to a process with another creation time; this reuse guard is not exercised by a test.
|
||
- `spawn_failed` shows up in `wait`, not in the launch return (the launch return is C1's fixed shape).
|
||
- Not tested: the POSIX path (`setsid` spawn, process-group kill, `/proc` tree) — written, never run; `--agent` drift (only the code path, no test); a real `claude` (no test spends tokens; Phase 1.3 smoke); supervisor survival across the end of the orchestrator's turn (1.3).
|
||
- Left for later: warmth inference, pings, `rebase`, `rebase_due`/`retrain_due` reasons, `usage`, `tick` (Phase 2); `SKILL.md` (Phase 3).
|
||
- Expected broken: `rebase`, `usage`, `tick` exit 2 "not implemented" (Phase 2).
|
||
- Commits: `009a252` (code, tests, templates, this note), then a one-line follow-up filling in this hash.
|
||
|
||
### Block 1.3 — Phase tests and live smoke
|
||
|
||
- Model: `claude-opus-5-5[1m]` (the Athanor, own Bash)
|
||
- Start / end: 2026-10-16 15:50:01 +0200 / 15:55:50 +0200 (`date`)
|
||
- Done:
|
||
- Gates after 1.2 (15:50): G-unit exit 0, `Ran 69 tests in 52.065s`, OK; G-where exit 0. Leftover-process check by command line (`_supervise`, the tests' temp prefix, `stub`): none.
|
||
- Live smoke per plan §7 "Phase 1 tests and smoke", in the scratch clone (`…\scratchpad\headless-forks-p0\clone`, HEAD `9d3ddc1`, clean), run log `…\headless-forks-p0\smoke1\2026-10-16-15-smoke-run.md`, state dir beside it. Every `wait` ran from Bash with `timeout: 600000`. Briefs in `…\smoke1\brief-*.md`.
|
||
- `init --model claude-opus-5-5 --cwd <clone>` → exit 0; binary via `CLAUDE_CODE_EXECPATH` (2.1.299); admission "exact id in the clone's `.claude/settings.json`".
|
||
- `train --base B1` (read 3 Spire files, no edits) → `wait --all` exit 0: `ok`, 11.6 s, base context 23,676, cost $0.2129.
|
||
- `fork --block S1` (sequential; write, `git diff --check`, commit, note) → `wait --all` exit 0: `cache: hit`, `fingerprint: ok`, `models_used: ["claude-opus-5-5"]`, note `notes/S1.md`, clone commit `38e2605`, cost Δ $0.0878.
|
||
- `fork --block S2` → `wait --all` exit 0: `hit`, `ok`, same model, note `notes/S2.md`, commit `447b6cd`, cost Δ $0.0953.
|
||
- `continue --job j0002` (append a line, commit) → `wait --all` exit 0: `ok`, commit `a54bb59`, cost Δ $0.0955 (= its raw `total_cost_usd` 0.39614 − S1's 0.30066).
|
||
- `fork --block K1` (long read loop) → `wait j0005 --timeout 25` exit **3** (`running`, 5 turns) → `kill --job j0005`: snapshot supervisor `python.exe` 25952, `conhost.exe` 11208, `claude.exe` 29560, `conhost.exe` 5932; `taskkill_rc` 0; `survivors: []`. Independent check by pid + creation time: all four dead. Command-line sweep (`smoke1`, `_supervise`, the clone path): nothing. → `wait --all` exit **4** (`killed`, `reason: kill`, tokens and cost `null`).
|
||
- `list` → `…\smoke1\list-final.json`.
|
||
- First-request cache reads (raw streams): S1, S2, K1 each 23,674 of the base's 23,676 (0.9999); the continue 27,192 (the fork's own longer prefix).
|
||
- All five streams ran `claude_code_version` 2.1.299. `raw/j0002.env.json` carries `CLAUDE_CODE_EXECPATH` (the 2.1.299 path) and `SPIRE_FORKS_CHILD=1`, and none of `CLAUDE_CODE_MESSAGING_TOKEN`, `VIRTUAL_ENV`, `CLAUDE_EFFORT`.
|
||
- **No visible window**: checked during S1, the continue and K1 (supervisor + `claude.exe` + 2 `conhost.exe` each): no visible window of the tree, no visible console or Windows Terminal window on the desktop.
|
||
- Ledger vs results (`…\probes\smoke_ledger.py`): 5 ledger lines, 5 result files; for every job, `status`, `flags`, `tokens`, `cost_delta_usd`, `cache`, `fingerprint`, `models_used`, `session`, `parent_session`, `turns`, `kind`, `block`, `base` are equal in both; every cost Δ equals the raw `total_cost_usd` difference.
|
||
- Phase-end tests (15:55): G-unit exit 0, `Ran 69 tests in 51.915s`, OK; G-where exit 0.
|
||
- Smoke results (from the ledger):
|
||
|
||
| Job | Kind | Block | Status | Cache | Fingerprint | Models used | in | out | cache write (1h) | cache read | Cost Δ | Turns | Wall s |
|
||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||
| j0001 | train | — | ok | — | ok | claude-opus-5-5 | 4 | 1,004 | 23,674 | 16,913 | $0.2129 | 4 | 11.6 |
|
||
| j0002 | fork | S1 | ok | hit | ok | claude-opus-5-5 | 12 | 1,452 | 3,518 | 152,790 | $0.0878 | 6 | 18.6 |
|
||
| j0003 | fork | S2 | ok | hit | ok | claude-opus-5-5 | 12 | 1,670 | 3,885 | 154,044 | $0.0953 | 6 | 23.1 |
|
||
| j0004 | continue | S1 | ok | hit | ok | claude-opus-5-5 | 12 | 1,777 | 3,263 | 168,958 | $0.0955 | 8 | 20.1 |
|
||
| j0005 | fork | K1 | killed (`kill`) | hit | ok | — | — | — | — | — | — | 6 | 26.0 |
|
||
|
||
Ledger file: `…\headless-forks-p0\smoke1\2026-10-16-15-smoke-run-forks\ledger.jsonl` (5 lines; not committed, it is scratch).
|
||
- Deviations from plan: none in the smoke steps. The plan's "kill leaves no `claude` process behind" was checked by recorded pid + creation time (A18) and by a command-line sweep, not by process name.
|
||
- Flagged for review:
|
||
- `jobs/<job>.json` `turns` counts assistant message ids (2 for the training) while the result's `turns` is `num_turns` (4). Only the result/ledger value is reported; the job-file field is a progress counter. Harmless, but two meanings under one name.
|
||
- The `continue` result's cache verdict is computed against the **base** context (27,192 / 23,676 → `hit`); its `parent_session` is the base session, not the fork it continues (1.2's documented choice).
|
||
- Supervisor survival across the end of the Athanor's turn is still untested: every smoke job finished or was killed inside this turn (the procedure forbids hand-back with running jobs anyway).
|
||
- The smoke base is small (23,676 tokens); the large-base behaviour was covered in P0.2, not re-run through the helper.
|
||
- Left for later: Phase 2 (warmth inference, pings, `rebase`, `usage`, `tick`).
|
||
- Expected broken: `rebase`, `usage`, `tick` exit 2 "not implemented" (Phase 2).
|
||
- Commits: no code; only the run-log commit that adds this note and the Phase 1 close (`📝 Headless forks Phase 1: smoke results and phase close`).
|
||
|
||
### Phase 1 close
|
||
|
||
- Gate: G-where after 1.1, 1.2 and at phase end — exit 0 each time, one JSON object.
|
||
- Tests: G-unit at phase end (15:55) — exit 0, 69 tests, 51.9 s. Live smoke — all plan §7 Phase 1 criteria met (above). No fallout block was needed, so no fix agent ran.
|
||
- Flags for the reviewer (each with its block):
|
||
- Phase 0 (handed over by Igor as flags for this review): every deviation and flag in "Block 0" and "Phase 0 close" above — P0.6 started before P0.1 (c) finished; `--no-hardlinks` clone; six tool-less calls outside the clone (main checkout, worktree); separate scratch dirs for `--agent` and P0.8; (d) produced with `TaskStop` because the Bash tool backgrounds at timeout; effort case run inside P0.2; rung-3 runs `r3-main`/`r3-tkill` on 2.1.283; P0.8 in an untrusted dir; effort unobservable; orphan background command; P0.6 ping read 0.916; visible windows before the fix and the retest; the 20-min clone; the clock jump.
|
||
- 1.1: `preamble.md` written in 1.1 instead of 1.2 (the digest moved it: `init` hashes it).
|
||
- 1.1: every deviation and "Flagged for review" line of its note — argparse sets declared early; N3 guard in the dispatch; `--help` as JSON; internal-error exit 2; extra `init` flags and nested thresholds; `where --binary`; two extra preamble lines; effort/permission-mode value sets (effort checked against `claude --help` 2.1.299 by the Athanor: match); per-run mcp path in the fingerprint hash; comma-splitting outside parentheses; stale-lock race window; invalid settings skipped, `[]` admits nothing; `ANTHROPIC_BASE_URL` kept and listed as unknown; access-denied treated as alive; `/tmp`-style paths; the permissive model regex.
|
||
- 1.2: every deviation and "Flagged for review" line of its note — float `--max-minutes`; lost reaper before the cap and killing the leftover tree; immediate kill on the restriction warning; post-exit descendant sweep; no overrides on `train`; `wait --all` "settled" semantics; the reason vocabulary (timeout has `reason: null`); precedence of failure reasons; null usage without a result; train `last_hit` from its last event; continue without template, its parent session and cost basis; note detection; failed-base name reuse; `kill` of a finished job; the kill/finalise race (stale lock, possible duplicate ledger line); `last_orchestrator_call_at` at wait start/end only; stall on any stdout line; pid-reuse guard untested; `spawn_failed` only visible in `wait`.
|
||
- 1.2: not tested — POSIX path, `--agent` drift, pid-reuse guard.
|
||
- 1.3: the four flags in its note.
|
||
- Phase 1 as a whole: the contract digest `docs/workpad/plans/2026-10-16-12-headless-forks-phase1-contract.md` (commit `670401d`) is an Athanor-written brief that pins semantics the build plan left open (state layout, CIM command, scrub additions incl. `CLAUDE_CODE_EXECPATH` and `SPIRE_FORKS_CHILD`, reason fields, the `init` idempotency rule, C8 admission rule). The reviewer should check it against the build plan rather than take it as given.
|
||
- Not run / untested: supervisor survival past the Athanor's hand-back; POSIX; a large base through the helper; `stalled` with a real `claude` (stub only); `lost` with a real `claude` (stub only).
|
||
- Spend (Phase 1): live smoke $0.49 client estimate (`total_cost_usd` of the four completed calls' deltas; the killed fork reported none). Unit tests spend nothing (stub). Subagent tokens: 1.1 211,075; 1.2 337,053 (harness totals); the Athanor's own unknown.
|
||
- Processes at hand-back: none from this phase (command-line sweep at 15:54, and the suite's own teardown check).
|
||
- Status: provisional until review, fix pass and re-check close.
|
||
|
||
### Phase 1 fix pass
|
||
|
||
- Model: `claude-opus-5-5[1m]` (fixer, own tools, no agents dispatched)
|
||
- Start / end: Fri Oct 16 16:11:40 WEDT 2026 / Fri Oct 16 16:27:01 WEDT 2026 (`date`; this note and its commit followed)
|
||
- Review: `docs/workpad/reports/2026-10-16-16-headless-forks-phase1-review.md`. Fixed: I1, I2, I3, M1, M2, M3, M4, M5, M9. Not touched (deferred by the Athanor): M6, M7, M8, M10–M16.
|
||
- Done, per finding (each test fails on the unfixed `20b049e` helper; checked by running the new tests against it in a scratch copy: 18 failures, 11 errors, 0 passes):
|
||
- **I1** — `_conclude`: `model_refused` only on the restriction warning, or when `models_used` is non-empty and lacks the requested id. New `api_error_reason(result)` sets `usage_limit` (429, or "usage limit"/"rate limit"/"hit your limit"/"API Error: 429"), `overloaded` (529 / "overloaded"), `prompt_too_long`, `api_error` (any other status, or "API Error" text); `reason_detail` = `api_error_status <n>: <first 200 chars of result/error text>`. Precedence: model refused > no result > API error > `is_error` > non-zero exit. Stub: `error` now bills nothing (no assistant events, empty `modelUsage` delta); new `apierr=<status>` and `errtext=<words_with_underscores>`. Tests: `RuleTests.test_api_error_reason`, `LaunchTests.test_api_errors_are_not_model_refused` (each new reason end to end + substitution still `model_refused`), `LaunchTests.test_failures_flags_and_drift` (its `error` case is now the empty-delta path and still reads `is_error`).
|
||
- **I2** — `process_tree(rec, alive_until)`: for a dead root, a direct child is taken only if its creation time is after the root's recorded one and at most `alive_until` + 1 s (the job-file times are whole-second ISO). No recorded ctime or no `alive_until` → a dead root claims nothing. Below a claimed (live, pid + ctime verified) child the old rule applies. The live-root path is unchanged. New `epoch_to_ctime` (Windows FILETIME; POSIX `/proc/stat` btime × CLK_TCK) and `last_alive_at(rt)` (later of `heartbeat_at`, `ended_at`). `kill_tree` passes `alive_until` through; `reap_lost` and `kill_job` pass the job file's last-alive time; the supervisor's own calls pass `time.time()`. Tests: `ProcessTreeTests` (fake process table, `process_table`/`proc_ctime`/`pid_alive` patched, nothing real touched): `test_dead_root_claims_only_children_from_its_lifetime` (reused-pid child after the heartbeat and a pre-job process with the dead pid as stale ppid are not claimed, the genuine child and its later child are), `test_dead_root_without_bounds_claims_nothing`, `test_live_root_unchanged`, `test_reap_lost_bounds_by_heartbeat`.
|
||
- **I3** — only a fork's first assistant event (supervisor, `hit_written = kind != "fork"`) and the training's last event (`_conclude`) refresh the base's `last_hit`; a continue no longer does. Test: `LaunchTests.test_continue_leaves_base_last_hit` (also checks a following fork still refreshes it).
|
||
- **M3** — new `SupClock`: a tick gap > `CLOCK_JUMP_S` (60 s; the tick is 0.25 s) is a jump; gap minus one tick is left out of the `max_minutes` elapsed time, and the orphan watchdog counts from the later of the last orchestrator call and the jump (one more `orphan_after`). A jump is logged to `sup.log` (stderr) and adds flag `clock_jump`. Tests: `RuleTests.test_sup_clock_jump`; `LaunchTests.test_clock_jump_not_orphaned_or_timed_out` (a real `Supervisor` run in-process under an injected `_now` that jumps 2 h one second into a 3 s stub call, `orphan_after` 600, `--max-minutes 1`: ends `ok` with `clock_jump`, jump logged; the unfixed helper ends it `timeout`).
|
||
- **M2** — a lock is stale only when its holder pid + ctime is dead, or it is older than `LOCK_ABANDONED_S` = 300 s (every critical section is a few small JSON read-modify-writes: ms, at worst seconds of `PermissionError` retries; 300 s is two orders above that and only matters for a holder that cannot be judged). Breaking: rename the lock to a unique `registry.<hex>.broken.lock`, delete it only if its content is the one judged stale, else put it back without overwriting (Windows `rename`, POSIX `link` + `unlink`). A failed break falls through to the deadline check and the sleep. `LOCK_STALE_S` is gone. Tests: `LockTests.test_unbreakable_stale_lock_respects_deadline` (rename/remove of the lock raise `PermissionError`: returns "registry locked" within the 0.5 s deadline; the unfixed helper spins), `test_fresh_lock_taken_during_break_survives` (a fresh lock swapped in at the moment of the break survives; no `.broken.lock` left), `test_dead_holder_lock_broken_at_once`, `test_abandoned_lock_broken_by_age`.
|
||
- **M1** — `finalize_job` writes the result, then the registry (the once-only guard), then the ledger line; `ledger_append` skips a job that already has a line (under the lock) and starts a new line after a torn last line. Test: `AtomicTests.test_ledger_append_dedupes_by_job`.
|
||
- **M4** — `CHILD_REFUSED` adds `kill`, `expire`, `init` (exit 2, message names the command and "inside a fork"). `stamp_orchestrator`/`touch_orchestrator` do nothing when `SPIRE_FORKS_CHILD` is set (all five stamp sites go through them). Tests: `CliTests.test_child_refuses_kill_expire_init`, `CliTests.test_child_does_not_stamp_orchestrator`.
|
||
- **M5** — `PERMISSION_MODES` = `acceptEdits, auto, bypassPermissions, manual, dontAsk, plan`; `plan` still refused; `default` is now "unknown permission mode" (its `allowed` list omits `plan`). Test: `InitTests.test_permission_modes_match_2_1_299`.
|
||
- **M9** — `ANTHROPIC_BASE_URL` on `SCRUB_KEEP`. `where` always carries `warnings` (list); one entry when the variable is set and, trailing slash and case ignored, not `https://api.anthropic.com`; exit stays 0. Test: `CliTests.test_where_warns_on_base_url` (unset, default, default with slash, a gateway URL).
|
||
- Behaviour changes to existing tests:
|
||
- `LockTests.test_stale_lock_broken` → `test_dead_holder_lock_broken_at_once`: the lock is fresh but its holder is dead (a dead-holder lock is now broken at once, no 30 s wait). The old fixture's `pid: 1` without ctime would be "alive" on POSIX.
|
||
- `test_garbage_stale_lock_broken_by_mtime` and `test_stale_under_injected_clock_and_safe_release`: the age bound is `LOCK_ABANDONED_S` (300 s) instead of `LOCK_STALE_S` (30 s).
|
||
- `test_fresh_lock_not_broken`: the holder is this live process (pid 1 is dead on Windows, which now means stale); it also checks a fresh garbage lock is not broken.
|
||
- `ScrubTests.test_unknown`: `ANTHROPIC_BASE_URL` is no longer in `scrub.unknown` (it is `keep`).
|
||
- Stub `error` directive: bills nothing (previously billed three turns to the requested model). `test_failures_flags_and_drift` expectations unchanged.
|
||
- The `timeout` `reason_detail` reads "run time over max_minutes" (was "wall time"); no test asserts it.
|
||
- Gates:
|
||
- G-unit `uv run plugins/spire/skills/forks/tests/test_forks.py` → exit 0, `Ran 86 tests in 68.942s` / `OK` (16:25:33–16:26:42; was 69 tests / 52 s).
|
||
- G-where `uv run plugins/spire/skills/forks/forks.py where` → exit 0, one line, one JSON object, `ok: true`, `spawn: cim-hidden`, binary 2.1.299 via `CLAUDE_CODE_EXECPATH`, `warnings: []`.
|
||
- Leftover processes: none (16:27, `Get-CimInstance Win32_Process` by command line: the tests' temp prefix, `_supervise`, the scratch copy of the old helper); also the suite's `tearDownModule`.
|
||
- No live `claude` calls (stub only).
|
||
- Flagged for the re-check:
|
||
- I2: children a dead root spawned in the last heartbeat interval (up to 10 s before it died) are not claimed. Missing a few is the price of never killing an unrelated process. The supervisor's post-exit sweep is unaffected (it passes `time.time()`).
|
||
- I2: on POSIX without `/proc/stat`, `epoch_to_ctime` is None, so a dead root claims nothing there.
|
||
- M2: one residual case. Waiter A renames a lock aside, finds a fresh lock L2 (not the stale one), and tries to put it back. If a third waiter C created a lock in that microsecond gap, the restore fails: L2 stays aside (logged, named `registry.<hex>.broken.lock`, matched by `.gitignore`'s `*.lock`) and both L2's holder and C believe they hold the lock. The old code could delete a fresh lock outright in the two-waiter case. `release` is still check-then-remove (token read, then `os.remove`), as before; not in this pass.
|
||
- M3: the stall measure (`quiet`) still uses the wall clock, so a suspend also sets `stalled` (a flag, no kill). `wall_s` in the result stays wall time, suspend included. A backwards clock jump is not treated as a jump.
|
||
- M3: `kill`/`expire` still do not refresh the orchestrator stamp. That is for the Phase 3 text, as the review says.
|
||
- I1: the text patterns are a heuristic over `result`/`error`/`errors`; the status code wins when present. A result with `is_error: false` and no `api_error_status` is never classified.
|
||
- M9: an empty `ANTHROPIC_BASE_URL` counts as "set, not default" and warns.
|
||
- Commits: `7b9b748` (code and tests), then this note's commit.
|
||
|
||
### Phase 1 fix pass — Athanor check and deferred Minors
|
||
|
||
- Athanor `claude-opus-5-5[1m]`, 2026-10-16 16:28 +0200 (`date`). Fixer: fresh general-purpose agent, `opus` alias, stamped `claude-opus-5-5[1m]`, 258,611 subagent tokens (harness total).
|
||
- Gates re-run by the Athanor: G-unit exit 0, `Ran 86 tests in 67.444s`, OK; G-where exit 0, one JSON object, `ok: true`, `warnings: []`. Leftover processes by command line (`_supervise`, the tests' temp prefix, `smoke1`): none.
|
||
- Diff check of `20b049e..7b9b748` on `tests/test_forks.py`: one removed assertion line, `scrub_unknown(...) == ["ANTHROPIC_BASE_URL", "CLAUDE_CODE_NEW_THING"]`, replaced by `== ["CLAUDE_CODE_NEW_THING"]`; that is M9's specified change. The lock tests were renamed or rebased on the new staleness rule, which is M2's specified change. No other assertion was removed or loosened.
|
||
- Deferred Minors (not fixed in this pass; coordinator's instruction):
|
||
- **M6** (runtime fingerprint ignores `skills`, versionless plugins): drift detection is Phase 2 work (`rebase_due`/`retrain_due` inputs); fix it there with the drift reasons.
|
||
- **M7** (per-run `mcp-empty.json` path in the hash): no cache effect; hash content instead of path when the trial needs cross-run comparison (Phase 5).
|
||
- **M8** (visible-window unit check is tree-scoped, no positive control, fails without a desktop): the smoke used the global detector; widen the unit check in Phase 2's test pass.
|
||
- **M10** (`train` refuses launch-field overrides; plan §4.3 allows them): a contract decision for the Phase 2 plan (`retrain_due` needs an input path); not a Phase 1 defect.
|
||
- **M11** (note detection by existence only): low impact; compare mtime against job start in Phase 2.
|
||
- **M12** (`kill` report shapes `killed:true,status:ok` and `ok:false` with exit 0): Phase 3 text keys on `ok` and `status`.
|
||
- **M13** (Git Bash `/tmp`-style paths land on the cwd's drive): the Athanor always passes drive-form paths; `cygpath` or refusal in Phase 2.
|
||
- **M14** (POSIX kill misses `claude`; no `/proc` on macOS): POSIX is out of the tested scope; SKILL.md says "Windows-tested, POSIX untested" (Phase 3).
|
||
- **M15** (C8 admission is the union of three files; untrusted dirs untested): SKILL.md records "permissive at init, enforced at runtime" (Phase 3); the runtime warning check backstops it.
|
||
- **M16** (two meanings of `turns`; post-exit sweep breadth; `continue` brief without frame): rename the job-file field in Phase 2; the other two are Phase 3 text.
|
||
- Status: fix pass done; re-check of I1–I3 by a fresh reviewer is Igor's call.
|