13 KiB
name, description, allowed-tools
| name | description | allowed-tools |
|---|---|---|
| gremlins | Trains one headless implementer (the base) per build and spawns gremlins from it, one per complex block, by forking the base's session (`claude -p --resume --fork-session`). Owns the command line, the prompt-cache bookkeeping and the usage ledger. Windows-tested; other platforms fail closed until tested (see Platforms). Loaded by the Athanor. | Bash(uv:*) |
!uv run "${CLAUDE_SKILL_DIR}/gremlins.py" where
Gremlins
What and when
The Athanor's build path for complex blocks. One base per build, trained early: a headless claude -p session that reads the code the build touches, writes the trunk notes and changes nothing. One gremlin per complex block: spawn (method fork) forks the base's session, so the gremlin starts from the base's reading at cache-read prices. Mechanical or distant blocks still go to fresh Agent-tool agents with the trunk notes.
Words: continue gives a finished gremlin one more turn (it resumes, it does not fork); rebase forks the base to absorb what changed, and that session becomes a new base; a ping is a one-turn keep-warm call the helper launches by itself.
The helper gremlins.py owns every claude -p command line, the registry, the cache bookkeeping and the usage ledger. You write briefs and read its JSON.
Claude Code only. Codex loads this skill too; it does not apply there.
Platforms
Windows-tested; other platforms fail closed until tested. On Linux, WSL and macOS where reports ok: false with the reason, init refuses, and on an existing state dir train, spawn, continue, rebase and tick refuse too (exit 2, error: "platform not supported"). Then use fresh agents for every block (the 0.2.2 shape). Do not try to work around it.
Setup
- The report injected above is
where's.scriptis the helper's absolute path: use it in every call,uv run "<script>" <command> ….ok: false→ Platforms, or thereason. No report above (the injection failed) → treat it asok: false. - The project needs an allow rule
Bash(uv run:*)(orBash(uv:*)): this skill'sallowed-toolsdid not pre-approve the calls in a subagent (P0.8, untrusted dir). Call plainuv; use$SCRIPTS_UVonly on a host withoutuvon PATH. init --run <run log .md> --model <full id>once per run, with--effortand--agent <project agent>where the plan names them. A successor never re-runsinitto "reset": it runslistand adopts the active base.- An
initre-run changes only the flags given, except that it re-resolves the claude binary through the source it came from, unless it was pinned with--binary(N3). Itswarningssay when the binary changed or could not be re-resolved; existing bases keep their own binary. --warm-margin-smust stay below the TTL (at 1 h, at most 1800 takes effect).--toolsdoes not re-derive the allow rules: pass--allowed-toolstogether with it.
Commands
Every command except where takes --run <run log .md>. stdout is one JSON object (usage --format md: one markdown fragment); a refusal is {"error": …, …}. Exit codes: 0 ok; 2 usage error or refused; 3 wait condition not met at its timeout; 4 wait condition met and a job in it did not end ok.
| Command | Key flags | Returns |
|---|---|---|
where |
[--binary B] |
{script, python, uv, binary, git, scrub, platform, warnings, ok} (+ reason, error and exit 2 when not ok) |
init |
--model, --effort, --agent, --cwd, --binary, --tools, --allowed-tools, --parallel-cap (2), --orphan-after (1200), --pings on|off, --warm-margin-s, --max-minutes (90), rebase thresholds |
{ok, state, registry, created, changed, changed_fields, bases, config, warnings} |
train |
--base B --brief F [--phase N] [--block X] [--supersedes OLD]… |
{job, base, session_id} |
spawn |
--base B --block X --brief F --phase N [--method fork] [--mode sequential|parallel] [--paths P…] [--delta-note PATH]… [--max-minutes M] [--max-budget-usd U] [--force] |
{job, base, method, base_state, warm_until, rebase_due, retrain_due, reasons} |
continue |
--job J --brief F [--max-minutes M] (J is a spawn job) |
{job} |
rebase |
--base B --as NEW --brief F --phase N [--delta-note PATH]… |
{job, base, parent, session_id} (base is NEW) |
wait |
--all or job ids, [--any] [--timeout S] (default 540, max 570) |
{done[], running[], pings[]} |
list |
— | {state, bases[], jobs[], running[], lost_now[]}; base rows carry warmth, trained_at and the due flags |
usage |
[--block X | --phase N] [--format json|md] |
tokens and cost per kind, cold starts, cache-hit ratio, priced total |
expire |
--base B | --all |
{retired, bases, killed[], ok} |
kill |
--job J |
{job, killed, status, ok, snapshot, survivors, tree_kill_rc} |
tick |
— | one keep-warm pass {state, pinged, bases} (wait and the supervisors already tick) |
A first phase, in calls (G = uv run "<script>", R = --run <run log .md>; every wait with Bash timeout: 600000):
G init R --model claude-opus-5-5[1m] --effort high
G train R --base B1 --phase 1 --brief <train brief> → G wait R --all (until exit 0 or 4)
G spawn R --base B1 --block 1.1 --phase 1 --brief <delta brief> --delta-note <notes>…
G wait R --all → read notes/1.1.md and the result
G continue R --job <job> --brief <fix brief> (only on a red gate, once)
G usage R --block 1.1 --format md → the block's run-log row
G usage R --phase 1 --format md; G list R → phase close; nothing running
Next phase: G rebase R --base B1 --as B2 --phase 2 --brief <delta> --delta-note <notes since trained_at>…, wait, then spawn from B2.
The helper treats --phase as optional; always pass it on spawn and rebase (it drives phase_boundary and usage --phase). spawn, continue and rebase refuse (exit 2) while parallel_cap jobs of those kinds run: wait first. spawn and rebase refuse a base that is not active; spawn --force overrides that.
Waiting
- Launches return at once; the job runs under a detached supervisor.
- Call
waitfrom Bash withtimeout: 600000; never omit it. The Bash tool backgrounds a call that passes its timeout instead of killing it (P0.1), and you are left without its output. - Loop
wait --alluntil exit 0 or 4. Exit 3 means "call again", anddonemay already hold finished results, failed ones too (C4). Each result comes back in exactly onedone: readdoneon every call, not only the last. - Pings come back in
pings, never indone, and never set the exit code; a ping cannot be waited on. A ping belongs to its base's latest phase and is counted in that phase'susage. running[]rows (job, block, elapsed_s, last_event_s_ago, messages) countmessages(stream messages so far); results and the ledger countturns(M16). They are different numbers.- A failed detach of the supervisor (
detach_failed) shows up as a failed result in the nextwait, not in the launch return.
Results
Each done entry (also results/<job>.json): job, kind, method, base, block, phase, status, flags, fingerprint, session, parent_session, models_used, tokens{input, output, cache_creation, cache_creation_1h, cache_creation_5m, cache_read}, cost_delta_usd, cache, base_state, wall_s, turns, denials, note, raw, paths, reason, model_requested.
kindtrain|spawn|continue|rebase|ping;methodis"fork"on a spawn,nullon every other kind.statusok|failed|timeout|killed|lost.reason: forfailedone ofmodel_refused, usage_limit, overloaded, prompt_too_long, api_error, is_error, exit_code, no_result, detach_failed, launch_error, supervisor_error; forkilledone ofkill, expire, orphaned; forlostsupervisor_dead;nullotherwise, also fortimeout(over--max-minutes).flags:stalled, needs_attention(permission denials: the allow rules may be short),usage_mismatch, ttl_downgrade, cold_start, clock_jump, ping_missed, orphaned, expired, parent_inactive.cachehit|partial|miss, from the first request against the base's context;base_stateat launchwarm|expiring|cold;fingerprintokordrift:<fields>. A cold base usually showspartial, because the shared system prefix stays warm (C5).noteisnotes/<block>.mdwhen the gremlin wrote it during this job, elsenull.pathsechoes--paths.
What to do per failure reason:
model_refused→ the model id is not admitted (availableModels); the user must change settings. Flag it, hand back.usage_limit→ plan usage is used up. Stop launching, flag it, hand back.overloaded,api_error→ transient.continueonce later, else flag it.prompt_too_long→ the base is too large. Re-train or rebase smaller.is_error,exit_code,no_result→ readraw/<job>.errand the note,continueonce, else flag it.detach_failed,launch_error,supervisor_error→ a helper or host problem (raw/<job>.sup.log). Flag it; do not retry blindly.- Status
timeout→ read the note,continueonce with what is left, else flag it. Statuslost(the supervisor died) → flag it.
continue takes spawn jobs only. A failed train is trained again (a failed base's name is free); a failed rebase leaves the old base active.
stalled means no stream output for stall_after_s (900 s); the helper reports it and does nothing. Together with clock_jump it is a host suspend, not a hung gremlin. While a job runs, the same shows as a large last_event_s_ago: wait again before acting; still quiet after that → kill and flag it.
Rules (flags, not judgment)
rebase_due(reasonsphase_boundary,commits:N,lines:N,delta_tokens:N,binary_drift,runtime_drift:…) →rebase --as <new> --phase <n>before the next spawn. Always pass--phase.rebasecounts towardparallel_cap.retrain_due(defining:…,max_base_tokens:N,rebases:N,binary_missing) →traina new base with--supersedes <old>, else the old base keeps getting pings.binary_missing→ launches refuse; train with the new binary (train --binary, or re-runinit --binaryfirst). A cold base is still spawnable; coldness never triggers a re-train.- Delta: attach handoff notes with
spawn --delta-note PATH(repeatable). A## Changes since basesection in a brief holds this gremlin's delta only, and no line in it may start with#, also not inside fenced code: such a line ends the section (N1). When rebasing, attach every note written since the base'strained_at(shown bylist): a note older than that counts as known to the base whether or not it was given to it (N2). continuesends the brief without a frame: put the commit rule and the note path into it (M16).- Sequential gremlins run one at a time (Minor 13). Parallel only for disjoint blocks, with
--mode parallel --paths …; parallel gremlins never commit, and the Athanor commits each block by the paths its note lists, one at a time. - Nothing running at hand-back:
listshows no running job other than pings (kindping; a ping ends within 300 s and is not a leak, Minor 7). Otherwisekillit and flag it. killandexpirerun long (each kill verifies for up to 10 s;expirekills its jobs one after another): call them from Bash withtimeout: 600000, aswait.kill: key onokandstatus, not onkilledor the exit code (M12).killed: true, status: okmeans the job finished first;ok: falselistssurvivors(exit still 0): flag it.- Orphan watchdog (N1): a running job is killed (
orphaned) afterorphan_after(default 1200 s) without an orchestrator call (wait,spawn,continue,train,rebase,list,usage).killandexpiredo not count (Phase 1 M3), and a gremlin's own calls never do. Keep loopingwaitwhile gremlins run. - Inside a gremlin
SPIRE_GREMLIN_CHILD=1is set, andtrain,spawn,continue,rebase,kill,expire,initandtickrefuse (N3);where,list,usageandwaitwork. - Models: full ids only;
spawntakes the base's model (no--model). Admission is permissive atinitand enforced at runtime (M15): a substituted model endsfailed/model_refused. - After
claudeexits, its leftover descendants are killed, including daemons a commit may have started (gpg-agent,git fsmonitor--daemon); they restart on demand (M16). - A re-train that supersedes a base while a rebase of it runs: the rebase's new base arrives superseded, and its result carries
parent_inactive(N4). Re-train first, or let the rebase finish. - Never resume or fork a headless session by hand; never call
claude -pyourself; neverSendMessageorTaskStopa gremlin (continueandkillcover it).
State
<run log without .md>-gremlins/, next to the run log: registry.json, ledger.jsonl, jobs/, briefs/, notes/, results/, logs/, raw/. Its .gitignore keeps out raw/ and lock files; everything else is committed with the run log at phase close. The Athanor reads notes/, results/ and logs/; raw/<job>.err and raw/<job>.sup.log only when a job failed.