17 KiB
Phase 1 contract digest — spire:forks helper core
Written by: the Athanor, claude-opus-5-5[1m], 2026-10-16 12:20 +0200, at Phase 1 intake.
What this is: the brief both Phase 1 block owners build against. It does not replace the build plan; it pins the points the build plan, the plan, the design and the preflight report leave to the builder, so that blocks 1.1 and 1.2 agree. Where this file and the build plan disagree, tell the Athanor in your handoff note and follow the build plan.
Sources, in order of precedence:
- Build plan
docs/workpad/plans/2026-10-16-02-headless-forks-build.md: §4 (C1–C8, N1–N3, usage columns), §6 Phase 1 table. - Preflight report
docs/workpad/reports/2026-10-16-09-headless-forks-preflight.md: "Defaults Phase 1 must use" table (measured on this host). - Coordinator decisions after Phase 0: in the run log
docs/workpad/reports/2026-10-16-08-headless-forks-run.md(Phase 0 close, last bullet). - Plan
docs/workpad/plans/2026-10-16-01-headless-forks.md: §2 (runtime), §3 decisions 5–8, §4 (contracts), §7 Phase 1. - Design
docs/workpad/designs/2026-10-16-01-headless-forks.md: §3.2–§3.5, §4, §5 (only the threshold defaults and the warmth formula), §6, §7.
Probe code from Phase 0 that you may reuse (read it, copy what helps; it is throwaway and not in git): C:\Users\g4b\AppData\Local\Temp\claude\G--dev-projects-reliquary\42c87f82-5542-46aa-8d01-4166e3f0bcfe\scratchpad\headless-forks-p0\probes\ — p0lib.py (scrub, command line, stream parser, pid + creation-time liveness, job-object query, visible_windows()), p01_launch.py (rung 5 = CIM hidden), p01_sup.py, p01_check.py (tree snapshot, taskkill).
Files
All inside plugins/spire/skills/forks/. Nothing else in the repo changes in Phase 1.
| File | Block | Notes |
|---|---|---|
forks.py |
1.1 skeleton, 1.2 the rest | One file, stdlib only, the exact PEP 723 header of plan §2. LF line endings. |
preamble.md |
1.1 (moved from 1.2: init hashes it) |
Appended system prompt for every base and fork. Content below. |
templates/train.md, templates/fork.md |
1.2 | Prompt frames (design §2). fork.md carries the stale-snapshot sentence (R13). |
tests/test_forks.py |
1.1 starts, 1.2 extends | PEP 723, unittest, run as uv run plugins/spire/skills/forks/tests/test_forks.py. Imports forks.py by path (importlib.util). Stubs and temp dirs are created at test runtime under tempfile; nothing generated is committed. |
Command surface and exit codes (Phase 1)
where, init, train, fork, continue, wait, list, expire, kill, internal _supervise. Phase 2 commands rebase, usage, tick exist in the dispatch and exit 2 with {"error":"not implemented","phase":2} until Phase 2. Every command except where takes --run <run log .md>.
Exit codes: 0 ok · 2 usage error or refused · 3 wait condition not met at timeout · 4 wait condition met and at least one job in it did not end ok (C4).
stdout: exactly one JSON object per call, newline-terminated. All diagnostics on stderr. Errors are JSON on stdout too ({"error": "...", ...}) with exit 2.
Paths
--run,--brief,--cwdaccept relative,G:\…,G:/…and Git Bash/g/…forms (also/c/…). Normalise to an absolute Windows path with backslashes (on POSIX:os.path.abspath). Store and hash only the normalised form.- State dir = the run log path with
.mdremoved plus-forks, as a sibling:reports/2026-10-16-08-x-run.md→reports/2026-10-16-08-x-run-forks/. - State dir contents:
registry.json,registry.lock,ledger.jsonl,.gitignore(raw/and*.lock),mcp-empty.json({"mcpServers":{}}),jobs/<job>.json,briefs/<job>.md,notes/,results/<job>.json,logs/,raw/<job>.jsonl,raw/<job>.err,raw/<job>.env.json(the child env; it is underraw/so it is never committed),raw/<job>.sup.log(supervisor's own stderr). - Job ids
j0001,j0002, … per run.
State files (C3)
registry.json:{"schema": 1, "config": {...}, "bases": {name: {...}}, "jobs": {job: {...index...}}, "run": {"last_orchestrator_call_at": iso, ...}}. Writes only underregistry.lock(create-exclusive; a lock older than 30 s is stale and may be broken), to a temp file thenos.replace, retryingPermissionErrorat least 20 times, 50 ms apart. Readers retry the same way.jobs/<job>.json: per-job runtime state — supervisor pid + creation time, claude pid + creation time,heartbeat_at,turns,last_event_at,statuswhile running. Its only writer is that job's supervisor (exceptkill, which may write the final status if the supervisor is dead). Same atomic write with retries.ledger.jsonl: append-only, appended underregistry.lock.- Times are ISO 8601 with offset; one module function
_now()returns epoch seconds and is the only clock (tests monkeypatch it). - PIDs are always stored with their creation time and compared as a pair (A19). Windows:
OpenProcess+GetProcessTimes; POSIX:/proc/<pid>/statstart time when present, else pid only.
Launch fields (init; design §3.3, plan §4.3)
init --run R [--model ID] [--effort E] [--permission-mode M] [--tools CSV] [--allowed-tools ...] [--disallowed-tools ...] [--agent NAME] [--cwd DIR] [--binary PATH] [--parallel-cap N] [--orphan-after S] [--pings on|off] [--max-minutes N] plus the thresholds below.
model: required full id matching^claude-[a-z0-9-]+(\[1m\])?$; aliases refused (exit 2).effortdefaulthigh;permission_modedefaultdontAsk(planrefused);toolsdefaultBash,Read,Edit,Write,Glob,Grep,Skill;allowed_toolsdefault = tools;disallowed_toolsdefaultBash(git push:*),Bash(git rebase:*),Bash(git reset --hard:*),Bash(claude:*).mcp: always--strict-mcp-config --mcp-config <state>/mcp-empty.json.append_system_prompt: the skill'spreamble.md, stored as path + sha256.agent: optional name; stored with the sha256 of the resolved<cwd>/.claude/agents/<name>.md(refuse if not found).cwd: default the git toplevel of the current directory (git --no-optional-locks rev-parse --show-toplevel), normalised.binary:--binary, else$CLAUDE_CODE_EXECPATH, elseshutil.which("claude"); stored as absolute path +version(<binary> --version, first token) +size+mtime. Launches always use this stored path, never PATH lookup.env.pinned:CLAUDE_CODE_PROMPT_CACHE_TTL=1h,DISABLE_AUTOUPDATER=1.- Fingerprint hash: sha256 of canonical JSON (sorted keys, no spaces) of all launch fields above.
- Config extras:
parallel_cap2,orphan_after1200 s (N1),pingson(P0.6 passed; used in Phase 2),ttl_s3600,max_minutes90,stall_after_s900, thresholdsrebase_commits6,rebase_lines1500,rebase_delta_tokens12000,max_base_tokens250000,max_rebases4. - Idempotent: re-running
initwith identical values succeeds and returns the config. Different values: allowed while no base exists (config replaced), refused with exit 2 once a base exists. - C8 model admission (rule measured in P0.5): read
availableModelsfrom~/.claude/settings.json,<cwd>/.claude/settings.json,<cwd>/.claude/settings.local.json. If no file has the key, admit. Otherwise admit iff some list contains the exact id, or the id without[1m], or the id's family alias (opus,sonnet,haiku,fable, taken from the second dash-separated token of the id). Else exit 2 with a message naming the id and the files that hold lists, and saying the user must add the id. Never write a settings file.
where (G-where)
Prints one JSON object: script (absolute path of forks.py), python (version, executable, base_executable), uv (version from uv --version if found, and SCRIPTS_UV if set), binary (resolved path, version, size, mtime, how resolved), git (resolved path and version), spawn (the constant naming the method: cim-hidden on Windows, setsid on POSIX), scrub (drop, keep, pinned lists, and unknown: host CLAUDE_*/ANTHROPIC_*/CLAUDE_CODE_* vars on neither list), ok (bool). Exit 0 when Python ≥ 3.10 and a binary resolves; otherwise exit 2 with ok: false and the reason. where never writes anything.
Env scrub (one constant; design §4 + plan §2 + P0.0)
- Drop: the twelve Desktop vars (design §4),
CLAUDE_EFFORT,ANTHROPIC_MODEL,ANTHROPIC_DEFAULT_*_MODEL,ANTHROPIC_SMALL_FAST_MODEL,CLAUDE_CODE_SUBAGENT_MODEL,ENABLE_PROMPT_CACHING_1H,FORCE_PROMPT_CACHING_5M,CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL,ANTHROPIC_API_KEY,ANTHROPIC_AUTH_TOKEN,CLAUDE_CODE_USE_BEDROCK,CLAUDE_CODE_USE_VERTEX,VIRTUAL_ENV, everyUV_*; PATH entries underVIRTUAL_ENVor undersys.prefixwhen it differs fromsys.base_prefix. - Keep: everything else, including
CLAUDECODE,CLAUDE_CODE_CHILD_SESSION,AI_AGENT,CLAUDE_PID,CLAUDE_CODE_GIT_BASH_PATH,PATH,HOME/USERPROFILE. - Pinned on top:
CLAUDE_CODE_PROMPT_CACHE_TTL=1h,DISABLE_AUTOUPDATER=1,SPIRE_FORKS_CHILD=1(N3), andCLAUDE_CODE_EXECPATH=<the stored binary path>(coordinator: so the 2.1.283 slip cannot recur). - The env is computed by the launching command (which has the real host env) and written to
raw/<job>.env.json. The supervisor (which gets the registry default env from CIM) reads that file and passes exactly that env toclaude.
Spawning (C6, P0.1, coordinator)
- Windows, default and only Phase 1 method
cim-hidden: the launching command runspowershell -NoProfile -NonInteractive -Commandwith$si = New-CimInstance -ClassName Win32_ProcessStartup -ClientOnly -Property @{ShowWindow=[uint16]0}; $r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{CommandLine=$env:SPIRE_FORKS_CMD; CurrentDirectory=$env:SPIRE_FORKS_CWD; ProcessStartupInformation=$si}; "{0} {1}" -f $r.ReturnValue, $r.ProcessId(command line and cwd passed through env vars, never interpolated). Thepowershellcall itself is started withCREATE_NO_WINDOW. Non-zeroReturnValue→ the job endsfailed, reasonspawn_failed. - Supervisor command line:
<interp> -I <abs forks.py> _supervise --run <run> --job <job>,<interp>=sys._base_executableif it differs fromsys.executable, elsesys.executable. Under-Ithe script dir is not onsys.path; do not rely on it. - The supervisor redirects its own stdout/stderr to
raw/<job>.sup.logat start (it has no inherited handles). - Every process the supervisor starts (
claude) getsCREATE_NO_WINDOW(0x08000000). NeverDETACHED_PROCESSorCREATE_NEW_CONSOLE. - POSIX:
subprocess.Popen(..., start_new_session=True)from the launching command, stdio to files; kill by process group. Implement, keep simple; it is not tested on this host. - No visible window is an acceptance criterion: on Windows, the tests assert that no visible top-level window belongs to the supervisor or any descendant while a stubbed job runs (
EnumWindows+IsWindowVisible+GetWindowThreadProcessId, as inp0lib.visible_windows()).
claude command line (fingerprint every time; flags are not inherited by forks)
<binary> -p --output-format stream-json --verbose --model M --effort E --permission-mode P --tools T --allowedTools A --disallowedTools D --strict-mcp-config --mcp-config <state>/mcp-empty.json --append-system-prompt-file <preamble> [--agent N] plus per kind: train --session-id <new uuid>; fork --resume <base session> --fork-session --session-id <new uuid> (P0.7: honoured); continue --resume <fork session> (no fork flag); ping (Phase 2). Prompt on stdin from briefs/<job>.md. cwd = the base's cwd. List flags are passed comma-joined, as in the probes.
Supervision and results (1.2)
- Stream parse:
system/init(session id, runtime fingerprint: sorted tool names hash,permissionMode, plugin names + versions, agents,apiKeySource); eachassistantevent bumpsturns/last_event_at(count messages bymessage.id); the first assistant event'susagegives the cache verdict;system/informationalwithlevel: warningwhose content containsis restricted→ model refused;resultgives the totals. - Usage rule (P0.3): the four columns and the 1h/5m split from
result.usage;models_used= keys ofresult.modelUsagewhose counts grew against the session total before the call; cost Δ =total_cost_usd− the session total before the call (base cumulative for a fork, the fork's last total forcontinue, 0 fortrain);usage_mismatchflag whenresult.usage≠ themodelUsagedelta. Never sum stream events for output tokens.turns=result.num_turns. - Base context tokens =
input + cache_creation + cache_readof the base's last assistant message (last stream event for that message id). Stored on the base at train end, withbase_commit(git --no-optional-locks rev-parse HEADin the cwd), training cost, cumulativemodelUsage,last_hit. - Cache verdict (C5): first assistant event
cache_readvs the base's context tokens:hit≥ 0.9,partial> 0,miss= 0.trainjobs getcache: null. - Warmth in Phase 1 (C1):
last_hit= time of the first assistant event of the most recent call on this base or any of its forks;warm_until = last_hit + ttl_s;margin = max(120, ttl_s/10);base_statewarm / expiring / cold.rebase_dueandretrain_duearefalse,reasons: []. cold_startflag when the base wascoldat launch, or the verdict is nothitand the fingerprint isok.- Model refused: the warning event, or
models_usednot containing the requested id (compare including[1m]; P0.5: themodelUsagekey carries the suffix) → statusfailed, reasonmodel_refused. - Watchdogs: wall time >
max_minutes→ kill tree,timeout; no stream output forstall_after_s→ flagstalled(not killed); non-emptypermission_denials→ flagneeds_attention;is_erroror non-zero exit →failedwith a reason. - Heartbeat: the supervisor writes
heartbeat_atat least every 30 s.waitandlistmark a running joblostwhen its heartbeat is older than 120 s and its supervisor pid + creation time is dead; a ledger line with whatever was captured. - N1 orphan watchdog:
wait,fork,continue,train,list(and Phase 2'srebase) updaterun.last_orchestrator_call_at. A supervisor whose job is running kills itsclaudetree when that is older thanorphan_after; statuskilled, flagorphaned, ledger line. - N3: launching commands (
train,fork,continue, and Phase 2'srebase/tickpings) exit 2 whenSPIRE_FORKS_CHILDis set.where,list,wait(andusage) stay allowed. - N2:
fork --mode parallelrequires--paths p…; stored on the job and echoed aspathsin the result. Sequential forks may pass it. - Parallel cap: at most
parallel_caprunning jobs of kindfork/continue; a launch over the cap exits 2. - Refusals (exit 2): fork from a base that is not
activewithout--force;continueof a running job;continueof a non-fork job; any launch-field override onfork/continue; missing binary (stored path gone). - Binary re-check at every launch (version, size, mtime against the base record): a difference is reported as fingerprint drift
binaryin the result (Phase 2 turns it intorebase_due). - Fork delta header (prepended by the helper to the brief, inside
templates/fork.md):git --no-optional-locks log --oneline <base_commit>..HEADandgit --no-optional-locks diff --shortstat <base_commit>. - Result object: exactly plan §4.2's fields, plus
paths(N2),reason(null unless failed/killed/lost),model_requested. Written toresults/<job>.json; the ledger line = design §6's shape +status,flags,reason. forklaunch return (C1):{job, base, base_state, warm_until, rebase_due, retrain_due, reasons}.trainreturns{job, base, session_id};continuereturns{job}.wait [job…] | --all [--any] [--timeout S](default 540, max 570): polls about every 2 s; returns{done: [results not returned before], running: [{job, block, elapsed_s, last_event_s_ago, turns}], pings: []}; exit per C4; marks returned results as delivered so each result is returned by at least onewait.listalways shows every job and base.kill --job J(andexpireof a base with running jobs): snapshot the supervisor's whole process tree first (pids + creation times; use a Toolhelp32 snapshot via ctypes rather than PowerShell for speed),taskkill /T /F /PID <supervisor>, then kill any recorded pid still alive (sweep), verify none of the snapshot survives, setkilled. A dead supervisor is handled too (kill the claude tree from the job file).expire --base B | --all: markretired; running jobs of that base are killed with the sweep (coordinator), flagexpired.
preamble.md (content owned by 1.1; short)
A headless implementer trained once and then forked per block; it may be resumed or forked later. Do the work yourself; never dispatch agents and never start claude or the forks helper. Never use run_in_background on Bash (coordinator decision): run every command in the foreground. Stage and commit by explicit paths only, and only when the brief says this fork commits. Write the handoff note to the path the brief names, in the run-log handoff-note format (plugins/spire/skills/implementation/references/run-log.md), stamping the exact model id. Gate and test output go to files under the state dir's logs/ (.txt).
Not in Phase 1
Warmth inference and pings, rebase, rebase_due/retrain_due reasons, usage rendering, tick (Phase 2). SKILL.md, athanor and implementation text (Phase 3). Version bump (Phase 3).