A graph-native multi-agent orchestrator whose node runtime is your own logged-in
claudeorcodexCLI, not a direct model API.
oh-my-graph runs each DAG node as a raw subprocess of the selected local model CLI instead of integrating an Agent SDK or direct API. The run uses the saved Claude or Codex login and its plan allowance; it never redistributes that login as a hosted product. Personal/local, bring-your-own-login.
Go 1.25+. This tool is a subprocess scheduler: errgroup + buffered-channel
semaphore for the concurrency cap; context cancellation propagates to
exec.CommandContext and kills in-flight children on halt-on-fail (awkward in
Python). Single static binary (go install).
Deps: stdlib os/exec+context, golang.org/x/sync/errgroup, gopkg.in/yaml.v3,
stdlib flag (cobra optional/later).
One run uses exactly one runtime, selected by the global
--runtime claude|codex flag before the subcommand. Claude is the default.
The choice is persisted in state.json; resume and browser gate actions load
it, and an explicit mismatch is refused. The scheduler depends only on
NodeRunner; CLIRunner is its one model-process exec seam and delegates only
argv/session/output protocol details.
Provider session ownership is intentionally asymmetric. Claude assigns a UUID
before spawn because its CLI accepts --session-id; Codex publishes the
authoritative thread id in thread.started. The scheduler stores whichever id
the selected protocol reports and uses it for handoff: session.
Codex reports token usage but no USD total. CostUnknown is durable through
the ledger, state.json, events.jsonl, CLI history views and web UI; it must
never render as $0. agent: and the goal-level USD budget are refused at
preflight for Codex; a node's budget_usd is not (ADR 0026) — it loads with a
warning saying the cap cannot apply and naming the guard that still holds, that
node's own timeout: or the runner's default.
A Claude node is one subprocess:
claude -p "<rendered prompt>" --output-format json --permission-mode <mode> \
[ --max-budget-usd <amount> ] \
[ --setting-sources "" ] [ --plugin-dir <dir> ]… [ --agent <name> ] \
[ --allowedTools "<comma,joined>" ] \
[ --tools "<comma,joined>" ] [ --strict-mcp-config ] \
[ --disallowedTools "<comma,joined>" ] \
[ --resume <session_id> ] [ --session-id <uuid> ]
This is emission order, not just a flag inventory: runner.buildArgs appends
in exactly this sequence and claude_test.go's want argv pins it
element-by-element, so a reordering is a test failure, not a style choice.
Note where --max-budget-usd sits — immediately after --permission-mode,
before the ceiling flags, because it is not one of them. Note too where
--plugin-dir sits — after isolation and before the grant, one flag per
ToolPolicy.PluginDirs entry in order, because it restores instruction material
layer 1 withheld before any layer decides what may be done with it. It is what
carries a staged skill corpus (ADR 0017) or a mapped node's staged agent
definition (ADR 0022) into the node, and it is emitted for those nodes only.
The bracketed tool-ceiling flags come from one runner.ToolPolicy per node and
are auto mode's alone (see "Auto mode"); a hand-written graph's policy carries
only AllowedTools, so its argv is the first line plus --allowedTools, then
--resume or --session-id, and --max-budget-usd when the node declared
budget_usd (that flag is not part of the ceiling — it rides on
NodeInvocation.BudgetUSD, which the scheduler passes for every node, planned
or hand-written).
Every fresh-session node gets --session-id with a UUID the
Claude protocol assigned, so the id is published on
node_started while the node is still RUNNING and a live view can find its
transcript; a resuming node gets --resume instead — the two are mutually
exclusive (claude --help, verified 2026-08-02: --session-id <uuid>, "must
be a valid UUID").
run with cwd = node.cwd. JSON envelope → session_id, result, total_cost_usd.
On a failed run the runner also captures WHY as a one-line
NodeOutcome.FailureCause — the envelope's own error report (errors /
is_error), else the stderr tail on a non-zero exit — so the failure detail
downstream (ledger, events.jsonl, watch, serve) names the cause (a
subscription session limit, say) instead of only "exit code 1".
- Subscription auth crux: start from
os.Environ()and DELETEANTHROPIC_API_KEY,ANTHROPIC_AUTH_TOKEN,OPENAI_API_KEYandCODEX_API_KEYfrom the child env (they silently switch to metered API billing). One list, no runtime branch: a Claude node drops the OpenAI switches and a Codex node drops the Anthropic ones, so neither list can fall behind the other. Enforced in code + asserted by a unit test on the built argv/env. NEVER--bare(disables OAuth). NEVER an Agent SDK. - Per-node
context.WithTimeout(default ~20m) so a wedged child can't hang the graph. - Non-JSON/parse failure = node failure (
NodeOutputError), never a silent zero result. - permission modes are a closed, load-validated set — the
claudeCLI's own--permission-modechoices, measured fromclaude --help(claude 2.1.221, 2026-08-05):acceptEdits,auto,bypassPermissions,dontAsk,manual,plan.autois the unattended default the Scheduler substitutes when a node declares none (schedule.DefaultPermissionMode; it wasdontAskbefore ADR 0034);planis read-only;bypassPermissionsis the loud opt-in below. Anything else is a load-timeGraphValidationErrornaming the node and listing the set, like an unknownretry.oncause — the value is passed through verbatim to argv, so an unvalidated typo (dontask) used to kill the node at spawn, mid-run, long after earlier nodes had spent money. The set is oh-my-graph's transcription of a third-party CLI's enum: a mode a futureclaudeadds is refused until this list enumerates it. - The permission model is rules + mode, and the rules come from more places
than the argv. A tool call is matched against every loaded permission rule;
if nothing matches, the call resolves to ask, and the mode decides what
happens next. Under
dontAskan unanswerable ask becomes a deny — the CLI is default-deny. Underauto, our unattended default since ADR 0034, the same call is put to the CLI's own model classifier, which approves or denies it: default-classifier, not default-deny. Neither mode prompts, because a node is a headless subprocess with no TTY, and claude 2.1.241 carries a denial reason for that case rather than code that waits (docs/measurements/0034-what-auto-mode-does-on-disk.md§B). An explicit deny rule is evaluated ahead of the classifier and is unaffected by the mode. The reason--allowedToolsstill cannot bound a node is not the flag: it is that the user's own~/.claude/settings.jsonis loaded as another rule source, and a standingBash(*)grant there matches first.--disallowedToolssubtracts and beats a prior allow, but only at bare-tool-name granularity (Bash); a scoped deny likeBash(*)matches a command literally starting with*and enforces nothing. Measured on claude 2.1.220. - Rule sources are selectable.
--setting-sourceschooses which ofuser/project/localsettings are loaded;--settings(flag settings) and enterprise policy settings are always loaded on top and cannot be dropped.--setting-sources ""therefore yields a child whose only allow rules are the ones on our argv — which is what finally makes a scopedBash(git *)a real ceiling rather than a declaration.--toolsnarrows the built-in tool set itself (a separate axis from the rules), and--strict-mcp-configbounds MCP. oh-my-graph applies these ONLY to coordinator-planned nodes — see "Auto mode" — and applies the first and the last of them only to a planned run that did not type--accept-loaded-user-config(ADR 0032). - Hand-written graphs never carry
--setting-sources,--tools,--strict-mcp-configor--disallowedTools: they are the user's own reviewed artifact and are meant to run under the user's own settings, hooks and MCP.bypassPermissionsopt-in per node only, loud warning at load, never a graph default.
A Codex node uses the same CLIRunner process lifecycle with this protocol:
codex exec --json --color never --skip-git-repo-check --sandbox <mode> \
--config 'approval_policy="never"' [isolation options] \
[resume <thread_id>] <rendered prompt>
plan maps to read-only, bypassPermissions to danger-full-access, and
the remaining permission modes to workspace-write. Planned nodes and the
assessor add --ignore-user-config, --ignore-rules,
project_doc_max_bytes=0, and mcp_servers={} — the assessor always, planned
nodes unless the run typed --accept-loaded-user-config, which omits all four
(ADR 0032; --sandbox and approval_policy="never" are appended outside that
branch and are unaffected). Hand-written nodes and the
planner keep normal Codex config. A turn.completed event supplies the final
token usage; the last completed agent_message is the node result. A
turn.failed event is a failed node even if the CLI process itself exits zero.
--skip-git-repo-check preserves the graph contract that a node cwd may be a
non-git directory; oh-my-graph already chooses that directory explicitly.
The sandbox is a NETWORK boundary as well as a filesystem one: under
workspace-write a node cannot reach the network (measured 2026-08-14,
gh api rate_limit → "error connecting to api.github.com", git ls-remote →
"Could not resolve host"), so under --runtime codex a graph halts at the
FIRST node that publishes. Where that node sits varies, and it decides what
the failure costs — the shipped graphs cover all three shapes:
| where | graphs | what a Codex run does first |
|---|---|---|
| last node | adr-driven-dev (finalize), every user of graphs/fragments/pr-publish.yaml (self-dev, dev-review-pr, backlog-batch ×2) |
all the work, then fails on that node |
| first node | apply-flags (dev applies, commits and pushes; verify is permission_mode: plan and reads only) |
fails immediately, having done nothing |
| several | merge-shepherd — gh in all five model nodes, starting with verify's gh pr view/gh pr diff/git fetch |
fails at node 1, having done nothing |
So "does the work and then fails" is only the last-node case. A graph can also
publish from a node that is not its last (apply-flags), which is why the
disclosure names positions rather than a list of "graphs that publish".
Two remedies exist, both per node rather than per run, and both are the user's
call rather than something oh-my-graph selects: permission_mode: bypassPermissions maps to danger-full-access, which is not a sandbox at all,
so such a node keeps network AND keyring; and Codex's
sandbox_workspace_write.network_access=true lifts the network block, which is
enough for git push/git ls-remote (what pr-publish and adr-driven-dev's
finalize need) but not for gh on a machine where a keyring is available —
gh uses the OS keyring only then, and the sandbox denies it ("no oauth token
found for github.com", measured 2026-08-14 on macOS). Where there is no keyring
(a headless Linux box, say) gh reads ~/.config/gh/hosts.yml, a plain file a
workspace-write sandbox restricts writes and network for but not reads — so
network_access=true probably does fix gh there. That case is unmeasured.
The run prints the limitation before any node spends
(noteCodexRuntimePolicy) and records it in
docs/LIMITATIONS.md.
Edges are inline depends_on (no separate edges list — single source of truth).
Parallelism is emergent: nodes sharing depends_on that don't depend on each
other run concurrently (up to cap). No parallel-group type in v1.
Node schema:
- id: e2e # one safe path element (alphanumerics, '.', '_', '-'; starts alphanumeric) — it becomes the <id>.out artifact filename and a serve URL parameter; same rule as `worktree`, rejected at load
type: claude-run # claude-run | gate (v1.1 — see "Gate nodes and resume")
depends_on: [dev] # fan-in: all must succeed first
prompt: | # may interpolate {{ inputs.<name> }} and {{ artifacts.<id> }}
Run make local PORT=8080 and report PASS or FAIL.
cwd: "{{ inputs.repo }}"
allowed_tools: [Read, "Bash(make *)", "Bash(git *)"]
permission_mode: dontAsk # optional: undeclared is `auto` (schedule.DefaultPermissionMode) — this asks for the stricter one
agent: code-reviewer # optional (v1.1): run as this Claude Code subagent — see "Node-as-subagent"
worktree: lane # optional: run in a managed git worktree shared by every node naming it — see "Worktree isolation"
budget_usd: 0.50 # per-node cost cap: claude aborts mid-run (--max-budget-usd) + post-hoc FAIL (see Execution engine)
timeout: 45m # optional: wall-clock bound on the node's whole run (Go duration; default 20m, no ceiling) — ADR 0007
handoff: artifact # artifact(default) | session
success_check: # see "Success checks" — verify is the only evidence-grounded predicate
exit_zero: true
result_matches: '^[*_`\s]*PASS' # self-report; never sufficient on its own — shape per "Verdict patterns"
verify: { command: "make local PORT=8080", timeout: 5m } # optional (v1.1)
retry: { max: 1, on: [nonzero_exit] } # optional
feedback: { rerun: impl, max: 2 } # optional (ADR 0010): on a judgment failure, re-run the depends_on path from `rerun` back to this node, at most `max` times — see "Execution engine"Instead of an inline body, a node may cite a fragment (use: + with:,
resolved away at load time — see "Fragments" below):
- id: e2e
use: e2e-verify # splice graphs-file-sibling fragments/e2e-verify.yaml
depends_on: [dev]
cwd: "{{ inputs.repo }}"
with: # bindings for the fragment's declared substitution points
checks: run `make local` (build + test + vet).The cited fragment may declare several nodes and the edges among them — a
review/repair round, a QA loop — and the citing site is unchanged. It splices
as <this id>/<the fragment's own id>, so the node above would become
e2e/review, e2e/apply, and depends_on: [e2e] downstream still means
"after it" (ADR 0027, "Fragments" below). Those internal nodes may themselves
carry use:, composing the namespace one hop further (e2e/review/gate),
bounded by a citation chain of 3 (ADR 0029).
Graph file has name, version, inputs: [..], concurrency: N,
on_fail: halt | continue (default halt — the graph's own failure policy;
see "Execution engine" step 4), nodes: [..].
Full worked example (dev→e2e→parallel reviews→pr) ships as graphs/dev-review-pr.yaml;
the two-node bounded review loop ships as graphs/review-loop.yaml.
feedback: keeps the static graph a DAG: the arc lives outside depends_on
and must point strictly backward to a proper depends_on-ancestor
(load-validated, with the loop body side-exit-free, gate-free, disjoint from
other bodies, and max required ≥ 1 — an unbounded loop on a paid runtime is
unrepresentable). Iteration is a runtime phenomenon; full semantics in
ADR 0010 and under "Execution engine" below.
A fragment is a definition file with declared substitution points — a
proven shape written once, upstream, instead of copy-varied across graphs. It
declares either one node's behavior (node:, below) or a whole
subgraph (nodes: + exit:, further below — the loop). The citing site is the
same for both: a node with use: and with:.
# graphs/fragments/e2e-verify.yaml
fragment: e2e-verify # REQUIRED, and must equal the filename (the name a use: resolves)
description: cold-safe e2e gate — session continuation, synchronous checks, verified verdict
substitutions: [checks, verify_command]
node:
type: claude-run
prompt: |
Continue the work — … and {{ with.checks }} …
allowed_tools: [Read, "Bash(make *)", "Bash(go build *)", "Bash(go test *)", "Bash(go vet *)", "Bash(git status*)", "Bash(git diff*)", "Bash(git log*)"]
handoff: session
success_check: { exit_zero: true, result_matches: '^[*_`\s]*PASS[*_`\s]*$', verify: { command: "{{ with.verify_command }}", timeout: 5m } }
retry: { max: 1, on: [nonzero_exit, verify_failed] }fragment: and description: are checked, not decorative. Both are
required: a fragment: disagreeing with the filename is a load error (the
filename is authoritative, so a mismatch is a typo no reader would catch), and
description: is what the disclosure line prints at every run and every
lint, so an empty one costs the reader of a run log the answer to "what is
this node".
Lookup rule — one location, no search path: use: <name> in
/dir/graph.yaml resolves to /dir/fragments/<name>.yaml and nowhere else;
resolution is a pure function of the entry file's path (no cwd dependence,
no shipped/embedded tier in v1). The name must be bare —
^[A-Za-z0-9][A-Za-z0-9._-]*$, refused before any file is opened — so a
separator, a leading dot or a .. cannot walk out of the fragments/
sibling (filepath.Join cleans, so an unconstrained name would). The
operational consequence, which follows from the rule and is the thing authors
actually hit: a graph file stored where no fragments/ sits beside it can
cite no fragment at all — a lane written to /tmp/lane.yaml has no reachable
fragment and never will, and the unresolved-fragment error says so with the two
fixes (author the graph inside a directory that has a fragments/ sibling —
oh-my-graph init <dir> unpacks one at <dir>/graphs/fragments/ — or put a
fragments/ directory, or a symlink to one, beside the graph). Resolution happens on a path-aware load
stage — graph.LoadFile (fail-fast; also returns the entry file's raw
bytes and one FragmentResolution per resolved use:) and its collect-all
counterpart graph.LintLoadFile (every fragment issue plus every structural
issue of the resolved graph, plus advisories on their own channel, plus the
LoadResult itself) — which run, lint and run --dry-run all load
through. Returning the loaded graph from the same read is load-bearing, not
a convenience: lint and run --dry-run also sweep that graph for advisories,
and a path that is not a regular file — lint <(…), /dev/stdin, a FIFO —
answers a second read with nothing, which decodes to an empty graph that
passes every check. graph.LintFile remains as a thin adapter that drops the
LoadResult; a caller that needs both must not read twice. The resolved document
feeds the exact same decode → Validate pipeline as a hand-written graph;
Parse stays fragment-blind, and Validate refuses any node still carrying
use:/with: as a backstop (the coordinator converts that refusal into a
PlanError — the planner may not emit fragments).
Merge rules (raw-YAML splice, judged by key presence, never Go zero
values): id is always the using node's, and required. prompt: alongside
use: is a load error — customization goes through declared
substitution points or it is a different shape. The behavior fields
(allowed_tools, permission_mode, budget_usd, timeout, handoff,
success_check, retry, agent, type) default from the fragment; a key
written in the using node overrides the whole top-level subtree (never a
deep merge). A single-node fragment may not declare wiring — id, depends_on, cwd,
worktree, feedback (load error) — nor a YAML alias or <<: merge key
inside the node: block (load error):
a spliced body has to be walkable in full, or a {{ with.x }} hiding behind
an alias would be neither declaration-checked nor substituted and would reach
the model verbatim. Anchors stay sanctioned everywhere else in a graph file,
and in the using graph a with: value written as *ref resolves normally.
Substitution tokens {{ with.<name> }} reuse the placeholder grammar but
resolve once, at load: typed replacement when the token is the entire scalar
(a bound list stays a list), textual when embedded (scalars only). A bound
value may itself carry {{ inputs.x }}/{{ artifacts.y }} — those survive
resolution and interpolate at run time as always. Unknown with: keys,
unbound declared points, undeclared body tokens, and with: without use:
are load errors — as is a body token that claims the with namespace
without obeying the grammar ({{ with.checks | inline }}, {{ with. }},
{{ With.checks }}): those can never substitute, so without the refusal they
would survive resolution into a paid prompt verbatim. A
declared-but-unreferenced point and a stray {{ with.x }} in a plain node are
advisories.
The multi-node form — a fragment that declares a LOOP (ADR 0027). A
fragment file may declare nodes: (a list, each with its own id) plus a
required exit:, instead of node:. Never both, never neither.
# graphs/fragments/repair-round.yaml
fragment: repair-round
description: one review/repair round — a review that never edits, then a gated apply
substitutions: [review_focus, review_agent, review_timeout, apply_scope, verify_command]
exit: apply
nodes:
- id: review
agent: "{{ with.review_agent }}"
prompt: "{{ with.review_focus }} …"
permission_mode: plan
- id: apply
depends_on: [review]
prompt: "Apply this: {{ artifacts.review | inline }} …"
success_check: { verify: { command: "{{ with.verify_command }}", timeout: 5m } }The invariant is one sentence covering both forms: a fragment may never name
an id it does not itself declare. A single-node fragment declares none, so
id/depends_on/feedback stay load errors for it; a multi-node one declares
its own, so edges among those are legal and naming anything else — in
depends_on, in feedback.rerun, or in an {{ artifacts.<id> }} token — is a
load error charged to the fragment file. cwd/worktree stay refused in both:
they are the using node's location, not wiring among declared ids.
Resolution, in the order it happens:
- Spliced ids are
<using-id>/<internal-id>—round1/review,round1/apply./cannot appear in anything anyone writes: in an entry graph the loader refuses it in a nodeid, adepends_on, afeedback.rerunand in any{{ artifacts.<id> }}/{{ feedback.<id> }}token in any scalar (a binding included — the token is the spelling that would otherwise read a loop's internal output from outside, with every other check satisfied); a multi-node fragment file is held to the same rule by its declared-ids invariant, and a single-node one — whose tokens name the citing graph and so are checked against no such set — by a namespaced-token refusal of its own;coordinator.validatePlannedNodeIDrefuses it in a planner reply. So a spliced id can never collide with an authored one.Validateaccepts the joined form as a backstop — it cannot tell a spliced graph from a resumed snapshot, and must not learn. - A multi-node
use:id may not collide with another node's id. The splice replaces the using node, so post-splice uniqueness would see only distinct ids while every downstreamdepends_on: [qa]and{{ artifacts.qa }}was rewritten to the loop's exit — past a node literally namedqa. - Each internal node is namespaced before substitution: its id, its
depends_on, itsfeedback.rerunand every{{ artifacts.<id> }}/{{ feedback.<id> }}token it wrote. A value bound at the using site is inserted afterwards and is never rewritten — it belongs to the citing graph's namespace, and a bound artifact id that names no node there is a load error rather than a run-time surprise. - Entry nodes (no internal parent) inherit the using node's
depends_on;cwd/worktreeon the using node propagate to every spliced node. Entry-hood is decided by the key's presence, so an emptydepends_on: []inside a fragment node is a load error rather than a silent opt-out that would start the node at the top of the citing graph. - From outside, the loop is one thing whose value is its exit's: both
depends_on: [round1]and{{ artifacts.round1 }}resolve toround1/<exit>.feedback: { rerun: round1 }does not — it is a load error, because rewriting it to the exit would silently re-run one node for an author who asked to re-run a loop. exit:is required and never inferred from the unique sink: inference is right only while there is exactly one sink, and when it is wrong it is wrong silently. It may not lie strictly inside one of the fragment's own feedback bodies, so no citing graph's downstream edge can manufacture a side exit in a fragment whose author wrote nothing wrong.- A multi-node
use:may declare wiring only (id,depends_on,cwd,worktree,with): a behavior key on it is a load error naming the key, since there is no coherent way to overlay one node'ssuccess_checkonto five. A loop needing different behavior needs a substitution point or a different fragment.
A fragment may cite a fragment (ADR 0029). A node inside a fragment file
may carry use:/with:, judged by exactly the rules a top-level one is —
graphs/fragments/gated-lane.yaml is the shipped instance, and
backlog-batch's lane A is one node citing it. Resolution is recursive descent
carrying a chain: the ordered fragment names from the entry graph down to
the file being spliced.
- The order at each level is namespace → substitute → descend, and it is
what makes parameter pass-through work rather than something added to it:
an inner
use:'swith:values are ordinary text in the outer fragment's file, so the outer bindings are already substituted into them before the inneruse:is read, and the level below rewrites only its own file's text. A bound value is never id-rewritten by any level, at any depth. - A cycle is a repeat on the CURRENT chain — a load error naming the cycle
in order, charged to the file whose
use:line closes it. Chain membership, not a global visited set: a diamond (two loops citing one leaf, or one loop citing a leaf twice) is the normal case and stays legal. - The chain is bounded at 3 citation HOPS (
maxFragmentChain), checked before the cited file is read, so a runaway is a message and never a hang or a stack overflow. Hops are not id segments: three multi-node hops mint a four-segment id, three single-node hops mint none, and an alias hop spends the budget anyway because what is bounded is how far the loader walks. It does not bound the resolved graph's size — three hops of five-node fragments is 125 nodes from oneuse:line, and that cost lands on the reader of--dry-runand of the goldens. - Namespacing composes left-to-right by the same join:
top+core→top/core, then+ make→top/core/make. Decomposition stays unique because an atom cannot contain the delimiter, which is ADR 0027's property consumed at N joins rather than re-decided; the on-disk spelling is injective for the same reason (handoff.SanitizeNodeID,a/b/c → a~b~c.out). There is still no bound on an id's length. exit:is transitive. If a fragment's exit names an internal node that is itself a loop, the effective exit is that loop's exit, resolved recursively — so a loop still exposes exactly one value from outside at any depth.depends_oninheritance chains the same way, one level at a time.- A nested
use:name must be a literal.use: "{{ with.which }}"is a load error: the chain, the cycle check and the bound are all decided before the cited file is read, so a citation whose target came from a binding would make the citation graph depend on data. - Lookup stays a pure function of the ENTRY file's path at every depth, so a
fragment that cites a fragment depends on a file its own author cannot ship
with it. There is no manifest and no pre-flight completeness check; what is
owed instead is in the message — an error below depth 1 names the chain, so
a reader who never wrote
e2e-verifyis told which fragment did. - A single-node fragment may not cite a multi-node one: its body is spliced
onto the citing node and declares no id, so there is no namespace to mint
<id>/<internal>in. Citing another single-node fragment is an alias and is fine — except that an alias may not write its ownprompt:: it RELAYS the cited fragment's behavior, and one that rewrites the prompt is claiming that fragment's name while replacing what it does, which is the same drift the citing-site rule refuses one file over. A single-node body cited from inside a fragment has its tokens namespaced against the citing fragment's declared ids, and a token naming an id that fragment does not declare is a load error charged to the citing site. - A fragment file's own
use:is judged against the FILE. The literal-name rule, theprompt:-alongside-use:refusal above and a deadwith:(a binding with nouse:to bind) are all facts about the file, decidable with no citing site in hand, so all three are reported once, against it. Left to splice time the same defect arrives charged to the citing node — about text in a file that node's author may never have opened. - A node that cites a multi-node fragment still cannot carry a
feedback:arc — wiring only, at every level — and that, not the depth bound, is the real ceiling on what nesting folds. An author who needs a gated nested loop moves the gate inside the nested fragment, where it is a key on an ordinary internal node. - One disclosure line per resolution, and a nested resolution is a
resolution. A nested line's
NodeIDis the already-namespaced id of the node that cited the fragment, so the ids alone say the shape of the tree; the parent's line is printed before the descent's.Splicednames only ids that exist in the resolved graph, so a parent line deliberately undercounts a subtree containing a nested loop — the lines below it answer "how big did this get".Depthcarries the chain length, stated rather than inferred: a resolution's slash count is a different quantity, because a single-node hop mints no segment, so an alias chain two files deep is a nested resolution whose id has no slash at all.
Non-goals, refused rather than deferred quietly: rerun: over a whole loop,
loop-until-dry convergence (max: N stays the only one), dynamic fan-out over a
runtime-sized collection, a planner emitting use:, and per-node overrides on a
multi-node use: at any depth.
Downstream of the loader no fragment concept exists: run, lint and
run --dry-run all print one disclosure line per resolved fragment (source
file + the fragment's own description + every overridden key, or — for a
multi-node splice, which overrides nothing — the ids it spliced; plus, when a
fragment parameterized its own allowed_tools and the citing node bound it,
the resolved grant of each spliced node substitution touched. A grant
written verbatim in the fragment adds no clause: one line per difference and
no more — ADR 0013's #196 amendment) plus the same fragment advisories on
the warning channel (run discloses what it spliced, so it discloses the
drift smell too; the six handoff sweeps stay lint-only), the snapshot stores the re-encoded
resolved graph whenever any node resolved a fragment (so resume never
re-reads a fragment; GraphSHA256 still hashes the entry file's bytes), and
scheduler, handoff, the event stream and every consumer reading it see
exactly the graphs they see today.
Shipped shapes live in graphs/fragments/ and ship inside the binary
alongside the templates (//go:embed *.yaml fragments/*.yaml), so
oh-my-graph init unpacks a tree whose use: nodes resolve — and re-running
it tops that tree up with payload files it does not have yet (a fragment added
by a later release), keeping every file already there;
internal/graph/testdata/golden/ holds the resolved goldens — one per
fragment-citing template (self-dev, dev-review-pr, backlog-batch,
adr-driven-dev) — that turn any fragment edit into a reviewed multi-template
diff. A multi-node fragment multiplies that blast radius by its node count, on
purpose: one edit to repair-round moves four nodes in adr-driven-dev's
golden, and the reviewer sees all four. Nesting adds a hop to that radius: an
edit to e2e-verify now moves a node in backlog-batch, which never names it —
it cites gated-lane, which does. The mitigation is the same goldens, and it
gets harder rather than easier, because the reviewer's diff is two files away
from the file that changed.
- artifact (default): engine persists each node's
.resultto~/.oh-my-graph/runs/<run-id>/<node-id>.out; dependents read via{{ artifacts.<id> }}(substitute file path by default;| inlinefilter to inline content). Robust, inspectable, parallel-safe, one clean session per node. Use for fan-in / reviews (many→one conclusions). - session (
handoff: session): dependent runs--resume <session_id>of its single session-parent (same cwd/git scope). Use for tight sequential continuation (dev→e2e). Validation: a session node must have EXACTLY ONE parent — a root node has no session to resume, and a fan-in can't merge sessions; both must use artifact. Rejected at load time. A gate parent is likewise rejected at load (a gate records no session to resume), andlint/run --dry-runwarn when a session child's cwd/worktree differs from its parent's. - feedback (
{{ feedback.<id> }}, ADR 0010): a third namespace beside inputs and artifacts, resolving to the feedback declarer<id>'s latest payload — its result text when the failing execution produced one, else its failure detail. It always inlines (no path form, no| inlinefilter) and resolves to the empty string until a round has fired, the one place "not there yet" is an expected state rather than a wiring bug; prompts write around it ("review feedback follows — empty on the first pass"). A token outside the body of<id>'s feedback edge is a load error, not a lint — it would otherwise be silently empty forever. The engine persists the payload to<run-dir>/feedback/<id>.out(overwritten per round, latest wins) so a mid-loop resume can re-seed it — an internal file, not a consumer contract, in its own directory so it can never collide with an artifact (node ids allow dots, so a node namedx.feedbackis legal); the.outartifact keeps meaning "a passed node's result". The arc and the token are two halves of one mechanism, solint/run --dry-runwarn when a loop declares the first and never writes the second (handoff.LintFeedbackQuoting, ADR 0028): if nothing in the body except the declarer itself quotes{{ feedback.<declarer> }}in its prompt, the re-run is handed the prompt it already ran, produces the same output, and the declarer fails again for the same reason — twice the money, the same result, and nothing else in the engine has anything to say about it. The warning lands on the rerun target, because that is where the missing line goes. Advisory, not a load error: a loop whose re-run reads the repository rather than the reply is a legitimate, if rare, shape. For planner output it is a plan refusal instead (coordinator.validatePlannedFeedbackQuoting— the same advisory-here/refusal-there splitLintFeedbackReachhas): the planner is asked for the arc and the quote in one prompt sentence, nobody runslinton a graphautoplanned and ran in the same breath, and the correction — one placeholder, empty on the first pass — costs nothing even when the refusal is wrong. Note the reach of any of these sweeps: they are printed bylintandrun --dry-runonly. A plainrunloads the graph and starts spending without them, which is why a defect this sweep can see is still paid for by an operator who did not lint first.
A node may set agent: <name> to run as one of the user's OWN Claude Code
subagents rather than as plain claude -p: the review node runs as your
code-reviewer, with its system prompt, its tools and its model. The mechanism
is one flag — NodeInvocation.Agent becomes --agent <name>, which claude
resolves against the existing ~/.claude/agents and <cwd>/.claude/agents.
There is no oh-my-graph-side agent registry, and there must never be one: the
user's definitions are the single source of truth.
Load-time validation rejects only surrounding whitespace — both the
whitespace-only agent: " " and the padded agent: " reviewer ", which would
otherwise be handed to claude verbatim and fail to resolve for a reason the
YAML does not show (validateAgentNames). Nothing else about the name is
checked: whether it resolves depends on the machine and the checkout, not on
the graph file, so a graph valid on one machine would otherwise be invalid on
another.
An unresolvable name is a node FAILURE, not a fallback. An earlier draft of
this section claimed a missing agent degrades to plain claude. That was measured
and is false: claude -p --agent <unknown> writes
--agent 'x' not found. Available agents: … to stderr, exits 1, and
prints nothing at all on stdout — so there is no envelope, and the node fails
as a *NodeOutputError. The design keeps that failure rather than implementing
the fallback, for two reasons. Detecting it would mean string-matching a CLI
error message, which is version-coupled in exactly the way ADR 0001 warns about;
and a node the graph asked to run as your reviewer, silently running as generic
claude instead, is a different node producing a plausible-looking review. A
loud failure is the smaller harm. What the runner does add is the CLI's own
stderr to the error (NodeOutputError.Stderr), because that message names every
agent that IS available — turning a dead end into a fix.
No longer mutually exclusive with the auto-mode tool ceiling's Layer 1
(ADR 0022, 2026-08-12). --setting-sources "" still disables discovery of the
user's agent definitions (E6's neighbour, E2, re-confirmed at CLI 2.1.228), so a
bare --agent <name> cannot resolve under it. A raw plan still rejects agent:
outright; the one path that puts the field on a planned node — coordinator
auto-mapping, below — no longer pays for it by dropping Layer 1. It stages the
matched definition into the run's own directory and supplies it with
--plugin-dir, which reaches the node without reopening --setting-sources, so
a mapped node keeps every ceiling layer. Until 2026-08-12 it dropped Layer 1
and was measured to lose its declared scope with it. The plan printout says what
each mapping costs before anything runs. Layer 1 still cannot be extended to
hand-written graphs without dropping agent: with it: nothing stages a
hand-written graph's definition.
Tool reconciliation: a claim only where there is a measurement. For a
hand-written graph, oh-my-graph does not parse the subagent's frontmatter and
does not reconcile its tools: with the node's allowed_tools — that path
never passes --tools, so E6's result does not cover it and no claim is made;
both files are the user's own artifacts, so it is a usability question there.
A coordinator-MAPPED node is different: it runs --agent plus the full
--tools/--allowedTools ceiling — exactly E6's measured configuration, where
frontmatter tools did not widen past --tools — and on top of that the
coordinator refuses to map any agent whose frontmatter declares a tool outside
the node's own planned allowed_tools (the skip and its reason are printed).
Coordinator auto-mapping (auto and chat graph turns). After a plan
validates — never before, and never by the planner LLM, which keeps getting its
agent: rejected — trusted code scans ~/.claude/agents — the user's own
directory only, never the repository's, since a scanned definition is now
copied into the node's --agent (ADR 0022 §3.7, measurement (l)) — and maps
planned nodes onto the
user's own agents by a deliberately conservative name-token rule: exact token
or ≥4-rune prefix between node id and agent name, exactly one candidate or
nothing (ambiguity is silence, not a guess; no fuzzy scoring, no description
matching). Scan failures are silent so zero-config stays zero-config;
--no-agent-mapping turns the whole thing off, --accept-loaded-user-config
turns it off too (the staging guarantee rests on Layer 1 — "The operator's
opt-in"), and --no-agent <name> declines
one agent while the rest still map; every decision made — including a decline —
is shown in the printed plan before execution, with what each mapped node gave
up named on its own line. ADR 0004 §4 originally deferred this
pending E6 and a tool bound — both now hold for the mapped configuration (see
the previous paragraph); its third condition, an explicit opt-in flag, was
traded for the printed-disclosure-plus-opt-out above, accepting that an auto
run's behaviour may now depend on agent files the user forgot they had —
visibly, in the plan printout, not silently at run time.
By default every node runs in the tree oh-my-graph was invoked from (or its
cwd), which is fine for read-only fan-out but broken for parallel EDITS:
lanes serialize on one checkout, a node's commit can sweep in the user's own
untracked files, and a node can switch branches under the user's feet (the
auto-branch bug). worktree: <name> is the root fix:
- The engine creates the worktree ONCE per unique name per run —
git worktree add ~/.oh-my-graph/runs/<run-id>/worktrees/<name> -b omg/<run-id>/<name> HEAD— off the invocation repo's HEAD. The managed path lives under the run directory, NEVER inside the user's checked-out tree. - ALL nodes sharing a name run in the SAME worktree (a lane's
dev → e2e → review → pr shares one isolated checkout); nodes with
DIFFERENT names get DIFFERENT worktrees and edit in parallel with no
shared-tree race. A node's
success_check.verifyinherits the worktree as its default cwd, so evidence is gathered where the work happened. - A node with NO worktree field keeps today's exact behaviour — fully
backward compatible.
worktreeandcwdare mutually exclusive, and the name must be a single safe path element (it becomes both a directory and a branch segment); both are load errors (validateWorktrees). - Cleanup at run end (
cmd/oh-my-graph, afterScheduler.Run, on a fresh context so a halted run still cleans up) never loses work: a worktree holding uncommitted changes is left in place entirely (git refuses to remove it; forcing would discard the changes), a branch carrying commits beyond its base is retained after its worktree dir is removed, and only a branch provably still at its base is deleted. Every retention is reported as a one-line note. A retained branch is a resume contract, not a dead end:Acquireis disk-aware, so aresumed leg (--retry-failedincluded) re-declaring the name reuses the managed worktree dir when it still exists (validated as a worktree of the invocation repo), else re-attaches the retained branch (git worktree add <path> <branch>, no-b) so the lane continues its committed state, and only creates fresh with-bwhen neither survives. A lane adopted either way is never judged empty at cleanup — its branch is always retained. What still fails loudly is a foreign directory squatting on the managed path: adopting an unknown checkout could mix or reset work that is not the run's own, which is exactly what ADR 0005's work-preservation rule forbids. - Handoff artifacts still persist to
~/.oh-my-graph/runs/<run-id>/exactly as before — the worktree isolates the node's WORKING TREE, not its result. - Auto-planned nodes may not set
worktree. Provisioning is not a tool call, so no permission mode or ceiling layer ever sees it;validatePlannedNodesrejects the field likecwd.
Who runs git — a third exec seam, not the NodeRunner. Provisioning is
neither a claude invocation nor an evidence command, so it gets its own seam
in internal/worktree: a Provider interface (Acquire(ctx, name) (path, error), idempotent per name), with GitManager (prod — the third of the
program's exactly four process-spawning objects (ADR 0006 added the fourth),
env-scrubbed via
internal/childenv because git worktree add fires the repo's own hooks),
RefusingProvider (the schedule.Options.Worktrees default: a forgotten
injection fails loudly) and FakeManager (tests — the scheduler's worktree
path stays spawn-free in CI). Cleanup is deliberately not on the interface:
the Scheduler only asks where a node runs; teardown is the CLI's job against
the concrete GitManager. See ADR 0005.
Scheduler = Kahn on depends_on, but maintains a ready set run concurrently:
- Validate on load: all depends_on ids exist; no cycles (DFS colour); fail fast
with
GraphValidationErrornaming the node. - in-degree per node; seed ready set with in-degree 0.
- Launch every ready node as a goroutine under
errgroup, gated by a buffered semaphore (size = min(graph.concurrency, globalCap=10), default 4). On each SUCCESS, decrement dependents' in-degree; newly-0 join ready set. - Node failure → retry policy; still failed → halt (default): cancel shared
context → kills in-flight children → exit non-zero naming the failing node.
A sibling the halt cancelled is recorded with the causal story —
cancelled: run halted after node "X" failed— not the raw Go "context canceled" its cancellation surfaces as.--continue-on-fail(opt-in) prunes only the failed subtree. A graph can opt in itself with graph-levelon_fail: continue(defaulthalt; any other value is a load-timeGraphValidationErrornaming the valid set, like an unknown retry cause) — what a batch of independent lanes declares so one lane's failure can't cancel every other lane's in-flight work, without the operator remembering the flag. Precedence is an OR, resolved in the scheduler (effectiveContinueOnFail, next toeffectiveConcurrency) sorun/auto/resumeall share it: either the flag or the graph saying continue means continue; the flag cannot force a halt onto a graph that declared continue, nor the graph cancel a flag the operator passed. - Done when ready+running are empty.
One outcome is exempt from step 4 entirely: a subprocess killed by the
subscription's session limit is a pause, not a node failure (ADR 0009).
The runner classifies it (NodeOutcome.SessionLimited); the scheduler then
stops launching, drains in-flight siblings instead of cancelling them, records
the limited node nowhere, and returns *LimitPausedError → exit code 2 with a
resume --retry-failed hint. Full semantics under "Gate nodes and resume"
below — the limit rides the same pause/drain machinery a gate does. Claude only:
under --runtime codex nothing sets SessionLimited, so the same situation is
an ordinary node failure — settled: the pause is a promise of the Claude
runtime, not of the engine, so no runtime owes a session-limit signal
(ADR 0009 "Scope", closing #171).
Feedback edges (ADR 0010) — the fourth intercepted signal. A node may
declare feedback: { rerun: <ancestor>, max: N }: when it fails for a
judgment cause (verify_failed, result_mismatch, nonzero_exit — the
fixed built-in trigger set; infrastructure and spend faults such as an
interpolation error or budget_exceeded fail final immediately), after its
own retries are spent, and rounds remain, the arc fires instead of
failing: the scheduler persists the declarer's output as the feedback
payload, writes the non-final trio (a ledger row pricing the failing
execution, a non-terminal marker snapshot record — round k, no verdict —
and node_retried on the stream; never node_failed, which stays terminal),
then re-arms the loop body — every node on any depends_on path from
rerun up to and including the declarer — by re-seeding in-body in-degrees
(out-of-body parents ran once and stay satisfied) and relaunching the target.
The signal rides the same interception seam as pauseSignal, limitSignal
and rejectSignal: a pause elsewhere suppresses the relaunch exactly as it
suppresses dependents, and the durable marker means a later resume re-enters
the loop at round k (body records below the marker's round are superseded
and re-run; max − k rounds remain). Re-runs start fresh sessions (the
retry cold-start rule) and share the lane's worktree within a leg. When the
rounds are spent the next judgment failure is final — the FAIL's detail
reads feedback exhausted after N rounds of <target> → <declarer>: <cause>
— and the existing story runs unchanged: on_fail halts or prunes, and
resume --retry-failed salvages by re-arming the loop (clearing the
declarer's FAIL also clears its body's retained PASSes and resets the rounds
budget — never by re-running the declarer alone against unchanged
artifacts). Worst case is legible from the file: (1 + max) × |body| runs
per arc — rounds, not attempts, so a body node that declares retry is
charged on top of every round it runs in — each under its own
timeout/budget/tool ceiling; the ledger prices
every execution with a feedback round k/N note.
A review gates only when its caller pairs a narrowed check with an arc
(issue #151). The shipped review fragments pass on both their verdicts
(CLEAN and FINDINGS:) — judging, not being clean, is the job — and nothing
else in the engine reads a verdict: depends_on is a success edge, and
retry, feedback and on_fail all hang off failure. So a passing
FINDINGS: gates nothing and every node below the review runs anyway, a pr
node included. Stopping on findings is therefore one shape, and it is a
pair: the using
node narrows success_check.result_matches to the clean verdict and
declares feedback: { rerun: <implementing node>, max: N }. The narrowing
alone would make the run where the reviewer did its job the run that reports
FAIL with nothing repaired; the arc turns that same failure into a repair
round carrying the findings in {{ feedback.<id> }}, so only an exhausted loop
is final. Both keys belong to the calling graph — ADR 0013 forbids a fragment
from declaring feedback, so a fragment cannot gate on its caller's behalf —
and internal/graph's TestAGatingReviewCarriesItsRecoveryArc holds the
shipped graphs to the pair by matching a real FINDINGS: reply against each
review node's effective pattern rather than reading how it was spelled.
backlog-batch's lane A gates (rerun: dev-a, max: 1, body of 3, so 6 body
runs over its 2 rounds — and 8 executions worst case, because e2e-a inherits
retry: { max: 1 } from e2e-verify and a retry is charged on top of its
round: 2 dev-a, 4 e2e-a, 2 review-a); lane B, dev-review-pr and
self-dev stay advisory by recorded
choice — the last two also because their parallel review fan-out cannot hold
an arc at all without tripping rule 3's side-exit refusal, each review's
sibling sitting outside any body that contains e2e. Repairing findings has
a second shape that is not this one and does not stop anything:
adr-driven-dev's unconditional apply node after each review round, which
fixes what the round found without a verdict ever failing — it costs a node on
every clean run, where an arc costs nothing unless the review fails.
A fan-in reviewer's arc reaches one branch — lint says which
(graph.LintFeedbackReach, advisory). When the declarer fans in from
several producers, rerun still names one node, so the body may exclude a
producer whose artifact the declarer judges: the loop then re-judges an
unchanged file every round and halts with the defect untouched (issue #118 —
five defects, all in the excluded branch, ~$14 of re-running the healthy
one). lint and run --dry-run warn for each depends_on producer outside
the body, naming the declarer, the rerun target, the unreachable producer and
— when one exists and still validates — the covering target to aim at
instead. Two parents are skipped: a gate, which rule 4 forbids a body from
ever containing, and a parent that is an ancestor of the rerun target,
which is rule 3's carve-out in topology — it sits upstream of the whole loop,
its output already flows into the body, and the loop re-runs its consumers.
That second skip is what keeps spec → impl → review with rerun: impl
quiet: warning there would advise rerun: spec, which re-runs the acceptance
criteria every round and re-judges the implementation against criteria that
just moved. #118's producer is a sibling of the target, on no path to it,
and still warns.
It stays an advisory, never an eighth load rule. The skips narrow the
sweep to the shapes rules 3 and 4 do not bless, but neither is a proof of
intent: a sibling corpus root is topologically identical to #118's sibling
work, so a legitimate graph can still be warned about, and refusing it would
break working hand-written graphs to catch a planner's mistake. What the sweep
sees is depends_on; which files a prompt actually judges it cannot see —
issue #118's reviewer named its two artifacts by literal path, not through
{{ artifacts.<id> }} — and the printed advisory says so itself rather than
asserting the defect as fact. A producer left outside the body that asks for
the payload with {{ feedback.<id> }} is already a load error, not an advisory
(the placeholder rule above).
Auto mode refuses what lint only warns about. The same sweep is a
refusal for planner output (coordinator.validatePlannedFeedbackReach), and
the planner prompt carries the rule so the ordinary plan never draws it: when a
reviewing node depends on more than one producer, rerun must name a node the
loop reaches all of them from — normally their nearest common ancestor — and a
parent that is only stable context belongs upstream of the rerun target, not
beside it. Neither reason for the advisory's restraint survives the change of
author: unreviewed output has no author to weigh a warning, and a refused plan
is not a lost run — the reply already faces the whole field-disposition ceiling,
and a refusal buys one corrected re-plan (repair.go) carrying the validator's
own sentence. The rule is deliberately narrower than "every producer must be in
the body": it fires only when the sweep found a covering target, so the
refusal always arrives with the edit that fixes it, and the two-independent-roots
shape — where the corpus-versus-work indistinguishability actually bites — is
never refused, because no aiming of the arc could repair it. The topology is
computed once, in graph.LintFeedbackReach; the coordinator reads its
advisories and decides only what to do with them.
retry: flat re-run up to max on causes in retry.on, fresh session (never
resume a failed one). For a handoff: session node this means a retried
attempt does not resume the parent session either — it starts cold, which
lint warns about up front and the passing attempt's ledger detail states.
A retry is no longer a byte-identical re-spawn, though: when the attempt it
repeats was judged — a failed success_check, or a verification that ran
and said no — the retried attempt's prompt carries that attempt's own reply,
quoted as nonce-fenced data (ADR 0020, internal/schedule/retryfeedback.go).
Exactly one prior attempt is ever carried and it never accumulates: every
attempt's prompt is rebuilt from the interpolated node prompt, so the added
cost is flat in the attempt index rather than triangular, bounded at 8000 bytes
of reply, cut head-and-tail with the cut announced. The check itself is not
quoted — not its expression, not the detail that embeds it — because feeding
back a result_matches regex teaches the cheapest possible pass, which is to
print whatever it matches; the node is told its attempt did not pass, told not
to argue the verdict, and pointed back at its own instructions. Causes that
rendered no verdict on the reply carry nothing: a spawn error, an interpolation
error, budget_exceeded, and a verification that could not be completed, the
same isJudgmentFailure split a feedback arc uses. A handoff: session retry
still starts cold — unchanged — and the quote says so out loud, because the
text it hands back is the node's own words out of a conversation it can no
longer open. Across processes, resume --retry-failed re-reads the reply from
<run-dir>/failed/<node-id>.out for each node it clears, gated on the
snapshot's judged flag: the FAIL record (and with it the ledger row and the
capped detail) is dropped by the retry leg, so that file is the only account of
the attempt that survives the boundary. A seeded execution is a retry like any
other and starts cold on the same terms — a handoff: session node does not
resume its parent there either, and the row that prices it says so — and the
failed/ file it was seeded from is removed once that execution passes, so the
directory never holds a losing reply beside a winning artifact. The causes are a closed set — nonzero_exit,
run_error, timeout, output_error, budget_exceeded, verify_failed,
result_mismatch (the graph.Cause* constants) — and an unknown cause is a
load-time GraphValidationError: it would match no failure the scheduler ever
produces and silently mean "never retry". A negative max is refused at
load for the same reason: the scheduler adds max to the attempt count only
when it is positive, so max: -1 is discarded and the node runs once — the
identical quiet non-retry, from a value no author can have meant. max: 0 is
legal and untouched: it IS the extra-attempt count a node declaring no retry
already has.
timeout is the newest of the seven and is not part of run_error
(ADR 0024). A node killed by its own bound — the timeout: it declared, or the
runner's 20-minute default — used to be classified as a run error alongside a
spawn that never started, so one token covered two failures that want opposite
policies: a failed spawn is worth an immediate cheap re-attempt, while a timeout
is the one failure that always burns its whole budget before dying, so
retrying it costs another full bound. The engine does not decide which of those
you have, because it cannot: a timed-out node is either a slow machine or an
instruction that cannot finish at any timeout, and nothing in the error tells
them apart. It gives the token; retry: { max: 1, on: [timeout] } is the
author's opt-in, and a graph that says nothing retries nothing, exactly as
before. The boundary is whose clock ran out: only a deadline the runner minted
for THIS node earns the token (a *runner.NodeTimeoutError), while a deadline
inherited from the run's own context stays run_error — retrying inside an
already-expired context would burn every attempt against a deadline that has
passed. A graph that listed run_error and wanted timeouts covered writes
on: [run_error, timeout]; that token narrowed, and it is the one thing here
that is not purely additive. The other clock is not this one: a
success_check.verify command that times out arrives through the verify seam
and is verify_failed, not timeout — splitting it too is deliberately not
decided (ADR 0024 §5).
budget_usd (post-hoc verdict — the backstop layer): a node that passes its
success_check is then judged against its declared budget_usd. Actual cost strictly greater than the budget
→ NodeBudgetError (node id + budgeted + actual), which flows through the exact
same path as a failed success_check: ledger row FAIL with the overspend in
Detail, retry only if opted in, halt-on-fail by default. A non-positive
budget_usd means "no budget declared" and is never enforced. The budget is
judged after Handoff.PersistOutput, so a node that did useful work before
blowing its budget still leaves its artifact on disk — the budget changes the
verdict, never handoff semantics. Its retry cause token is budget_exceeded,
deliberately distinct from nonzero_exit so a pre-existing retry policy can
never re-spend an already-blown budget by accident.
The verdict above is the post-hoc backstop layer. On top of it there is now
a native mid-run kill: a positive budget_usd is passed to the node's
subprocess as claude --max-budget-usd, and the CLI aborts the run itself the
moment its own running spend crosses the budget. Verified against claude 2.1.220
(free — a couple of trivial -p probes, no metered call): the abort exits
non-zero yet still prints a parseable --output-format json envelope whose
subtype is error_max_budget_usd; the runner maps that to
NodeOutcome.BudgetExhausted, and the scheduler raises the same
*NodeBudgetError the post-hoc check raises, so a native kill fails as
budget_exceeded — not the generic nonzero_exit its exit code alone would
imply — keeping the retry contract intact (a bare nonzero_exit policy never
re-spends a budget-killed node). The bound is per claude -p invocation,
which is exactly one oh-my-graph node: a resumed session does not re-count its
parent's spend (verified empirically), so it fits a per-node budget even under
handoff: session. A natively-killed node persists no artifact — the run
was interrupted before a result existed — whereas a post-hoc overspend completed
and keeps its output; the two failure shapes are deliberately distinct.
Both layers are kept because they cover different overshoots. --max-budget-usd
stops the next API call once cumulative spend crosses the budget, but cannot
un-spend a call already in flight, so a single expensive turn can still land
over budget — the post-hoc verdict is what catches that at exit. What remains
outside both layers: the overshoot of that one in-flight call (the CLI accounts
between calls, not sub-call), and any cross-node or whole-graph budget — each
node's cap is independent. Finer-grained, sub-call cost observation would still
need --output-format stream-json + incremental parsing, an ADR-level change to
the one-envelope NodeRunner contract; that alone stays deferred — mid-node
kill itself no longer does. Deriving a wall-clock timeout from budget_usd via
an assumed $/minute rate was still considered and rejected: the conversion rate
would be fabricated, so it would look like enforcement while enforcing nothing.
The per-node context.WithTimeout (20m default) remains as a wall-clock bound
orthogonal to cost — and since ADR 0007 a node may replace the default with its
own timeout: (a Go duration, validated at load like the verify timeout but
with no ceiling: the node timeout IS the critical path, and raising it is the
point of declaring it). An undeclared timeout keeps the 20m default, so no
node is ever unbounded.
A turn-denominated budget (budget_turns: → claude -p --max-turns N) was
proposed as a supplement to budget_usd — dollars are a hard cost ceiling but
a poor scoping unit (hard to estimate per task), while turns are a unit humans
can estimate — and is rejected for now: the installed CLI's claude --help
documents no --max-turns flag (verified 2026-08-02), so the engine could ship
only the schema, not the enforcement. See ADR 0007 for the recorded design and
the revisit condition.
success_check is a conjunction of predicates, cheapest first, evaluated only
after the runner returns an outcome. All configured predicates must pass.
| predicate | judges | trusts the node? |
|---|---|---|
exit_zero |
the subprocess exit code | no |
result_matches |
a regex over the node's .result text |
yes — self-report |
verify |
a command the ENGINE runs, by its own exit code/output | no |
An entirely empty success_check still means "exit zero is enough". The v0.1
contract is unchanged for every existing graph; verify is purely additive.
result_matches is kept — it is a cheap, useful filter — but it is demoted in
the docs and in the ledger's language to a secondary signal: a node passes
by emitting "PASS", which is narration, not evidence. verify is the only
predicate that observes state outside the LLM's own account of itself, and it is
what a node whose success is externally observable should carry.
success_check:
exit_zero: true
result_matches: '^[*_`\s]*PASS' # optional, secondary — see "Verdict patterns"
verify:
command: "go test ./... -run TestFoo" # required; run via the platform shell (`sh -c` on unix, `cmd /c` on Windows)
cwd: "{{ inputs.repo }}" # optional; default = the node's own cwd
timeout: 2m # optional; Go duration, default 2m, ceiling 10m
expect_exit: 0 # optional; default 0
output_matches: "^ok\\s+github" # optional; regex over the FULL combined stdout+stderr — no length bound, so an anchored pattern still means what it reads ascommand and cwd interpolate exactly like a prompt — the scheduler runs
them through the same Handoff.Interpolate — and the resolved command is
then what the second exec seam runs through your shell. So the placeholder you
choose decides whether a MODEL's text lands on that command line.
{{ artifacts.<id> }} is safe by default: with no filter it is the persisted
.out FILE PATH, which the engine computes, and a command that reads the file
gets the same content without ever quoting it into argv. The two shapes that
do splice model text are {{ artifacts.<id> | inline }} — the node's own reply
— and {{ feedback.<id> }}, which always inlines and has no filterless form;
handoff.LintVerifyInlining warns on both — in command only, since a cwd
becomes exec.Cmd.Dir rather than a command line, and in no prompt, where
inlining is the designed use. It is an advisory, not a load error, for the
reason every sweep in that package is one: only a person can write what it
condemns. A hand-written graph is the user's own reviewed artifact, and a
planned graph carries no verification a MODEL wrote either —
validatePlannedNodeVerify refuses a planner-authored verify:, so the only
one it can carry is the --verify-cmd the user supplied, which is
advisory-eligible like any other command line but is still the user's own
string. What that refusal costs is coverage, and it is worth stating:
engine-run evidence in a planned graph is established per RUN — the user's
--verify-cmd attaches at the graph's sinks — so an interior planned node's
verdict is its own word plus the subprocess exit status, by construction and
not by omission (ADR 0033). The supported route to interior coverage is run
on a hand-written graph, which writes verify: on whichever node its author
means (DESIGN.md:1589).
type SuccessCheck struct {
ExitZero bool `yaml:"exit_zero" json:"exit_zero,omitempty"`
ResultMatches string `yaml:"result_matches" json:"result_matches,omitempty"`
Verify *Verification `yaml:"verify" json:"verify,omitempty"` // nil = no evidence check
}
// Verification is a pointer field so "absent" and "zero" are distinguishable,
// and ExpectExit is a *int so an explicit 0 is expressible.
type Verification struct {
Command string `yaml:"command" json:"command,omitempty"`
Cwd string `yaml:"cwd" json:"cwd,omitempty"`
Timeout string `yaml:"timeout" json:"timeout,omitempty"` // parsed with time.ParseDuration at LOAD time
ExpectExit *int `yaml:"expect_exit" json:"expect_exit,omitempty"`
OutputMatches string `yaml:"output_matches" json:"output_matches,omitempty"`
timeout time.Duration // Timeout parsed once at load; read via TimeoutDuration
}Both tag sets are load-bearing, and a new field needs both. The yaml tag
is how the field is authored; the json tag is how it survives a run. The
snapshot stores the normalized graph as re-parseable JSON (Snapshot.Graph),
so a field with no json tag round-trips under Go's default key name at best,
and a field tagged json:"-" would silently vanish from every resumed run —
the graph the second leg executes would quietly differ from the one the first
leg did. graph.go states this at the struct itself; copy the pair, not just
the yaml half.
SuccessCheck.IsZero() must also test Verify == nil, and Validate must
reject an uncompilable result_matches, an empty command, an unparseable
timeout, a timeout over the ceiling, and an uncompilable output_matches — at
load, naming the node (GraphValidationError), never mid-run. Changing this
struct touches loader, validator, shipped example graphs and tests together.
result_matches is a Go regexp matched against the model's raw final reply
(outcome.Result, the CLI's result field), with no normalization whatsoever:
no trimming, no markdown stripping, no case folding, and no flags the engine
adds of its own. ^ and $ therefore anchor to the start and end of the whole
reply text unless the pattern itself opens with (?m), which no shipped
pattern does — and "Where the verdict may sit" below is the measurement of why
not.
The pattern is compiled at load, exactly like verify.output_matches: a
pattern that does not compile is a GraphValidationError naming the node and
quoting the pattern, so lint and run --dry-run refuse the graph and no node
is spawned. A verdict pattern is a declaration, and a broken declaration is
knowable from the file alone — it used to be diagnosed only inside the
scheduler's evaluation, which runs after its own node has been paid for.
Models emit markdown. A prompt that says "begin your reply with PASS" leaves
the model free to write **PASS**, and it does — that exact reply has failed
^PASS and halted a real run of a shipped graph, twice in one release, on a
node whose suite had actually passed. The graph author sees the flake as luck,
because earlier runs of the same graph passed.
So a verdict pattern is written in two halves, and both are load-bearing:
- The prompt is the instruction. Demand the bare token as the very first
characters of the reply, and say that markdown emphasis is wrong — name the
wrong shape (
`**PASS**` is WRONG) rather than only describing the right one. Then say where everything else goes, because "no preamble" only forbids; it does not offer. A node that finishes its work and has one exception to report — a step that found nothing to do, a caveat — will put that sentence somewhere, and if the prompt never names a place, it goes on top. That is how run 20260807-144514 openedmerge's reply with "no local branch to delete" and was recorded FAIL over a merge that had landed. Every shipped prefix verdict carries the offer: anything you need to qualify goes AFTER the verdict, never before it — as one unbroken line, sogrep -c "Anything you need to qualify" graphs/*.yaml graphs/fragments/*.yamlis a sweep that cannot silently miss a node. That sweep counts 24 declarations, covering 33 runtime nodes — a fragment states the clause once and every node citing it gets it, which is the point: six of the 24 live ingraphs/fragments/, in five files, and carry fifteen of the nodes between them. The gap widened by two whenadr-driven-dev's two repair rounds became twouse:of one multi-node fragment (ADR 0027): the same 33 nodes, four fewer places to correct the sentence in. The four whole-reply pins (haiku-smoke'swrite, thee2e-verifyfragment,apply-flags'sverify, andcoordinator.plannedVerdictPattern) say the opposite and must — their reply is the token and nothing else, so for them the answer to "where does the caveat go" is "nowhere, and a caveat means this is not the verdict you have". - The pattern is the backstop. Wrap the token in the decoration class
[*_`\s]— emphasis, code span, whitespace — while keeping the anchor:'^[*_`\s]*PASS'for a prefix verdict,'^[*_`\s]*PASS[*_`\s]*$'when the whole reply must be the verdict and nothing else,'^[*_`\s]*APPLIED[*_`\s]*:'when the token carries punctuation a model may wrap (**APPLIED:**and**APPLIED**:both match). Single-quote the YAML scalar so the regex reaches the engine byte-for-byte.
The class is exactly the decoration a model wraps around a bare token it was
told to emit alone: **PASS**, `PASS`, a stray leading or trailing
newline. It deliberately stops there. Block structure that changes the shape
of the reply — a ## PASS heading, a - PASS list item, a sentence of
preamble — is the prompt's job to prevent, not the pattern's to absorb; every
character added to the class buys tolerance with a little of the anchor's
meaning. The class is also case-sensitive, like the whole match: a node
told to reply ship it fails on Ship it, so a lowercase or mixed-case token
needs the prompt to say the casing is literal.
Never relax the ^ into an unanchored match to fix a markdown failure: an
unanchored PASS also passes a FAIL report that mentions the word, which turns
a cheap filter into a check that cannot fail. The tolerance belongs in the
character class, not in dropping the anchor. Every shipped graph and fragment
follows this shape, and so does the pattern the auto-mode planner hands its
branch-assertion check node (coordinator.plannedVerdictPattern, where a
planned node may not set verify and the pattern is the whole gate); a new
verdict token joins it.
That rule is not a guess. Every result_matches failure this project has on
disk was replayed against the anchor, the anchor dropped, and the anchor
weakened to (?m) (per line rather than per reply). The corpus: 187 runs, 218
verdict-bearing node executions, 22 result_matches failures. Count the
executions from each run's events.jsonl, not from state.json's nodes map —
that map keeps only the last record per node id, so retries and feedback rounds
overwrite themselves and the same corpus reads as 211 / 18.
Of those 22, 16 were the check working — three of them the literal promise
reply ("I'll report when the stress test finishes", "I'll wait for that
waiter", "4 planner processes are running") and the rest honest
FAIL/NOT READY/BLOCKED reports. The remaining six were pattern
misjudgements, all in the same direction — a reply rejected for where its
verdict sat, or for how it was decorated, and not for what it said. Whether the
work behind each was done is a separate question, and ADR 0019 keeps its
buckets apart: one is world-confirmed (merge, PR #135), one is a synthetic
fixture, and four are read-only nodes nothing on disk can confirm today:
| what went wrong | count | still reachable? |
|---|---|---|
**PASS** against a bare ^PASS |
1 | no — the decoration class fixed it |
verdict first, $ pin, evidence trailing |
3 | not in a shipped graph; all three were ad-hoc graphs whose prompt said "reply with exactly X" and asked for the evidence |
| verdict present, on a later line | 2 | yes — one fixture, one real (merge, PR #135) |
The 196 passes were not audited as a body, so "nothing was wrongly admitted" is
not a claim this table can make. What was audited is the node the question is
about: merge's 18 patterned PASSes are 16 MERGED <sha> — every SHA an
ancestor of main today — and 2 WITHHELD <reason>. The corpus's one known
false PASS is merge too, and it is the reply in the paragraph below: it ran
under { exit_zero: true }, before this pattern existed.
Replaying the same 22 with the anchor dropped (leading ^ stripped, any
$ kept) admits 11 replies — and 8 of those 11 are among the 16 correct FAILs,
because NOT READY contains READY and a FAIL report discussing the run's
PASS lines contains PASS. Eight false passes to buy three true fixes is the
trade this section already refuses.
Replaying them with (?m) admits 3 and none of the 16, which looks like a
bargain and is not. Both halves of that number mislead:
- Only 2 of the 3 are later-line verdicts. The third (
^COVERED$) has its verdict on the first line;(?m)admits it by releasing the$. Applied to a whole-reply pin,(?m)does not move the anchor — it deletes the pin, and "and nothing else" stops meaning anything. - "None of the 16" is the wrong population. The 22 are replies that already
failed; the promise reply is not among them, because before the pattern
existed it passed. Constructed against
merge's own pattern,(?m)accepts a plan that lists both verdicts as bullets, an indented code block quoting the instruction, and a sentence carrying the PR head SHA — five promise replies in all, one of them the prompt's ownMERGED 4f2a1c9example quoted back.^rejects all five.
The payload is not the lock. A SHA is seven hex characters, and a model that
has merged nothing can type them; what ^ actually rejects is the preamble a
promise cannot do without — the sentence saying why the verdict cannot be given
yet, which must come before the quote of what it will say. Position is the
lock, at every node, for a payload verdict exactly as for a bare word. So no
pattern in this repo is line-anchored, and (?m) appears in none of them.
Where a false FAIL is unusually expensive, make the re-run cheap instead of
widening the check. merge-shepherd's merge was the one node whose re-run was
not safe — it re-entered gh pr merge on an already-merged PR under a grant too
narrow to look — so it gained a step 0 that establishes PR state first and two
read-only commands (gh pr view, git merge-base) to do it with, the ancestry
check reading origin/main only after the pull that refreshes it. ADR 0019
records the decision, the refused relaxation, and what would overturn either.
A verdict nothing checks is not a verdict. A prompt that asks for one and
a success_check of { exit_zero: true } is the same "a prompt is not a
mechanism" failure as the markdown one above, and it fails in the more
expensive direction. merge-shepherd's last node was asked for the merge
commit SHA and replied "CodeRabbit's re-review is mid-flight, so I'm waiting
… Poller armed (30s interval, 12 min cap). I'll proceed as soon as it lands."
The claude process exited 0, nothing matched anything, the node passed, the
ledger recorded a successful merge step, and nothing was merged; the operator
found out because git pull did not move. A false FAIL costs a retry, so it
announces itself — a false PASS writes a wrong row in the ledger and stays
there. So: if a node's prompt names the answer it must give, its
success_check must be able to tell whether it got one.
The reply that has to be rejected is rarely a wrong answer — it is a promise
of future work. A node's turn ends when it replies, so "I'll follow up once X
lands" is the node reporting work that will never happen. Two halves again:
the prompt says the turn ends at the reply and demands the state that is true
as it replies, never a plan to continue; the token carries a payload the
node could not name before doing the work — a commit SHA, a PR URL, a file
path, a count of comments actioned.
MERGED[*_`\s:]*[0-9a-f]{7,40}\b is an assertion; MERGED alone is a word a
model writes about a merge it intends.
Be precise about what that buys, because it is easy to over-read: a payload
bounds promises, not lies. result_matches still reads only the
node's own reply (LIMITATIONS.md #7), and a determined model can copy a SHA
out of an upstream artifact. What it removes is the honest failure — the
reply a model writes when it has genuinely not finished and is telling you so
— which is the one that actually happened, and the only one a pattern can
catch. Bounding lies takes success_check.verify, where the engine runs a
command of its own.
The payload must also be shaped, not merely present. Three of these
patterns first shipped with a payload that any prose satisfies, which is the
same hole one refactor further in: ADR[*_`\s:]*\S accepts ADR: pending;
TRIAGED[*_`\s:]*[0-9] accepts TRIAGED 3 of 7 so far, the rest to follow, because a partial count is a count; APPLIED: carries no payload at
all. Ask of the class what reply it lets through, not what reply it was
written for: a path needs its extension (\S+\.md\b), a count needs the
rest of its line to be empty ([0-9]+[*_` \t]*(\n|$), and a prompt that
says the first line holds the count and nothing else), a SHA needs its length
([0-9a-f]{7,40}\b). A payload that a qualifier can survive next to is not
a payload — it is a decoration on the same promise.
Two legitimate outcomes, one pattern. Some nodes have two right answers —
merge either merged or deliberately did not, and refusing to merge past an
unfinished review is the graph working, not failing. That is an anchored
alternation, not a relaxed anchor:
'^[*_`\s]*(MERGED[*_`\s:]*[0-9a-f]{7,40}\b|WITHHELD[*_`\s:]*[[:alnum:]])'.
Both outcomes pass, everything else fails, and the anchor still means what it
meant. Note that the shaping rule binds the negative branch too: closing it
with \S lets the separator class hand its own last character back as the
reason, so WITHHELD: and WITHHELD — both pass carrying no reason at all —
hence [[:alnum:]], which the decoration a real reason is written behind
(**WITHHELD**: CodeRabbit has not concluded) still reaches. Two rules keep
it honest. Pick tokens where neither is a prefix of the
other and neither contains a separator a model may render differently —
WITHHELD over NOT-MERGED, because a model told to write NOT-MERGED will
sometimes write NOT MERGED and the decoration class deliberately does not
absorb that. And pick a word for the negative outcome that names a decision
rather than a state (WITHHELD, not PENDING), so the token itself cannot be
honestly used for "not yet" — otherwise the alternation re-admits the promise
it exists to reject. A graph whose green run can mean either outcome must say
so in its header: the ledger says the node passed, and only its artifact says
which.
Three outcomes, two of which pass — and the token that is deliberately
absent. A node that waits has one more outcome than a node that decides:
the thing it waited for concluded well, it is stuck on something no waiting
reaches, or it had not concluded when the timeout arrived.
merge-shepherd's recheck — the re-wait for the check rollup and the review
after triage may have pushed a fix — is the shipped case, and it writes all
three. The pattern is
'^[*_`\s]*(RECHECKED|UNSETTLED)[*_`\s:]+[0-9a-fA-F]{7,40}\b'; the stuck
outcome is LATCHED <sha> — <what>; unblock: <act>, which appears in the
prompt and not in the pattern. Three rules, none of them new:
- Leaving a token out of the pattern is how a graph halts on purpose. A PR
only a person can unstick must not reach the human gate, so
LATCHEDis written by the prompt, matched by nothing, and fails the node — the same shape astriage'sBLOCKED. The absent token is part of the grammar; a reader who sees only the regex sees two thirds of it, which is why the prompt names all three. It also bounds what the two-halves rule can deliver: a pattern cannot shape a token it must reject, soLATCHED's second half — theunblock:act — is prompt-side only, and what holds it is a test (internal/graph/shipped_graphs_test.go), not a regex. - Split the failing outcomes by the operator's next ACTION, not by
severity. This node used to call its halting verdict
BLOCKEDand define it as RED — "a judgment that something is wrong with the code". That filed things wrongly in both directions: a workflow run awaiting a maintainer's click halted as if the code were bad, while four conditions that need a person and nothing else — a required context with no reporter, a queued run nobody approved, a rate-limited bot that will not review again until asked, a branch that conflicts with its base — were polled for the full timeout and then passed to the gate asUNSETTLED, a word that means wait longer. The axis that decides a wait's honesty is LATCHED vs SELF-RESOLVING (ADR 0021), so that is the axis the tokens split on, and the halting one carries the act. - Factor the payload, don't weaken a branch. Both passing branches share
one shaped payload rather than each arguing for its own, because the SHA is
what the node is for: a verdict about "the checks" that cannot say which
commit they ran against is precisely the ambiguity the node was added to
remove. Contrast
merge, whose two branches assert different things and so needMERGED's SHA andWITHHELD's[[:alnum:]]separately. - A state word is admissible only when the state is an outcome. The rule
above prefers a decision over a state, and
UNSETTLEDis a state — the one exception, and it is bought, not free. It passes, so a run whose checks were merely slow reaches the gate instead of discarding a paid pipeline; and a node that writes it without waiting is not caught by the pattern. What bounds that is what lies downstream: a gate the operator must still open, and amergewhose answer toUNSETTLEDisWITHHELD. A prematureUNSETTLEDcosts a glance and a refused merge; it can never cost a merge of unchecked code. Where a premature answer would be expensive —RECHECKED— the token names a completed check, not a state. The rest of that price is paid in the graph's terminal state, and it should be said out loud: anUNSETTLEDthe operator approves over ends the run GREEN, having merged nothing. That outcome is not what the state word bought — beforerecheckexisted,mergeansweredWITHHELDto those same unfinished reviews, which also passes, and three of the five hits that motivated the node ended exactly there. Only two of those three were slow: a re-review stillPENDINGon the triage commit (PR #111, PR #137). The third (PR #134) was a newCHANGES_REQUESTEDagainst it — a latch, whichrecheckanswersLATCHED, halting before the gate rather than reaching it as anUNSETTLED. Admitting a state word makes the green-run-merged-nothing class rarer — the common case, where restarted checks conclude in minutes, now merges — but it does not remove it, and a graph that buys this exception owes its header that sentence. The exception is also narrower than it was: since 2026-08-11UNSETTLEDmay only be written over something that was still MOVING, so the latched conditions that used to reach it now halt instead.
Widening the separator class between a token and its payload is the one
tolerance worth buying node by node, and the currency is what a false FAIL
costs there. merge admits - and — (MERGED — 4f2a1c9) because its retry
re-enters gh pr merge on an already-merged PR under a grant too narrow to
look at what happened, so a false FAIL is an operator's morning, not a
re-run; the SHA still carries the assertion, so nothing is given up. Every
other node pays a retry, and keeps the narrow class.
What reuse can and cannot deduplicate here. A use:/with: fragment
(ADR 0013) puts a verdict rule in one place exactly when the whole NODE is one
shape. Six shipped fragments carry their own — e2e-verify, the two review
shapes, pr-publish, repair-round and gated-lane — and the three prefix
verdicts among the first four cover ten runtime nodes between them. It reaches no further, and ADR 0013's Migration update is
the measurement: the patterns still repeated in the templates are shared by
nodes that share nothing else. The six nodes declaring ^[*_`\s]*DONE\b have
zero words of prompt in common and four different tool grants. A fragment
is a node's behavior, not a paragraph, so no mechanism hands those six one
clause — and none should, because each writes its own token, its own payload
and its own "if it is not finished" branch. The fourth whole-reply pin is
further out of reach still: coordinator.plannedVerdictPattern is not in a
graph at all but a Go string rendered into the planner's prompt for the planner
to copy character-for-character into JSON, so nothing graph-side can share it.
What keeps the two spellings of that one idiom from drifting is therefore a
test, not this paragraph: TestPlannedVerdictPatternMatchesE2EVerifyFragment
(internal/coordinator) reads graphs/fragments/e2e-verify.yaml out of the
embedded payload and fails unless its result_matches is byte-identical to the
constant. Reuse could not deduplicate them; a go test can still pin them.
The same limit holds outside this repo's templates, and it was asked once more
of a hand-written corpus: 75 lane graphs on one machine ending in a pr node,
33 of them carrying a full review → apply → pr scaffold, proposed as a
fifth fragment. Nothing was extracted (ADR 0013's 2026-08-10 update;
docs/measurements/0013-lane-corpus-has-no-extractable-fragment.md). The
repeated part is worktree/cwd/depends_on/id, which a fragment may not
carry; the one candidate with enough shared prose declares neither half of the
verdict convention — result_matches 0 of 35, verdict clause 0 of 35 — so both
halves would have been written rather than extracted, which is the silent
mismatch this section exists to prevent, not an instance of reuse. Nor is the
corpus merely innocent of the convention: no apply, review or pr node in
it declares result_matches, use: appears zero times, and the 32 nodes that
do declare one (in other roles) are hand-retyped e2e-verify patterns of which
all 32 drifted — 19 to a bare PASS, which, since result_matches is a
search, passes on "the suite did not PASS". That is what a corpus that adopted
the shapes by copy-paste instead of by use: looks like from the inside, and
it is the argument for the fix being to cite the four shipped shapes.
The rule is stated here and swept for by lint and run --dry-run
(handoff.LintVerdicts), because a rule only DESIGN.md knows is the same
"a prompt is not a mechanism" shape one level up. Two advisories, both cheap
and both drawn from a real miss: a prompt that demands a token (START your reply with…) under a success_check with no result_matches, and a
result_matches declared without exit_zero — which silently deletes the
exit-code guard a node had for free while it declared no check at all
(SuccessCheck.IsZero), so a "fix" that adds a predicate can remove one.
They stay advisories: neither can judge whether a payload is shaped tightly
enough to reject a promise, which is the part that still needs a reader.
The engine's matching semantics stay deliberately dumb — normalizing the reply
in Go (trim, strip emphasis, case-fold) would change what every existing
result_matches in the wild means, so it is an ADR-scale question, not a patch.
Until such an ADR exists, the idiom above is the fix.
Where it runs in the node lifecycle (schedule.runNode):
Handoff.ResolveInputs → NodeRunner.Run → exit_zero → result_matches → verify → Handoff.PersistOutput → budget_usd → RunState.RecordNode + RunLedger.Record → enqueue dependents (the same order schedule.runNode's own doc comment
fixes). Ordering is
deliberate: the in-memory predicates are free, the verification command is not,
and a node that crashed should not have a command run against the wreckage.
Who runs it — a second exec seam, not the NodeRunner. A verification command
is not a claude invocation, so it does not belong behind NodeRunner (that
interface exists so FakeRunner can stand in for claude; teaching it to also
run arbitrary shell would give it two reasons to change). It gets its own,
narrower seam in internal/verify:
type Request struct {
Command string
Cwd string
Timeout time.Duration
}
type Result struct {
ExitCode int
Output string // the FULL combined stdout+stderr — never truncated
}
type Verifier interface {
Verify(ctx context.Context, req Request) (Result, error)
}ShellVerifier(prod) is the second of the program's exactly four process-spawning seams (ADR 0002; the third isworktree.GitManager, ADR 0005; the fourth isbrowser.ExecOpener, ADR 0006) and the only object ininternal/verifythat spawns anything. Injected bycmd/oh-my-graph, never constructed by the scheduler.RefusingVerifieris theOptions.Verifierdefault: a scheduler test that forgets to inject one gets a loud failure instead of a real spawn.FakeVerifier(scripted, keyed by command) is what tests inject, so the whole verify path stays spawn-free in CI.Result.Outputis judged, so it is not truncated.output_matchesis applied to everything the command printed. Truncation is a presentation concern and lives where the output is retained or rendered — the ledger's DETAIL column caps it (schedule.capDetail), and a*TimeoutError, which can be wrapped and held, keeps only a marked tail. Bounding the judged value instead would silently narrow the graph's predicate: the seam prepends…(earlier output truncated)…when it cuts, so an anchored pattern like^ok\s+githubcould never match a chatty command. Memory is not the trade it looks like —CombinedOutputhas already buffered the whole thing before any cut could apply.- Selection is data-driven and the caller stays ignorant: the scheduler asks the
injected
Verifierand never learns which kind ran. A second verification kind (file_exists:,git_clean:) arrives as anotherVerifierbehind a composite that dispatches on the declared kind — no scheduler change. v1.1 ships exactly one kind: minimal implementation, sufficient interface.
This narrows the "only CLIRunner touches os/exec" invariant, on
purpose. The invariant's restated form: exactly four objects may spawn a
process — runner.CLIRunner, verify.ShellVerifier,
worktree.GitManager (see "Worktree isolation") and browser.ExecOpener
(ADR 0006) — each behind its own injected interface, and no other package
imports os/exec. Both purposes survive: the subscription-auth scrub still
has exactly one home per spawner, and the engine is still fully testable with
zero spawns. See ADR 0002, ADR 0005 and ADR 0006.
The env scrub applies to verification commands too. verify: { command: "claude -p ..." } is legal and would otherwise run on metered API billing if
the key happened to be set. All four provider API-key switches are
deleted from the verification child's env by the same shared policy the runner
uses (internal/childenv), asserted by its own unit test.
Failure and retry. A failed verification is a *NodeCheckError with
Predicate: "verify" and a detail carrying the exit code and a truncated tail of
the command's output — so the ledger says why, not just that. The retry
cause token is verify_failed, joining nonzero_exit / result_mismatch /
output_error / run_error / timeout / budget_exceeded — the full closed set of
graph.Cause* constants retry.on accepts — so
retry: { max: 1, on: [verify_failed] } works.
A verification that times out or cannot spawn is a node failure, never a silent
pass. Cancellation propagates: the shared run context reaches the verification
child, so halt-on-fail kills it like any claude child.
Auto-planned nodes may not set verify. The command is arbitrary shell that
runs outside every guard the coordinator builds — no permission mode, no tool
ceiling, no cwd restriction. validatePlannedNodes rejects it. See "Auto mode".
A gate node is a clean stopping point, not a blocking wait. oh-my-graph is
a stateless CLI with no daemon (SECURITY.md), so parking a process for hours
holding a shared context, in-flight 20-minute claude children and an errgroup
would be a worse version of "exit and come back". A gate therefore stops the run
by design, persists everything a second leg needs, and exits.
Lifecycle at a gate:
- The gate node's
GateController.Evaluatereturns a decision. pause→ stop launching new work, but drain what is already in flight rather than cancelling it, so sibling nodes' results are persisted and do not have to be re-run (and re-paid for). This is the one place the halt path does not cancel the context.- Write the run snapshot atomically — the pause itself is carried by the
snapshot (
runstate.SnapshotRecorder.RecordPause) and by the run feed'spausedoutcome, not by a ledger row (the ledger has noPAUSEDverdict) — print the exactresumecommand, and return a*schedule.PausedError. cmd/oh-my-graphmaps that to exit code 2.0= every node passed,1= the run failed,2= the run is paused and resumable,3=autorefused to start for want of build evidence (ADR 0030 — nothing ran, nothing is resumable, nothing was billed, and no run directory exists). A pause is not a failure and must not be reported as one; nor is a refusal.
--continue-on-fail does not apply to gates: a pause always stops the whole
run, because approving "part of" a paused run later is not a coherent operation.
Pause vs. a real failure draining alongside it, under default halt-on-fail:
draining (step 2) means an in-flight sibling can still fail for real — not a
gate pause/reject, an ordinary node error — after another node has already
paused the run. Under the default (--continue-on-fail off), that real
failure takes precedence: it becomes a *schedule.HaltError, which cancels the
shared context and is what grp.Wait() returns, so Run returns it directly
and never reaches the pause bookkeeping below — the already-recorded pause is
discarded, no snapshot pause is written, and the CLI reports exit code 1, not
2. This is intended, not a race to fix: halt-on-fail's contract — a real node
failure stops the whole run — does not become conditional on whether some
other branch happened to pause first. --continue-on-fail avoids the
conflict entirely, since a real failure under it prunes a subtree instead of
halting, so it never competes with a pause for how Run returns.
GateController — the decision source is data, not a DI swap:
type Decision string
const (
DecisionApprove Decision = "approve"
DecisionReject Decision = "reject"
DecisionPause Decision = "pause"
)
type GateController interface {
Evaluate(ctx context.Context, node graph.Node) (Decision, error)
}PauseController— injected byrun/auto. AlwaysDecisionPause: a fresh run cannot carry an approval.RecordedController— injected byresume, wrapping the snapshot's decision map. Returns the recorded decision for a gate already decided,DecisionPausefor the next undecided one.
The scheduler asks the same question either way and never branches on "am I
resuming"; which controller answers is chosen once, at the CLI boundary, from
the invocation. Adding a future --auto-approve policy or an interactive TTY
controller is another implementation, not a scheduler change.
What the snapshot must hold — ~/.oh-my-graph/runs/<run-id>/state.json,
written temp-file + rename (atomic), with a schema version so an
incompatible snapshot is refused rather than misread:
- the normalized graph itself (plus the source path and its SHA-256), so a
resume does not silently depend on the YAML not having been edited.
autoalready writesgraph.json;runmust now snapshot the same normalized form. - the run's inputs and the flags that change meaning:
--inputbindings,continue_on_fail,default_permission_mode, and — critically — the per-node tool policies for an auto run. Resuming a planned graph without its ceiling would silently drop the entire Layer-1/2 guard; the snapshot carries it for the same reasonexecutePlantakes a wholePlaninstead of two arguments.default_permission_modeis there for the same class of reason ascontinue_on_fail: a node declaring no mode falls back to whatever the binary's default is, so without it a run launched under one default would silently resume under another after an upgrade. Absent meansdontAsk— every snapshot written before ADR 0034 ran that mode — and a resumed leg writes the resolved value back, so the question is open exactly once per run. Optional and additive: the schema stays at 3. - per-node completion records: verdict, session id, cost, duration,
artifact path. The session id is the one thing resume needs that today exists
only in
Handoff.sessionsin memory — without it ahandoff: sessionchild cannot--resumeits parent on the second leg. The.outartifact files stay exactly as they are; they remain the{{ artifacts.<id> }}target. - gate decisions so far, and which gate the run is paused at.
One field the snapshot holds but resume does not trust: an auto graph's
success_check.verify. A verification is a command the ENGINE runs, outside
every layer of the auto ceiling — which is why validatePlannedNodeVerify
refuses a planner-authored one at plan time — so a resumed leg reconstructing a
planned graph strips any it finds (coordinator.ReattachVerifyCommand, ADR
0016 §4). The discriminator is the snapshot's tool policies, non-empty exactly
for a planned graph: a hand-written graph's verify: is the user's own reviewed
artifact and round-trips untouched. What replaces the stripped command is
resume --verify-cmd — the same flag pair auto takes, the same value object,
the same 10-minute ceiling, attached to the same sinks by the same trusted code
— so the command comes from the human on the resumed leg exactly as it did on
the first, and the run directory is an admissible source on neither. A resume
that supplies nothing while the snapshot carried one is refused, naming the
nodes: resuming with strictly weaker checking than the leg being continued is
the failure ADR 0016 is about. The flag pair is registered on resume and on
auto only — a hand-written graph writes verify: on whichever node it means,
run has no such flag, and a resumed leg must not be able to attach a check a
fresh run could not, so resume --verify-cmd against a hand-written snapshot is
an error rather than an attachment.
What the snapshot deliberately does NOT hold: in-degree counts and the
ready set. Both are derived from graph × completed, so persisting them would
create a second source of truth that can go stale. resume recomputes them:
Graph.ReadyGiven(done map[string]bool) []string answers the topology question
(and Roots() becomes ReadyGiven(nil)), while the scheduler seeds each node's
in-degree as len(DependsOn) − (parents already completed).
Snapshot writes happen after every node, not just at gates, so the file on
disk always reflects everything finished so far, including a Ctrl-C'd or
crashed run's progress. Two resume modes read that file: the gate mode only
continues a run whose snapshot actually recorded a gate pause
(Gate.PausedAt != "") and refuses anything else with "run is not paused"
(see executeResume's guard), while --retry-failed continues a run whose
snapshot recorded failures (see the CLI contract below). A Ctrl-C'd or
crashed run that recorded neither a pause nor a failure is still neither
mode's business — resuming that is future work. A snapshot
write failure mid-run is non-fatal, but its cost is not merely a gap in the
printed ledger: the dropped write means that node is absent from the persisted
state, so a later resume would not know it ran and would re-execute it — a
real cost, not a cosmetic one — which is why it is warned on the progress feed
rather than silently swallowed; a snapshot write failure at a gate pause is
fatal, because a pause whose state was not persisted is an unrecoverable
stop, and reporting it as a clean pause would lie.
Two front-ends, one resume. A gate decision reaches the run through
executeResume and nothing else. oh-my-graph resume parses it from flags;
the web live view POSTs it from the browser (POST /api/gate/approve
|/reject → serve.GateResumer → cliGateResumer → the same
executeResume, ADR 0014). The lock, the snapshot load, the explicit-gate-id
check, the RecordedController and the leg itself are shared, so the two
front-ends cannot drift; only the standalone serve process wires the
browser one.
CLI contract:
oh-my-graph resume <run-id> (--approve <gate-id> | --reject <gate-id> | --retry-failed) [--verify-cmd 'CMD'] [--verify-timeout D] [--concurrency N] [--no-web] [--no-skill-activation]
-
Exactly one of
--approve/--rejectis required when the run is paused at a gate. A bareresume <run-id>on a paused run is an error naming the pending gate — a resume must never silently approve. -
The gate id is explicit, not implied, so resuming an old run cannot approve a gate the user was not looking at. A mismatch (
--approve xwhile paused aty) is an error. -
--reject <gate-id>prunes the gate's subtree; the run finishes the independent branches and exits1, naming the rejected gate. It is not a crash, but the graph did not complete as declared. -
--inputonresumeis rejected. Inputs come from the snapshot; changing one mid-run would make the already-persisted artifacts inconsistent with the prompts that produced them.--concurrencymay be overridden — it is not semantic.--no-webis accepted withrun/auto's exact meaning: a resumed leg embeds the same live view under the same TTY gate (see "Web live view"), and this opts out. -
--verify-cmd/--verify-timeoutare accepted for the opposite reason--inputis refused: build evidence is the one thing a resumed leg must take from the human rather than from the run directory (ADR 0016 §4). An auto run started with--verify-cmdneeds it supplied again on every resumed leg — without it the resume is refused, with it the command attaches to the same sinks under the same ceiling and the engine judges its exit code, exactly as on the first leg. A pause hint for such a run prints the flags back with the command in it, so the promised copy-pasteable resume stays copy-pasteable (ADR 0009) — and quoted, since a hint that pastes as a different command is the defect #198 reported, one step downstream. Supplied where the flag has nothing to attach to — a hand-written graph — it is an error even when the leg would have been a no-op, rather than being accepted and ignored. -
Multiple gates ⇒ multiple resumes: a resumed run advances to the next gate and pauses again. The decision map makes batch approval a later, additive change.
-
--retry-failedsalvages a failed run instead of deciding a gate; combining it with--approve/--rejectis a flag error (a retry leg replays prior gate decisions unchanged and must never sneak a new one in). It keeps every PASSED node's record and artifact as-is — dependents interpolate them exactly as after a gate resume — clears the FAILED records, and runs the graph again, so only the cleared nodes plus the nodes an earlier leg cancelled or never reached execute. A REJECTED gate is a standing human decision, not a failure to salvage: its record is retained, it is never retried, and its subtree stays pruned. A run with no retryable failure reports "no failed nodes to retry" and exits 0 without spawning anything or opening a new leg on the event stream — unless the run holds launchable nodes with no record at all (a session-limit pause leaves exactly that state — ADR 0009), in which case the leg runs them as "running unfinished nodes"; a gate-paused run is still redirected to--approve/--reject. -
A subscription session limit is a pause, not a failure (ADR 0009) — on Claude. The runner classifies the CLI's limit message (
NodeOutcome.SessionLimited, matcher pinned ininternal/runner/sessionlimit.goagainst Claude's own prose, and set only for the Claude runtime), so a--runtime codexrun reaches no SESSION-LIMIT pause: the limit is an ordinary node failure — a retry first only if that node declares aretry:block, sinceshouldRetryreturns false whennode.Retryis nil — whichresume --retry-failedstill salvages. Only that ONE pause is runtime-shaped: agatenode is an engine construct with no runtime branch, so a Codex run pauses at a gate exactly as a Claude run does (merge-shepherd, the graph named above, has one). Whether ADR 0009 is a promise of the engine or of the Claude runtime is SETTLED as the runtime's (ADR 0009 "Scope", closing #171). The scheduler then stops launching new work but drains in-flight siblings (which may themselves limit and join the paused set), records the limited node NOWHERE (un-run, not FAILED — no ledger row, snapshot record, or terminal event), and returns*LimitPausedError→ exit code 2 with a best-effort-parsed "resume after with:resume <run-id> --retry-failed" hint — carrying--verify-cmd '<the command>'when this run's sinks hold one (POSIX-quoted, so a command containing a quote pastes as itself, plus--verify-timeout Dwhen the bound is not the default), since a resumed leg re-supplies it rather than reading it back off disk. A gate pause outranks a limit; a limit outranks continue-on-fail pruned failures. The leg closes on the stream as outcome"paused"with a distinguishingdetail. A retry leg's worktree provisioning is disk-aware: a retried node re-declaring a name reuses the lane's surviving dir or re-attaches the branch a paused leg retained, so the lane continues its committed state instead of dying on the ref collision (see "Worktree isolation"). -
A
resume.lockguards against two concurrent legs of the same run id double-running nodes: therun/autofirst leg holds it for its whole duration, and everyresumetakes the same lock — so aresume --retry-failedraced against a still-in-flight run fails on the lock instead of double-spawning. The lock is the kernel's exclusiveflock(2)on that file, not the file's existence (ADR 0015): the kernel releases it when the holder dies, however it dies, so a held lock means a live leg and there is nothing stale to report. The file's first line is a format marker (oh-my-graph-lock 1) and its second is the holder's pid — informational, a label on an already-established liveness, never a liveness test (a pid was measured being recycled by an unrelated process). Release unlocks and closes but does not unlink: acquiring is open-then-flock, and an unlink between those steps would let one leg hold the lock on an orphaned inode while another took it uncontended on a fresh one. A lock file with no marker was written by a pre-flockbinary, whose live leg holds no flock at all; on the acquire path it keeps the old semantics, where existence is the lock, a human decides, and the refusal names the exact path to delete. A reader can ask the same file whether a leg is still alive without creating, writing or removing anything (runstate.ProbeLock, a shared-lock probe on a read-only fd); a missing file, a non-local filesystem and any error alike answer unknown, which means the answer this tool gave before ADR 0015. -
A pre-
flocklock file is read under a second, weaker liveness rule, and it is the reason an old run resolves differently from a new one. The flock is silent about a file whose writer never took one, so folding "unmarked" into unknown would leave every run abandoned before the upgrade readingRUNNINGfor the rest of time — there is no later moment at which such a file becomes readable, because the only thing that can change is a pid and the marker will never appear. Its single pid line is the only signal it carries, and it is read in one direction only: a pid that names no process at all (kill(pid, 0)→ESRCH) means the leg that wrote the file has exited — free; a pid that names something is a holder or a recycled stranger, indistinguishable — unknown; an unreadable pid — unknown. This does not reopen the pid recycling ADR 0015 refutes, which produced a false alive: pid-alive is never read as evidence here, so recycling can only move an answer to unknown, never onto a live run. And the acquire path is deliberately unchanged, so no answer derived this way can start a second leg by itself. The residual false-dead that cannot be mechanised away is a pre-flockleg inside a container read from outside it, where a namespace-local pid means nothing and the format records no namespace to check. The arm self-expires, but not by itself: the acquire path still refuses an unmarked lock under legacy semantics, so recovering such a run is two steps — the surfaces deriveABANDONEDand printresume … --retry-failed, that resume is refused with the exactrmfor the stale file, and only then does the retry take (and write) a marked lock, after which the flock alone decides forever. The human on that path is ADR 0015 §4's deliberate choice, not an oversight; what the hint cannot do is promise the second step away. -
An abandoned run is derived from that probe, never repaired into the feed (ADR 0015 §2). One rule, stated once in
internal/runstatusand shared by every surface: in flight = an open leg AND a held lock; abandoned = an open leg AND an affirmatively free lock; everything else is settled, and every doubt reads as in flight. No reader writes anything —events.jsonlkeeps only lines a scheduler emitted.Since ADR 0023 that rule produces ONE SIX-VALUED STATUS rather than a liveness answer each surface then combines with a verdict of its own making:
PLANNING → RUNNING → { PASS | FAIL | PAUSED | ABANDONED }PLANNINGis an open leg whose latestrun_startedcarriesphase: "planning"— anautorun inside its planner call, which now has a run id because the id identifies an EXECUTION, not the moment a graph starts executing (#163).PAUSEDis read off the closed leg'srun_finishedoutcome, never off the snapshot's gate block, which is what makes it cover ADR 0009's gate-less session-limit pause as well as a gate pause; both used to renderFAIL.PASSis the one value that requires the snapshot, so a settled run without one cannot claim it.Status.Settled()andStatus.InFlight()are both predicates, becausePLANNINGsplits the in-flight side —ResolveRunkeys onInFlight()and would silently stop preferring a planning run if written as an equality test.Two absences are NOT statuses and must not become them: a missing
state.jsonis a fact about the run (normal atPLANNING, permanent for a refused plan) and never damage, while an unreadable snapshot or stream is a fact about the READER — theWARNING+skip row and theunknowncard. Only the second may make a row disappear.A third case has no status at all rather than one of the six: a directory whose stream has said NOTHING — the instant between the run taking its lock (which creates the directory) and its first event, and permanently for a directory whose stream could never be created.
Deriveis total, so its default arm would call thatFAIL; every surface that renders a word asksrunstatus.Spokenfirst and falls back to the placeholder it already uses for what is not known yet (pendingon the card,-in the table, an omitted word inshow). A lonerun_finisheddoes not count as having spoken: a close with no open before it is damage, not a leg.runs listrenders the six words under aSTATUSheader (renamed fromVERDICT, which would have kept the very conflation the enumeration removes) and, under its total, one line saying how much of the run home the table covers: how many runs are shown out of how many directories were found, how many of them are in flight, and how many were skipped and under which reason (3 of 4 run(s) shown; 2 in flight, 1 abandoned; 1 skipped (…)). It is printed whether or not anything was skipped, because a reader must be able to tell "nothing was hidden from me" apart from "64 of 325 runs are shown" — a summary that appeared only when there was something to report renders those two cases identically. The in-flight clause is there for the same reason and is stated AT ZERO: "no run is running" and "the status column is broken" are what an emptyRUNNINGcolumn renders identically, and only the first is the conclusion an operator draws from it.showandwatchappend the same clause to their own status lines (run run-x is PASS; 0 in flight.) so one wording answers the question on every surface. The count isrunstatus.InFlightCountover statuses those surfaces already derived —Deriveremains the only place the open-leg-AND-held-lock rule lives, and no surface re-decides it.--show-skippednames each skipped directory and quotes the reader's own reason, one line each on stderr; that per-run detail used to be unconditional and was four fifths of the command's output on a long-lived run home. That per-run sentence isrunstatus.Unreadableand is the ONE wording for a directory nothing could read — the run id, which of the two provable classes the damage is in, and the reader's own error quoted whole — soruns list --show-skipped,show(above the table it can still print, since the snapshot loaded and only the status is missing) andwatch(in place of the status line, on stderr, with the tail running on) say the same sentence about the same directory. It states no consequence, because the consequence is the one thing the surfaces legitimately disagree about. The dashboard is the surface that neither counts nor reprints: it drops no run, so it has nothing to summarise, and its card carries the reader's error in a JSON field a machine reads — human prose is not added there.serve'sResolveRunstops preferring a corpse as "the run happening right now",watchrefuses to tail a stream that will never get another line, the dashboard paints the card abandoned, and the single-run live view — the surface that carries the gate button — reads the same answer off/api/graph(additiveabandoned/hintkeys, absent on every other run): its header saysABANDONED, the nodes its dead leg left open stop spinning and stop tailing that leg's transcript, and the hint sits above the feed the button lives in. That page's own stream reducer applies the same leg boundary the two Go reducers do — everyrun_startedends the previous leg's running nodes — and it renders that event'sphaserather than a word of its own, so the header chip readsplanningfor a planner leg exactly as the card linking to it does, and the feed marker names the phase exactly aswatchdoes. The leg that opens a run must hold the lock before its first event and after its last, or a starting run would read abandoned for its first instants; that ordering is stated atacquireRunLockand pinned byTestRunLeg_LockBracketsTheEventStream. -
The residual hazard is an orphaned
claude, and the mitigation is wording. Every child is spawned withSetpgid, so a death that took the engine alone (SIGHUP,kill -9, a panic, an OOM kill) leaves a subprocess still running and still spending while the run reads abandoned. There are two deliberate spenders on such a run —resumeand the gate button — so the recovery hint reaches every surface either is reachable from: theABANDONEDrow,watch's refusal,resume's own stderr, the card, and the run page the card links to, which is where the button actually is. Both must say what the click would allow before it is pressed. ADR 0015 rejects probing for the orphan; that would be a fifth exec seam. -
Recovery is
resume --retry-failed, and nothing new — except for a run killed before its first node settled, which has nostate.jsonand therefore nothing to resume from: its hint says "run the graph again", andresumeitself fails on it with that sentence instead of a bare "no such file".
Auto-planned graphs still may not contain gates. validatePlannedNodes
already rejects type: gate and continues to: an unattended run whose planner
decides where a human should be interrupted is not a feature, and it collides
with the deny-by-default field policy below.
serve [<run-id>] [--port N] [--no-open] is two views on one port,
depending on its one optional argument:
serve <run-id>is the live view of ONE run: a chronological run feed — what each node produced, why something failed — as the main surface, with the DAG as a compact collapsible side map (GitHub Actions' log-first layout, not Airflow's graph-first one: for this tool's runs the substance is in the node output, not the topology).servewith no run id is the dashboard:/renders one live mini-DAG card per run — in-flight first (state colour, elapsed, cost, node counts), settled runs in a collapsed list below — and each run's own live view is mounted at/run/<id>/, which is where a card click goes. Watching four concurrent runs is one process, one port and one tab instead of four of each.
The tool therefore renders its own runs: serve is the live view, runs list / show / watch the terminal ones.
Both pages state, in the footer, which build is serving them —
v0.5.2 (cef30c6, built 2026-08-09 14:02), the Label field of the one
serve.Build both pages are rendered with, put into the page once per process
alongside the gate token. A serve process outlives the tree it was built
from: it holds its port for as long as it runs and keeps serving the code it
was compiled from while bin/oh-my-graph is rebuilt underneath it, which from
a browser is indistinguishable from the new build misbehaving. The version cannot settle that on its own (every build
between two tags carries the same one), so the label also carries the VCS
revision the toolchain stamped and the running executable's own mtime — the
second because the first is absent from a -buildvcs=false build, a proxy
module build, and a build from a linked git worktree, which is how this
project's own graph lanes build.
The same three atoms are in each page's head, machine-readable, beside the
gate token: <meta name="omg-version">, <meta name="omg-revision"> and
<meta name="omg-built-at"> (the executable's mtime in RFC3339, from the same
single stat the footer's label reads). The label settles the question for a
reader and no one else — a stale server renders a stale label, so anything
comparing a server against the build it expects had to parse prose out of the
body. All three tags are always emitted, empty content meaning "unknown"; a tag
that is absent means a server older than this change, which is itself an
answer. They are disclosure only — no route, no state, nothing the page acts on
— and both surfaces are rendered from one serve.Build value, so a run view
mounted on the dashboard cannot name a different binary than the dashboard that
linked to it.
The structural rule is that rendering gets no privileged access to the
engine: serve is a consumer of the run-feed contract
(docs/RUN-FEED.md) in everything but its two gate routes, living in-repo
but on the same footing as an out-of-repo consumer — reading state.json
for structure and tailing events.jsonl for progress through the same
readers runs list and watch use (runfeed.InFlight, runfeed.Follow —
serve via its FollowWait wait-for-create variant, since a viewer may
connect before the stream exists). That is also why the contract is
documented and versioned rather than treated as an internal detail: the
in-repo views hold themselves to it, so any other reader of a run directory
gets the same guarantees. A stream schema newer than the
binary takes watch's posture, not runs list's: one non-terminal
warning frame, then keep forwarding (a list can skip one run; a live view
going blank would make a routine schema bump fatal, which RUN-FEED.md's
compatibility rule forbids).
serve is deliberately no longer strictly read-only (ADR 0014). Every
route reads except two: POST /api/gate/approve and POST /api/gate/reject
decide the gate a run is paused at, which continues the run — rewriting
state.json, appending to events.jsonl, and running the nodes the gate was
blocking. The boundary that moved is what serve is, not who may reach it:
the package still owns no gate logic (it calls the injected GateResumer,
which the CLI builds over executeResume — see "Gate nodes and resume"), it
still imports no os/exec, and a decision is valid ONLY while the viewed run
is genuinely paused at the named gate. A held resume.lock (a leg in
flight), a missing snapshot, Gate.PausedAt == "", a gate id that is not the
pending one, and a view with no resumer injected are each 409. Only the
standalone serve process injects a resumer: a paused run's process has
already exited (ADR 0003), so an embedded live view can never be looking at
one, and it answers 409 like any other view that cannot resume.
- Run resolution:
serve <run-id>resolves that id (serve.ResolveRun; a mistyped id is a clear error, raised before the listener binds). With no id there is nothing to resolve — the dashboard is the view of every run, including the ones that do not exist yet, so an empty (or absent) runs root is an empty dashboard that fills in when something runs, not an error. No production caller reachesResolveRun's in-flight-then-newest branches:runServecalls it only insideif flags.runID != "", and the embedded live view already owns the id it was mounted under, so it never asks. Those branches are kept, not dead-stripped — they are the package's answer to "which single run would a caller with no id mean", exercised byTestResolveRunand depended on bycmd/oh-my-graph's--plan-onlytest (which asserts a preview left nothing underruns/for an id-less resolve to land on).ResolveRun's own doc comment says the same; do not read them as the no-argument CLI path, which is the dashboard. - The dashboard subscribes to the runs ROOT the way a run view
subscribes to its
events.jsonl:/api/cards/eventssweeps the root at the same poll cadence and streams onecardframe per run that is new or changed, onecard_removedper deleted directory, andcards_readyafter the first sweep;/api/cardsis the same data in one read. "Changed" is the size-and-modtime of the two contract files, so an idle dashboard with forty settled runs costs two stats per run per tick and re-sends nothing. A card is derived through the existing readers — ONErunfeed.Walk(the one-shot counterpart toFollow) for per-node state and the leg boundaries, plusrunstate.Load+graph.Parsefor structure and cost. It does not callrunfeed.InFlight: one walk already carries the leg state, and a card is rebuilt for every changed run on every tick, so a second read of the same stream was doubling the I/O on the hot path.buildCardtherefore folds the leg state (runfeed.Leg's two booleans — the last leg is open, arun_startedwas seen at all) off its own walk, keeping it distinct from the two boundary TIMESTAMPS the same walk collects for the elapsed clock, and then hands it to the SHARED derivation (runstatus.Probe), which composes it with the lock exactly asruns list,ResolveRun,watchand the single-run view's/api/graphdo — the composition is stated once, not five times (ADR 0015 §2). The one duplicated half, the leg rule itself, is held by an enforced agreement rather than a structural one:TestBuildCard_AgreesWithTheSharedRulejudges the inline leg derivation againstrunfeed.InFlight, and the card's state andResolveRun's preference againstrunstatus.Of, which is what keeps a card from disagreeing withruns list,watchor the run's own view about the same run. A card's state is a MAPPING from the shared enumeration and nothing else since ADR 0023 (runStateused to compose its own answer from three facts, which is how a session-limit pause fell through to the default arm and painted red). Its vocabulary is the node vocabulary plus three:planning— a run inside its planner call, with no graph to draw yet, pulsing beside the running ones because that wait is the one #163 reported as invisible;paused— the RUN-level pause, wider than a node'sgate-pausedbecause it covers a session-limit pause too; andabandoned— a leg the stream left open whose lock is free, i.e. whose process is gone (muted, never red: nothing failed, the work simply has no verdict). A directory whose stream has said NOTHING keepspending, on that affirmative fact rather than on "has not settled" — under six valuesFAILis settled, so the old guard would have left every refused plan readingpendingforever. Nodes that leg left running render abandoned rather than spinning forever and tally as pending, and the card carries the recovery hint, because the page it links to has a gate button that starts a leg with one click. Cost is the snapshot's per-node total, the same accountingruns listprints. A run directory this binary cannot read renders as anunknowncard carrying the reason rather than being dropped:runs listcan skip a broken run because a table can — it says how many it skipped and why, in the coverage line under its total — but a dashboard that silently omitted one would be lying about what is on the machine. The token itself is a contract between four files with no compiler between them —card.gochooses the word,dashboard.csspaints the tile's stripe,style.cssowns the colour token and the chip's dot,dashboard.jsdecides whether a card carrying it is live and whether it is counted at all — sointernal/serve/assets_test.goderives the whole token set from the enumeration and reads the three embedded assets back, rather than leaving the agreement to comments. That is also what holds the page'sLIVE_STATEStoStatus.InFlight(): a page with no build step cannot import the predicate, and a hand-written equality test over the in-flight values is the exact shape ADR 0023 removed from the Go side. /run/<id>/mounts the single-run view unchanged. Endpoints are path-scoped (/run/<id>/api/...) rather than query-scoped because that is by far the smaller diff: the page already fetches with document-relative URLs (api/graph,api/events,api/result,api/transcript,api/gate/*) and linksstyle.css/app.jsthe same way, so mounting the existing route set (Server.routes) under the prefix reaches every endpoint AND every asset with zero UI changes. A?run=<id>scheme would have to be threaded through every fetch and theEventSource, and would not scope the static assets at all. The run id in the URL is matched against the runs root's DIRECTORY LISTING before any path is built from it (runInRoot) — exactly as strict as/api/result's node-id check, one level up: a typo and a traversal probe are the same 404, reached without a single path join. The gate token is the serving process's, so the dashboard page and every run view mounted under it carry the same one.- 127.0.0.1 only (
serve.Listen, default port 8642): run directories hold prompts, artifacts and session ids, so the server must never be reachable off-host. The loopback bind IS the access control for reading; widening it would need an auth story first. Covered by a test on the bound listener address, not just config. Because the bind is the access control, requests whose Host header is not127.0.0.1/localhostare rejected with 403 (requireLoopbackHost) — otherwise a hostile page could DNS-rebind a domain it controls onto 127.0.0.1 and read/api/*through the victim's own browser. Neither guard covers a mutating route — a page the user is already visiting can POST tohttp://127.0.0.1:8642/with a perfectly legitimate Host — so the two gate POSTs additionally require a per-process random token (crypto/rand, hex), minted inserve.New, rendered into the served page (the one asset not shipped byte-for-byte) and sent back asX-OMG-Token: missing is 400, mismatched is 403, compared in constant time. Both are refusals — no shape of a gate POST reaches the resumer without the token. It is a CSRF guard, not a login — a custom header also forces a preflight, which a cross-origin form POST cannot satisfy. Layered in front of it, as hardening rather than as a fix: a POST whoseOriginnames anything but this server's own origin is 403 (requireSameOrigin), so a decision from a page this process did not serve is refused on its provenance before its token is weighed, and independently of the token staying secret. An absentOriginis allowed through — curl and the CLI's own tests send none, and the token remains the whole guard there — so the check only narrows what a browser can do. - Zero runtime network dependencies: one static page embedded with
go:embed— hand-written JS/CSS plus a pinned, vendored cytoscape.js (internal/serve/ui/vendor/README.mdrecords its version and MIT license). No build step, no npm, no CDN. - Spawns nothing itself. The
internal/servepackage imports noos/execand starts no process; the two processes its features imply are reached through injected seams the CLI builds — a resumed leg's nodes throughrunner.CLIRunnerbehindserve.GateResumer(ADR 0014), so a gate decided in the browser spawns exactly whatoh-my-graph resumewould. The server itself never shells out toopen/xdg-open; browser-open lives behind its own seam —browser.Opener, the fourth exec seam (ADR 0006) — and only the CLI wires it. A leg whose stdout is a terminal — a freshrun/auto, or aresumeof either — embeds this server (sameserve.Listen/handler/serveRunlifecycle, ephemeral loopback port) for exactly that leg's duration, prints the URL asservedoes, and opens it through the injectedExecOpener;--no-webopts out, a non-terminal stdout (scripts, CI) gets no server, no browser, and byte-identical output. A resumed leg's view reads the same run directory the first leg's did, so it shows the whole run's history, not just this leg's. A chat graph turn stays un-wired (ADR 0006). The standaloneservesubcommand takes the SAME gate: it prints the URL and, when stdout is a terminal and--no-openwas not passed, hands it to the injectedExecOpener—--no-openisserve's name for--no-web's opt-out, and a non-terminal stdout reaches no Opener at all, leaving its output byte-identical to before. - The graph structure appears when it is known, and that is before the first
node, not after it:
run/autowritesstate.jsonup front (runstate.SnapshotRecorder.WriteInitial) and again after every terminal verdict./api/graphreports the structure unavailable only while a run legitimately has no snapshot at all — anautorun still inside its planner call, or one whose planner reply was refused (docs/RUN-FEED.md). The UI polls; events stream from the start. - Node results:
/api/result?node=<id>serves that node's handoff artifact (<run-dir>/<node-id>.out) as text/plain for the feed's settled entries — the id is matched against the snapshot's own node set before any filesystem use (unknown id → 404; known node without an artifact → 204 "no result yet"). - Live transcript tail:
/api/transcript?node=<id>serves a RUNNING node's "now doing" — the last ~30 human-relevant entries (assistant text + tool-use names) of the session transcript named by the pre-assignedsession_idthe feed published onnode_started/node_retried(docs/RUN-FEED.md); the UI polls it onto the node's open feed line every few seconds and drops it on settle. This is serve's ONE read outside the run directory — into the user's own~/.claude/projects— and only for the run's own sessions: the file read is named by the feed-published, shape-checked UUID (found by session-id filename, not by reproducing the CLI's undocumented cwd-to-dirname mangling), never by URL input. The node id gets/api/result's membership guard, with one widening: while no snapshot exists, the run's own feed vouches instead. (Defensive rather than load-bearing: a run with a graph writesstate.jsonbefore its first node starts, so the snapshot-less shapes — anautorun inside its planner call, or one whose plan was refused — are the ones running no node at all.) Not running / no session id (a gate, a session-handoff node) / no transcript yet → 204. That transcript is claude's file, so the tail is a Claude-runtime supplement: on a run whose snapshot names a runtime that keeps no such file,/api/graphcarries atranscript_noteand the page renders that one sentence in the tail's slot instead of polling an endpoint that could only 204 (#178). That field is the view's ONE runtime branch — computed server-side inserve.transcriptTailNote, so the page itself stays runtime-unaware — and the honest line is deliberate: an empty tail is indistinguishable from a node that has not printed yet, which is the harm that made the silence a bug. - Deciding the paused gate is the view's one action: the gate the run is
parked at carries approve/reject buttons on its feed entry and nowhere
else. They are derived from the stream like the rest of the feed — a
gate_pausedwith no latergate_approved/gate_rejectedfor that node is the actionable state — so a reconnect's full replay puts them back on exactly the gate still waiting. A POST is answered 202 the moment the leg starts (a leg runs for minutes; the run feed is the progress report, streaming into the SSE connection the page already holds open — which is why/api/eventsdeliberately does not end atrun_finished), and the leg is detached from the request so closing the tab does not kill it. Theoh-my-graph resumecommand stays on the entry as secondary text: it is still the way in from an embedded view. - Scope: the dashboard is a card wall over the run directories and the
single-run view behind it — no history browsing beyond what is on disk, no
login (the gate token is a CSRF guard, not auth), no config file, no
WebSocket (SSE over the append-only stream and the runs root is the whole
transport). A card's mini-DAG is hand-drawn SVG (layered by depth, one dot
per node, one line per
depends_on) rather than a cytoscape instance per card: a dashboard can hold dozens of cards, and this is orientation at a glance — the real map is one click away.
oh-my-graph auto "<goal>" [--plan-only] [--verify-cmd 'CMD'] [--verify-timeout D] [--accept-no-build-evidence] [--accept-loaded-user-config] [--input k=v ...] is the
zero-config path; custom
YAML stays the precise-control path.
Zero-config is not zero-evidence in a repository that builds. Because a
planned node can carry neither success_check.verify (the planner's reply is
untrusted input) nor a build tool (plannedToolAllowlist is exact-matched), an
auto run without --verify-cmd has by construction no engine-run evidence:
the only terminal predicate left is result_matches on a node's own reply, and
that node wrote the reply. So since ADR 0030 the question is asked at launch,
once, before the planner call: if DetectBuildSignals(".") finds a marker and no
--verify-cmd was supplied, auto refuses — exit 3, on stdout, naming the
marker and a suggested command — unless --accept-no-build-evidence states that
this run carries no build evidence. A directory with no build signal is not
gated: there is nothing for an evidence command to be evidence about, and
requiring a flag there would be friction with no defect behind it. The answer —
attached, declared, disclosed or none-detected, with the markers detected
— is written to the run's state.json (build_evidence) and stated on the plan
screen, so a reader of a finished run can tell a chosen absence from an
accidental one. run (hand-written graphs) and resume are not gated; chat
asks and never refuses, because it registers no verification flag a refusal could
name (ADR 0030 §2.6). This amends ADR 0016 §3 in one direction only: a repository
file may now cause the tool to stop; it may still never cause it to run, widen
or attach anything. Planning a graph is ONE
planner call through the same NodeRunner seam every node uses (CLIRunner:
env scrub, read-only plan permission mode, never the Agent SDK) — the
Coordinator makes exactly that one call per PLAN — per cycle, not per auto
run — plus at most one more for that same plan:
a plan refused by validation buys ONE corrected call (maxPlanRepairAttempts,
internal/coordinator/repair.go), so the planner-call ceiling is 2 per plan
and 2 × N for --max-cycles N. --plan-only stops
the sequence immediately after the topology print, so that one call is all it
makes and no node runs — the inspection path for the mappings and the ceiling,
and deliberately NOT free the way run --dry-run is: there is no plan to
inspect until one has been bought, which is why the stop line prints its cost
and the paid-for spec is kept — in $OMG_HOME/plans/<id>/graph.json, never
under runs/. The reason for that is no longer the mechanism one (a directory
with no state.json reading as damage — ADR 0023 §2.5 dissolved exactly that,
and such directories are now ordinary): a preview has none of the six statuses
and cannot be given one. It is not PLANNING (it finished), not RUNNING,
not ABANDONED (its process left on purpose), and it has no verdict about
work, because there was no work. So --plan-only mints no run id at ANY point,
its planner call included. It is rejected with --max-cycles ≥ 2, since
every cycle after the first is planned from the previous cycle's run. (Interactive chat
reuses the same Coordinator but adds a routing call per turn before planning;
see "Ambient chat".) The planner asks
claude to reply with a graph spec as a JSON object (name / nodes / depends_on /
prompt / allowed_tools / handoff). JSON is a YAML subset, so the reply is
loaded through the existing parser, normalization, and DAG validation — a
VALIDATION-REFUSED plan buys the one corrected call above, and if that reply is
refused too the whole step fails before anything runs, with what it paid for
kept as rejected.json (its own name, so nothing walking the tree for
graph.json mistakes it for a graph the engine would run). WHERE it is kept
follows one rule (ADR 0023 §3.1): into the run directory when a run leg
already exists, and into $OMG_HOME/plans/<id>/ when none does. auto and
every goal cycle have committed to execute before their planner call, so their
run id exists and the refusal is recorded beside it, where it reads FAIL — the
engine judged the material it was given and diagnosed it. --plan-only has the
diagnosis without the commitment, so its rejection goes to plans/, which
thereby keeps one honest meaning: specs that never belonged to a run. The
corrected reply is UNTRUSTED exactly like the first: same graph.Parse, same
validatePlannedNodes, same mapping order — there is no shortcut for "it
already failed once". Only a refusal the reply's own CONTENT caused is
retryable: a runner error, a non-zero planner exit, a reply with no JSON
object, a reply whose JSON does not decode, and any other non-content failure
stop the step with no second planner call, because there is nothing precise to
hand back and a blind retry on a paid runtime is not a repair.
Auto-specific guards, enforced in
coordinator.validatePlannedNodes (not just requested in the planner prompt):
a planned node may NOT request permission_mode: bypassPermissions
(hand-written YAML may opt in per node because the user reviewed it; an
unreviewed plan may not); a planned node may NOT set cwd (it always runs in
the invocation's working directory, so an unreviewed plan cannot reach into an
unrelated checkout or a path under $HOME; this bounds where it can act and
does not by itself make a write-capable node safe); and every planned node's
allowed_tools must be a non-empty list drawn only from
coordinator.plannedToolAllowlist (Read, Glob, Grep, Edit, Write, and a small
set of scoped Bash(<prefix> *) patterns) — anything else (bare Bash,
Bash(*), an un-scoped Bash(rm -rf *), unrestricted WebFetch/WebSearch,
an empty list) fails Plan with a *PlanError naming the node and the
offending tool.
After validation — and only after — the coordinator may map planned nodes onto
the user's own Claude Code agents (internal/coordinator/agentmap.go): a scan
of ~/.claude/agents only — never a project directory, the same cut skill
staging has always had and for the sharper reason measurement (l) recorded — a
conservative name-token match between node id and agent name (exactly one
candidate or nothing), and a refusal to map any agent whose frontmatter tools
exceed the node's own allowed_tools. A mapped node runs --agent <name> and
keeps every ceiling layer, Layer 1 included (ADR 0022, 2026-08-12): the
matched definition is copied into <run-dir>/agents-plugin/agents/<name>.md,
pinned by its plan-time SHA-256, and supplied with --plugin-dir, which reaches
the node without reopening --setting-sources. Until then a mapped node dropped
Layer 1 and was measured to lose its scope ceiling with it. Every decision is
shown in the printed plan, and --no-agent-mapping turns it off run-wide while
--no-agent <name> declines a single agent. So does
--accept-loaded-user-config, which sets agentMappingOff itself: the staging
guarantee holds only while Layer 1 is "", so a run that carries the operator's
settings maps no node at all and the CLI discovers their agents natively (ADR
0032 §2.4, below). The full rule and what it now costs
the node live in "Node-as-subagent"; the raw plan itself still may not carry
agent:.
Strictly after agent mapping, the coordinator stages the user's own Claude
Code skills for planned nodes (internal/coordinator/skillstage.go, ADR 0017).
The mechanism is the CLI's own description-driven activation, not a plan-time
choice: trusted code scans ~/.claude/skills/*/SKILL.md only (never a project
directory and never a plugin's — both surfaces stay cut, and the plan printout
names them as out of scope on every run), copies the whole corpus into
<run-dir>/skills-plugin/ as a Claude Code plugin, and gives each planned node
--plugin-dir <staged> plus Skill in its --tools list. Two layers and only
two: --plugin-dir is not a ceiling layer at all (it supplies definitions and
grants nothing), and Skill enters at Layer 3, through the one function
that builds that list. Staging happens for a run that stayed isolated;
--accept-loaded-user-config sets skillActivationOff for the same reason it
sets agentMappingOff (below), so an opted-in run stages no corpus.
Layer 1 stays --setting-sources "" — ADR 0017's
measurement (g) showed that relaxing it lets a node that declared Bash(git *)
run an out-of-scope command, because --tools bounds tool NAMES and not
SCOPES. Layer 0 does not move either: plannedToolAllowlist never learns the
word, so a plan may not DECLARE Skill, and the grant is invisible in
graph.json — its durable record is state.json's tool_policies.
The staged directory is not protected by its location. Write is unscoped in
plannedToolAllowlist and a node runs as the same uid, so no path this process
creates is unwritable by it. The requirement — a node must not be able to
stage a skill for a later node — is met within a leg by lifetime: a
manifest of every staged file with its source SHA-256 is taken at plan time and
held in memory, and the directory is re-materialized from that manifest
immediately before every node spawn (a NodeRunner decorator,
coordinator.GuardStaging), deleting whatever the manifest does not name and
restoring whatever no longer hashes to it. The nodes read the staged copy, so a
source skill edited or deleted mid-run changes nothing and stops nothing; only
a staged file that must be restored while its source no longer holds the
planned bytes fails a spawn, and the message attributes that to the engine
rather than to the node. Across legs there is no claim, so there is no
activation either: the only manifest a resumed leg could re-stage from is the
sidecar in a run directory a node can write, and its per-file SHA-256 does not
close that, because one actor authoring both source and sha256 satisfies its
own check. Closing it needs an integrity anchor outside the run directory, which
this build does not have, so since 2026-08-07 resume drops Skill and
--plugin-dir from every rehydrated policy and prints why (ADR 0017 §6). It
never rehydrates the directory path verbatim — a --plugin-dir pointing at
nothing is accepted by the CLI with exit 0, so absence is indistinguishable from
a model that chose no skill, which is exactly why the de-escalation is
disclosed on the leg that makes it.
What is lost, permanently: the plan can no longer say WHICH skill a node will
use, because the model chooses at run time by description. What replaces it is
a per-run corpus disclosure — every staged skill with size and SHA-256, the
nodes reached, the agent-mapped nodes excluded, and the per-invocation prompt
cost, which every node pays on every retry and every feedback re-run.
--no-skill-activation turns it off, on auto and on resume alike
(de-escalation only, so no resumed leg can widen a run's ceiling);
--no-skill-mapping is accepted as a deprecated alias with a printed notice.
ADR 0012's plan-time inlining — the name-token matcher, the 16 KiB cap, the
{{ neutralization and the nonce fence around skill bodies — is deleted in the
same change: the two must never coexist, or a node would receive the same skill
twice and become unattributable.
What is delivered and what is used are not the same thing, and only the first
is established. ADR 0017's acceptance test (2026-08-07,
docs/measurements/0017-skill-activation-acceptance{,-run-2}.md) confirmed on
real spawns, by an argv-recording shim, that every activated node is launched
with --setting-sources "", --plugin-dir <staged> and Skill in --tools,
against a clean --no-skill-activation control. It also recorded 1 Skill
invocation across 7 activated planned nodes, and zero across the three nodes
of the pre-registered run, under prompts the planner itself wrote — while a
prompt that names a skill fires reliably.
One sentence is why that number is no longer the number (2026-08-08,
44 spawns, docs/measurements/0017-skill-activation-yield.md). Trusted code
appends a fixed, skill-agnostic sentence — "A corpus of procedures is
available through the Skill tool; consult it if one fits this task." — to the
prompt of every node it activates, and only those: an agent-mapped node, which
is excluded from activation, is never told a corpus it does not have exists.
It names no skill and no directory, so it announces THAT a corpus exists and
never WHICH one to use; the choosing stays in the node's own model, at run
time, through the CLI's description gate. The same planner prompt scores 0 of 9
activations without it and 8 of 9 with it, all 8 reaching for the same real
skill of the user's own corpus. It is not written into the saved
graph.json — that artifact is re-runnable through run, which has no staged
plugin and no Skill tool — which is why activation is the LAST post-validation
mutation, after every step that writes the spec.
The yield is real; the benefit is still not measured. On the one task where the
deliverable could be checked mechanically the two arms were indistinguishable,
and a node whose prompt is an output contract (a verification node's
PASS/FAIL) does not activate with the sentence or without it. ADR 0017
stays Proposed for that reason and this section describes a mechanism whose
yield is measured and whose value is not. One further
consequence to know when reading the two mapping steps above: they are
mutually exclusive. Agent mapping runs first and an agent-mapped node is
excluded from activation, so the nodes whose job matches a named role most
cleanly — the design and doc nodes — are the ones a skill cannot reach.
That exclusion is total, and it is measured (2026-08-09, 10 spawns,
docs/measurements/0017-agent-mapped-nodes-cannot-invoke-a-skill.md): an
excluded node's --tools never carries Skill, so it invokes no skill by any
route — the definitions its nil setting sources load (user, project and local
alike; measured at both project and user scope) are visible to the CLI and
unreachable by it, and permission_denials is empty because the tool is absent
rather than denied. The exclusion is kept anyway — the --agent +
--plugin-dir + settings composite is unmeasured, and a staged plugin would
meet the user's own plugins there for the first time — but the plan printout
now states the cost and names --no-agent-mapping (which turns agent mapping
off for the whole plan), and lifting it is gated
on ADR 0017's measurement (j) rather than on the argument that it costs little.
(j) was run on 2026-08-12 — 21 spawns, $4.16, claude 2.1.228 — and the
exclusion stays (docs/measurements/0017-lifting-the-agent-mapped-exclusion.md).
Not because the composite fails: --agent + --plugin-dir + Skill invoked
the staged skill 3 of 3, and it costs the ceiling nothing because an
agent-mapped node's ceiling is already breached without it. It stays because
Skill on these nodes resolves against a corpus the repository under work
can write: with the staged copy and a repository-committed .claude/skills copy
loaded under one name, the bare name resolved to the repository's 3 of 3, and a
repository-committed SKILL.md was invoked 3 of 3 by a prompt that never
mentions skills. Both candidate fixes carry that, since both keep Layer 1 at
nil — so the thing to change is applyAgentMapping's SettingSources, not
the exclusion.
That change was measured and shipped on 2026-08-12 (ADR 0022), and it moves
the ground under this paragraph without moving the exclusion. A mapped node's
Layer 1 is now "" and its agent arrives from a staged --plugin-dir, so the
repository-supplied definitions the paragraph above rests on no longer load: the
repository's .claude/skills copy fired 0 of 3, and where the model called
Skill the CLI answered Unknown skill: …, is_error: true
(docs/measurements/0017-staged-agent-restores-layer-1.md). The exclusion is
still in force — applySkillActivation still skips an agent-mapped node — but
it is now a decision nobody has re-taken rather than a refusal with a number
behind it, and re-deciding it needs its own record. The other half of the
paragraph is unchanged and still true: an excluded node's --tools carries no
Skill, so it invokes none by any route.
What shipped instead of the lift is disclosure and a cheaper way out. The
exclusion's cost — no Skill tool — is printed per node, by name, on the
plan screen; and --no-agent <name> (WithoutAgentsNamed) declines a single
agent while every other mapping stands. (Until ADR 0022 that per-node line
carried a second cost, a declared scope enforced only as far as the user's own
settings enforce it, and noteCeiling carried a matching ceiling exception.
Both are gone with the fact they described — a warning kept past its cause
teaches a reader to discount the next one — and wiring_test.go asserts their
absence.) The agent is the unit because
it is the only identifier that exists before the planner is paid: node ids are
bought, agent names are the user's own files, and the plan names the agent on
the node line it took. The decline is applied after candidateFor picks a
single candidate, never by removing the definition before matching, so it can
only ever remove a mapping — dropping it earlier would let declining one of two
ambiguous agents promote the other and create a mapping. A declined node keeps
Layer 1 and is therefore activated like any other planned node: exactly the
configuration (j)'s ACT/G-ACT arms measured, which held the ceiling and
invoked the staged skill under an attributable name — and, in the post-hoc
X-ACT arm, kept that name 3 of 3 under the same three-way collision that
beat it on a mapped node, so "attributable" here is measured against
competition rather than in its absence.
The last thing computed with a plan is a warning rather than a decision. If the
goal or a planned prompt names an absolute path that resolves into a git
checkout outside the invocation repository, Plan.Unisolated
(internal/coordinator/unisolated.go) carries it and the plan printout says —
before anything spends, and in the same output --plan-only renders — that
oh-my-graph isolates nothing there: no worktree, no lock, so a node working in
that checkout must create its own worktree or race whatever else is standing in
it (#103). The boundary is one of ownership, not of protection: auto
rejects cwd: and worktree: at plan time, so it provisions no managed
worktree anywhere — the invocation repository included, where planned nodes edit
and commit directly — and what the reported checkouts have that it does not is
that the user did not open them for this run. The printed text may not read as
if the invocation repository were the isolated one. The rule is deliberately
narrow: a mention must resolve into a .git checkout (an ordinary clone or a
linked worktree, both of which have their own HEAD), which is what keeps /tmp
scratch paths, templated paths, files that do not exist and every path inside
the invocation repository silent; a checkout that is a tool installation
rather than a work tree is dropped as well (rooted under /usr, /opt,
/Library, /System, /nix, or under a dot-directory of $HOME — Homebrew,
nvm, oh-my-zsh, a plugin marketplace and a chezmoi-managed ~/.config are all
real clones, and a warning about a HEAD nobody will switch is the line that
teaches the reader to scroll past the block); and one warning is emitted per
checkout rather than per path, since the hazard is a shared HEAD. It is computed on the planner's own
prompts, so absolute paths a local SKILL.md happens to document — which now
reach a node through the staged plugin rather than through its prompt — are
never attributed to the plan. It is a WARNING and never
a refusal — a multi-repository goal is legitimate, and the engine simply cannot
isolate it — and the printed text states its own blind spots (a path built at
run time, one arriving through --input or a parent's artifact, a repository
reached by a relative path, a tool installation's own clone, and what a node
actually does once it is there), because a heuristic that reads as complete is
worse than no heuristic at all.
The allowlist above bounds what a plan may declare. Execution is bounded by
a separate, layered policy carried on Plan.ToolPolicies (one
runner.ToolPolicy per node id), handed to schedule.Options.ToolPolicies and
rendered by the runner. One value object rather than parallel maps, so a caller
cannot pass three quarters of a ceiling:
// internal/runner
type ToolPolicy struct {
AllowedTools []string // --allowedTools
DisallowedTools []string // --disallowedTools
Tools []string // --tools (nil = flag omitted)
SettingSources *string // --setting-sources ("" = load none; nil = flag omitted)
StrictMCPConfig bool // --strict-mcp-config
PluginDirs []string // one --plugin-dir <dir> per entry, in order
}| layer | mechanism | closes |
|---|---|---|
| 0 declaration | plannedToolAllowlist, plan-time rejection |
a plan asking for Bash(*) |
| 1 isolation | --setting-sources "" (nil, flag omitted, under --accept-loaded-user-config) |
the user's standing grants; settings hooks |
| 2 grant | --allowedTools "Read,Bash(git *)" — the allow rules themselves |
scoped Bash |
| 3 narrowing | --tools "<bare names declared>" |
tools the model can even attempt |
| 4 MCP | --strict-mcp-config, no --mcp-config (false, flag omitted, under the same opt-in) |
mcp__<server>__<tool> |
| 5 residual | --disallowedTools (PR #5's list, retained) |
anything the above missed |
The table is the default — the run that types nothing. Layers 1 and 4 are the two an operator may decline together, at launch, and nothing else in it moves; "The operator's opt-in" below is the whole of that difference.
The permission mode is a separate axis from the table, and ADR 0034 moved it.
The default is now auto where it was dontAsk, and that changes exactly one
layer's reach: layers 0, 1, 3, 4 and 5 bind precisely as before, layer 2's argv
is byte-identical, and what moved is the disposition of the calls that argv does
NOT name — from a categorical deny to a question put to the CLI's own model
classifier. Default-deny became default-classifier, at layer 2 and nowhere
else. Two layers gain weight rather than losing it: layer 3, because --tools
removes a tool outright and leaves the classifier nothing to adjudicate, and
layer 5, because an explicit deny is evaluated ahead of the classifier and is
now the only categorical refusal in the stack.
PluginDirs is the sixth field and not a sixth layer: it ADDS definitions —
a staged skill corpus (ADR 0017) or the one agent a node was mapped onto
(ADR 0022) — and grants no capability, since whatever it supplies still runs
inside the five layers above (a Skill tool exists only when layer 3's Tools
names it, and a staged agent's frontmatter does not widen past --tools, E6).
It exists because layer 1 stays "": --setting-sources "" withholds the
DEFINITIONS along with the settings, and a plugin directory is the one source of
definitions that does not reopen it. Empty omits the flag, which is every
hand-written graph, every opted-in planned run (which neither activates nor
maps, since layer 1 is what makes staging worth anything) and every planned run
that neither activated nor mapped; a
resumed leg is emptied deliberately, since rehydrating the path would trust a
directory the previous leg's nodes could write (ADR 0017 §6, below).
Layer 1 is the load-bearing change. Rules from ~/.claude/settings.json are why
--allowedTools could never bind: they are matched alongside ours and a standing
Bash(*) wins. --setting-sources "" loads none of user/project/local settings,
leaving our argv as the only allow-rule source; enterprise policy settings are
still loaded and still cannot be escaped — nor can they be escaped by omitting
the flag, which is why the opt-in below can widen everything it widens and still
not reach a managed policy. Combined with dontAsk — under which
an unmatched call resolves to ask and an unanswerable ask becomes a deny —
Bash(git *) meant git and nothing else. Measured, not inferred (E1): the
identical node declaration ran an out-of-scope touch without Layer 1 and had
it denied with Layer 1, while in-scope git kept working. E1 ran under
dontAsk, and only one of its halves depends on that. The load-bearing half is
Layer 1's: isolation is what stops the user's standing Bash(*) from matching
before the node's own narrower grant, and that is true under any mode. The
denial half is the mode's, so under today's auto default the same
out-of-scope touch reaches the classifier instead of being refused outright,
and whether it survives that is unmeasured. The gap "a node
declaring a scoped Bash(...) keeps the whole Bash tool" is closed for
planned nodes — including the agent-mapped ones, since 2026-08-12. Those
used to drop Layer 1 to nil so --agent could resolve (E2), and for them it
was therefore not closed at all: on 2026-08-12, claude 2.1.228, the argv
runner.buildArgs then emitted for an agent-mapped planned node declaring
Bash(git *) ran the out-of-scope touch under dontAsk with
permission_denials: [], twice, while the same probe's non-mapped node denied
the identical command. ADR 0022 closed it by changing where the definition
comes from rather than which flags bind: the matched agent is staged into
<run-dir>/agents-plugin/ and supplied with --plugin-dir, which reaches the
node with Layer 1 still "". Same machine, same build, minutes apart, the
identical arm was denied 3 of 3 with the refusal named in
permission_denials, and the in-scope git init control ran 2 of 2
(docs/measurements/0017-staged-agent-restores-layer-1.md). That measurement
re-confirms E2 and widens it: under --setting-sources "" the CLI's own list of
agents it can see is five built-ins and neither the user's nor the repository's
directories, which is why the definition has to be staged at all. Staging is
then a channel of its own, and the scan feeding it is ~/.claude/agents only:
while it also read <cwd>/.claude/agents, a definition committed to the
repository under work was the one staged, and its system prompt ran the node 2
of 2 (docs/measurements/0022-repo-planted-agent-and-the-agents-only-dir.md).
Layer 1 also closes the settings-hook gap: a node that writes
.claude/settings.local.json into the invocation directory achieves nothing,
because no node in this run (or any later auto run) loads local settings —
unless that run typed --accept-loaded-user-config, where local settings are
loaded on purpose and this gap is open again by the operator's own statement.
Layer 3 is a genuine replacement of the built-in tool set, not an addition to
it (E4): a tool omitted from --tools does not exist for the node, and naming
it in --allowedTools does not bring it back. So the two compose as an
intersection — --tools decides what exists, --allowedTools what is
permitted — and Layer 3 must list every tool the node needs.
Layers 3 and 5 are deliberate redundancy, not belt-and-braces theatre: they are independent mechanisms, so a wrong assumption about any one layer degrades to the previous behaviour rather than to nothing.
Remaining honest gaps, unchanged by this work: skill/slash-command surfaces are
still not enumerable — though ADR 0012 and then ADR 0017 measured the
planned-node case layer by layer: Layer 1 withholds the skill DEFINITIONS and
Layer 3 withholds the Skill TOOL, which is why activation needs both a staged
--plugin-dir and Skill in --tools. Layer 1's half of that is now measured
under competition rather than assumed: with a same-named skill in the working
repository's .claude/skills and in a plugin that repository's own
settings.json enables, an activated node still resolved the bare name to the
STAGED copy 3 of 3 (measurement (j), arm X-ACT), while a mapped node — nil
Layer 1 — lost it to the repository 3 of 3. Layer 4 is unverified (E5 —
--strict-mcp-config ships because it is free, not because MCP closure was
observed); and dropping user settings also drops the user's CLAUDE.md, hooks and
MCP servers for planned nodes — a behaviour change that makes planned nodes
more isolated and less capable than they were, which is the intended direction
but must be stated in the README rather than discovered, and which the operator
may decline for one run with the opt-in below. Through v0.6.0
agent-mapped nodes inverted that last sentence for the SETTINGS half: nothing
was dropped for them, so the user's CLAUDE.md and hooks loaded — and so did the
working repository's project scope, which is where the repository-supplied skill
above came from. Since 2026-08-12 (ADR 0022) they do not: the mapping is
carried by a staged definition and a --plugin-dir instead of by nil settings,
so a mapped node drops exactly what every other planned node drops and is
measured under the same E1. What was never part of that difference is layer 4:
it is a flag rather than a settings scope, so --strict-mcp-config was on a
mapped node's argv exactly as on any other planned node's, with no --mcp-config
beside it, before the change and after it (measurement (j),
argv/omg-probe-writer.argv.txt). Whether that flag actually closes MCP is E5,
and E5 is unmeasured — the sentence above about MCP servers is a statement about
the argv, not an observed closure.
The ceiling above is what a planned node gets by default. auto takes one flag
by which the operator — never the plan — states that this run's planned
nodes carry their own CLI configuration. It takes no value, defaults to OFF, and
a run that does not type it is byte-for-byte the run that shipped in v0.10.0:
same argv, same screens, same state.json.
It is runtime-neutral, because the mechanism is one bit both protocols
already branch on (spec.Policy.SettingSources != nil in
codex_protocol.go, policy.SettingSources != nil in claude_protocol.go).
toolPolicyFor builds:
| layer | field | default | with the opt-in |
|---|---|---|---|
| 1 isolation | SettingSources |
&"" |
nil (flag omitted) |
| 2 grant | AllowedTools |
node's declaration | unchanged |
| 3 narrowing | Tools |
narrowedToolsFor(node, …) |
unchanged |
| 4 MCP | StrictMCPConfig |
true |
false (flag omitted) |
| 5 residual | DisallowedTools |
disallowedToolsFor(node) |
unchanged |
So an opted-in planned node's setting/config posture is exactly a hand-written
run node's, and its tool posture is exactly a planned node's. Layer 4 moves
with layer 1 deliberately: E5 is unmeasured, so leaving --strict-mcp-config on
would make the disclosure's "your MCP servers load" a sentence nobody had
checked. That layers 3 and 5 still bind under restored settings is ADR 0032 §8's
required measurement, which does not exist — the ADR is Proposed until it
does, and this paragraph's last claim is a projection.
WithLoadedUserConfig() sets agentMappingOff and skillActivationOff in the
Option, not at the call site, so no later composition of options reopens them:
both ADR 0017's and ADR 0022's guarantees are held by layer 1 being "", and
measurement (j) arm X resolved a three-way name collision to the repository's
own copy 3 of 3 under nil. The CLI discovers the operator's agents and skills
natively instead; what is given up is the staged copy's attributable name and
its shadow-proofness.
The choice is disclosed on the plan screen through a note* sibling of
noteCeiling and noteMissingBuildEvidence — one slot that prints the
isolated sentence or the loaded one, never both and never neither, with a
different literal per runtime because the bill differs (on Claude the operator's
standing grants come back with their settings, so a declared Bash(git *) is a
declaration again; on Codex --sandbox and approval_policy="never" are argv
outside the branch and the flag widens neither). There is no new snapshot
field: inside a non-empty ToolPolicies map an absent setting_sources is
unambiguous, so the disclosure predicate reads the policies about to be spawned
and cannot drift from the argv. resume therefore inherits the choice and
reprints the line before the banner while registering no flag of its own — a
resumed leg's flags may only de-escalate (ADR 0017 §6, one direction over).
Out of scope in every direction: run is untouched (Options.ToolPolicies is
nil there and TestScheduler_HandWrittenGraphGetsNoCeiling passes unmodified);
chat parses no such flag, so every chat-planned node stays isolated; the
planner already ran unisolated and the assessor keeps layer 1 unconditionally,
because its input is untrusted model output; the graph schema gains nothing, so
no planner can request this per node; enterprise and managed settings are
unioned on top and cannot be dropped by an argument's absence; and
internal/childenv is untouched — one list, no runtime branch, still scrubbing
all four API-key variables from every child. A restored configuration is not a
restored API key.
agent: on a planned node would let an unreviewed plan choose which of the
user's subagents — and therefore which system prompt, tool grant and model —
runs the node, routing around Layers 0–3 entirely. A planner-authored
success_check.verify: would let it run arbitrary shell outside every guard.
Both are rejected in validatePlannedNodes, alongside bypassPermissions,
cwd and type: gate. What is rejected there is the planner's authorship, not
the field: trusted code may attach a verify: strictly after validation, from
the user-supplied --verify-cmd string, and does
(coordinator.attachVerifyCommand, ADR 0016 §2).
The general rule, because this class of hole recurs every time the schema grows:
every field on graph.Node must have an explicit disposition in
validatePlannedNodes — allowed, constrained, or rejected. Adding a field to
Node without adding a case is a review-blocking defect, not a nit. A
table-driven test over reflect.VisibleFields(reflect.TypeOf(graph.Node{}))
that fails on any field name the coordinator has no recorded disposition for
turns that rule into a build failure. Current dispositions:
| field | planned-node disposition |
|---|---|
prompt, depends_on, handoff |
allowed (prompt must be non-empty) |
id |
constrained — no /: the splice namespace is minted by the loader alone (validatePlannedNodeID, ADR 0027) |
type |
constrained — claude-run only; gate rejected |
allowed_tools |
constrained — non-empty, plannedToolAllowlist only |
permission_mode |
constrained — bypassPermissions rejected |
cwd |
rejected |
agent |
rejected |
worktree |
rejected (the engine would run git worktree add on an unreviewed plan's say-so — see "Worktree isolation") |
success_check.verify |
rejected when planner-authored (exit_zero/result_matches allowed); trusted code may set it strictly after validation, from the user-supplied --verify-cmd string (coordinator.attachVerifyCommand, ADR 0016 §2) |
use |
rejected — a planner-emitted use: would let unreviewed output pick which local file's prompt text, tool grant and verify command get spliced in, and a fragment file in the run's repo is attacker-influencable whenever the repo is untrusted (ADR 0013: trusted code resolves files, the planner never names local resources). Refused at the coordinator's graph.Parse boundary |
with |
rejected — use's substitution bindings, on the same grounds: dead without a use:, and a with: on a planned node means the plan tried to reference a fragment at all |
budget_usd, timeout |
allowed |
retry |
constrained — bounded re-runs of an already-ceilinged node, but a planned max above maxPlannedRetries (3) is rejected: verify_failed is a legal cause, so retry count is the one lever planner output still has on an injected evidence command's execution (ADR 0016 §2) |
feedback |
constrained — retry's standing one level up: bounded re-runs of body nodes already inside every ceiling, granting no tool, no path, no shell; the load validations hold for a planned graph exactly as for a hand-written one, but two things they leave open are closed here (ADR 0010). max: only max ≥ 1 is required at load and a plan has no human reviewer for the upper bound, so a planned max above maxPlannedFeedbackRounds (3) is rejected. Reach: an arc on a fan-in declarer may name a target whose body excludes a producer the declarer judges — valid, and unable to converge (#118) — so validatePlannedFeedbackReach refuses it whenever graph.LintFeedbackReach found a covering target, naming that target in the refusal. The quote: an arc whose loop body never quotes {{ feedback.<declarer> }} re-runs a prompt that cannot have changed, for every round of max — valid, and ADR 0028's specimen — so validatePlannedFeedbackQuoting refuses it, naming the token to paste and the prompt it belongs in |
Both mechanisms apply ONLY to coordinator-planned graphs; hand-written YAML
(oh-my-graph run) is human-authored/reviewed, passes a nil deny list, and is
not restricted by either. The generated spec is
saved to ~/.oh-my-graph/runs/<run-id>/graph.json — being valid YAML it can be
hand-edited and re-run with oh-my-graph run — then executed by the same
Scheduler as any other graph. A --plan-only plan is saved the same way but
to ~/.oh-my-graph/plans/<id>/graph.json, because it has no run to belong to.
So is a declined chat plan's: chat's commitment to execute does not exist
until a human answers its [y/N], so the spec save happens after that answer
and a n leaves no run directory at all (ADR 0023 §2.4). It used to save
before the prompt, which meant declining manufactured a runs/<id>/ holding a
graph.json and no state.json — a corrupt run produced by saying no.
auto plans once by default; --max-cycles N (N ≥ 2) opts into the bounded
goal loop: plan → validate → execute → assess, at most N cycles, each
cycle a whole ordinary run of a freshly planned graph. The flag is the
bound — 0 and negatives are rejected at parse, there is no config or env
default — and --max-cycles 1 (the default) is byte-identical to today: one
plan, one run, no assessment call, no new files or fields.
The cycle engine is coordinator.RunGoal; planAndExecute is its only
caller, supplying the save→print→confirm→execute sequence as the
ExecuteCycle callback — so auto and chat cannot drift, and the loop is
unit-testable against FakeRunner with zero real spawns. Chat stays
single-cycle in v1: it calls planAndExecute with singleCycle
(commonRunFlags carry no cycle count). Per cycle:
- Plan/validate:
coordinator.Planverbatim — the full field-disposition table and layered ceiling, every cycle, no caching. On cycle k ≥ 2 the planner prompt gains a continuation section carrying the previous assessment'sremaining(truncated, quoted as context, never as a rule change). The cycle's run id and leg are opened BEFORE this call, throughGoalOptions.OnCyclePlanning— the CLI mints and owns both, the loop takes on no closing obligation — so each cycle's planner call readsPLANNINGexactly as a single-cycleauto's does (ADR 0023). Without the hook the status would exist forautoand not forauto --max-cycles 3, and a status that depends on a flag is worse than no status. A cycle whose plan is REFUSED keeps that directory: it holdsrejected.json, no snapshot, and readsFAIL. - Execute: an ordinary run — own run id, directory,
graph.json,state.json,events.jsonl,resume.lock. The snapshot additionally carries the additivegoalblock (text,cycle,max_cycles,first_run_id— the group key; no schema bump, absent on single-cycle runs, preserved across resume legs). The browser live-view launch fires for cycle 1 only; later cycles still serve their own view and print its URL.servestays a per-run view and shows the goal block in its header (/api/graphcarries it beside the DAG); a goal-level view that follows the chain is additive later. - Assess:
coordinator.Assess, the third coordinator call class, under its own stricter stance —--tools "", settings-isolated, strict MCP, deny list extended with Read/Glob/Grep (measured: E8, ADR 0011's Measurement outcome — a read-this-file lure in an artifact did not reach the verdict) — judging only engine-assembled material: the goal, the run outcome, per-node verdict/detail/cost from the snapshot (the loop re-readsstate.jsonafterexecuteGraphreturns — the observation seam), bounded head+tail artifact excerpts, and the one cross-cycle line (the previousremaining). All three are raw model output, so every block rides in a nonce-fenced marker pair — one 6-hex-character nonce perAssesscall, in the opening AND closing marker, with the prompt telling the assessor that only markers bearing it are real (internal/fence; ADR 0012's inlined skill bodies were its first caller and are gone, the mechanism is not). Fixed markers would be forgeable by the very material they fence: an injected artifact could close its own block and speak from apparent outside it. The next cycle's planner prompt fences theremainingit quotes the same way. The verdict is a hard JSON contract; garbage is an*AssessErrorthat stops the loop. Each verdict is printed the moment it returns (GoalOptions.OnCycleAssessed) and persisted asassess.jsonin that cycle's run directory (goal_met,remaining,evidence,assess_cost_usd— the assessment cost the cycle's ledger cannot include, since the ledger prints before assessment).
Termination and exit follow ADR 0011 §2's (outcome × verdict) precedence:
goal_met always stops the loop, but exit 0 additionally requires the final
cycle's run to have passed — the untrusted judge can stop spending, never
flip an engine-reported failure. Unmet-and-exhausted (or the optional
--max-goal-budget-usd soft ceiling tripping at a cycle boundary, or a
declined confirm on a later cycle) exits 1 with the final remaining; a
session-limit pause pauses the whole loop (exit 2, standard resume hint —
the resumed run completes as an ordinary run; the goal loop does not
re-enter). The ceiling is rejected at parse without --max-cycles ≥ 2 — a
flag that could never fire would read as a bound that isn't one. When the
loop ends, the goal summary prints below the final ledger: one line per
cycle (run id, outcome, run total with planning included, assessment cost,
verdict) and the grand total — the multiplier is printed, never derivable.
A cycle that ended the loop mid-flight (pause, planning failure, garbage
verdict) prints as an explicit incomplete cycle — a failed assessment's own
cost still counted (AssessError.CostUSD), the run spend pointed at its own
ledger — so the summary never under-counts silently.
- Graph — validated nodes + adjacency; "is DAG?", "roots?", "dependents of X?". Pure data.
- Node — value object (id, type, prompt, cwd, tools, permission, budget, timeout, success_check, retry, handoff, depends_on, agent, worktree, feedback, and the load-time-only use/with).
- Edge = implicit
Node.DependsOn []string(no struct). - Scheduler — drive DAG: ready/running sets, cap, context cancel, halt/continue; calls NodeRunner.Run, consults Graph, writes RunLedger, asks Handoff to resolve/persist.
- NodeRunner (interface) — THE claude-exec seam. Injected (constructor), not global.
type NodeRunner interface { Run(ctx context.Context, spec NodeInvocation) (NodeOutcome, error) } type NodeInvocation struct { Prompt, Cwd, PermissionMode, ResumeSession, SessionID, Agent string; BudgetUSD float64; Timeout time.Duration; Policy ToolPolicy } type NodeOutcome struct { SessionID, Result string; TotalCostUSD float64; ExitCode int; FailureCause string; BudgetExhausted, SessionLimited bool }
CLIRunner(prod): builds argv, SCRUBS ANTHROPIC_API_KEY/AUTH_TOKEN, execs under context, parses JSON. One of the exactly four objects that spawn a process (the others:ShellVerifier,worktree.GitManager,browser.ExecOpener).FakeRunner(tests): scriptedmap[key]NodeOutcomekeyed by the invocation (NodeInvocationhas no id field; the key defaults tospec.Prompt, and tests set each node's prompt equal to its id so the "keyed by node id" shorthand reads true) — entire scheduler (topo, fan-out, fan-in, retry, halt, cost sum) unit-testable with ZERO real spawns. This is the testability keystone.
- Verifier (interface, v1.1) — THE evidence seam, separate from NodeRunner
because a verification command is not a claude invocation. Injected the same
way.
ShellVerifier(prod, the only object in its package that spawns),RefusingVerifier(default — a forgotten injection fails loudly),FakeVerifier(tests). See "Success checks". - Worktree Provider (interface) — THE worktree-provisioning seam: resolves
a node's
worktree: <name>to its managed checkout, creating it on first use (idempotent per name).GitManager(prod — the third spawner, ADR 0005),RefusingProvider(default — a forgotten injection fails loudly),FakeManager(tests). Run-end cleanup is the CLI's job against the concreteGitManager, never the Scheduler's. - Browser Opener (interface) — THE browser-open seam: opens a URL (the
servelive view) in the user's default browser.ExecOpener(prod — the fourth spawner, ADR 0006:open/xdg-open/cmd /c startbehind build tags),RefusingOpener(injection safety — code that must hold an Opener without ever opening fails loudly),FakeOpener(tests). Wired at therun/auto/resumecall sites only, behind the TTY-and-not---no-webgate (see "Web live view"); everywhere else the Opener is nil — the live view is off entirely and no Opener is consulted. - Handoff — interpolate {{artifacts/inputs}}, persist outputs, pick --resume
session. Gains
Seed(nodeID, artifactPath, sessionID)so a resumed run can rehydrate a previous leg's artifacts and session ids without Handoff having to know what a snapshot is. - Coordinator — auto mode: one planner NodeRunner call → JSON graph spec →
existing Parse/Validate (+ planned-node field dispositions). Owns the tool
ceiling policy (
Plan.ToolPolicies). Never runs the graph itself. - GateController (interface, v1.1) — answers approve/reject/pause for a gate
node.
PauseControllerfor fresh runs,RecordedController(snapshot-backed) forresume; chosen at the CLI boundary, invisible to the Scheduler. - RunState (v1.1) — owns
state.json: the resumable snapshot (graph, inputs, flags, tool policies, per-node completion incl. session id, gate decisions). Written atomically after every node. The Scheduler talks to aRecorderinterface and defaults to a no-op, so nothing about persistence leaks into the engine's tests. - RunFeed — owns
events.jsonl: the append-only, schema-versioned stream of node lifecycle events (run_started/node_started/node_passed/node_failed/ node_retried/gate_paused/gate_approved/gate_rejected/run_finished), one JSON line per transition, fsynced per line. Emitted from the same scheduler hook points as the progress line and the snapshot, via anEventSinkinterface defaulting to a no-op — the third destination next toRecorder, same seam pattern.node_startedandnode_retriedpublish the attempt's pre-assigned session id (see--session-idabove), so a consumer can locate a running node's transcript before the terminal event. This is the stable consumer contract — the in-repo views (runs list,show,watch,serve) read a run through it like any external consumer would, which is what keeps it honest; the full contract, including how it versions alongsidestate.json, is docs/RUN-FEED.md. - RunStatus (
internal/runstatus) — the one composition of the two facts those views need and neither owner has: the stream's leg state (RunFeed'sInFlight) and the run's liveness (RunState'sProbeLock). It answers settled / in flight / abandoned, spawns nothing and writes nothing, and it owns the recovery wording every surface prints. RunFeed stays a pure stdlib reader of the stream and RunState keeps the lock file it already owned; this is only their meeting point (ADR 0015 §2). - RunLedger — record session_id/cost/verdict/timing, plus auto mode's one
planning-call cost; end-of-run table + total cost (planning cost included, so
an auto run's total is honest; a hand-written
runrecords no planning cost). Every PASS row is qualified by how the verdict was reached —verified/self-reported/exit-only/approved, a closed set derived in trusted code from the predicates the engine actually evaluated (ADR 0016 §6). The qualifier sits besideVerdict, never replacing it, so nothing that tests for PASS/FAIL changes; it is carried intostate.jsonand ontonode_passedfrom the sameledger.Record, so the table, the snapshot and the feed cannot disagree about it. A FAIL carries none — its cause is inDETAIL.
Node lifecycle: Scheduler ready → Handoff.ResolveInputs → NodeRunner.Run → exit_zero → result_matches → Verifier.Verify → pass: Handoff.PersistOutput → budget_usd check → pass: RunState.RecordNode + RunLedger.Record + enqueue dependents; fail (any check, Verifier, OR budget): retry or Record(FAIL) + cancel if halt; gate pause: drain in flight, snapshot, exit 2. Output is persisted before the budget verdict so an over-budget node's artifact survives. Scheduler never knows if a real claude ran, or what actually ran the verification.
IN: YAML loader + DAG/cycle validation; {{inputs}}/{{artifacts}} interpolation;
concurrent ready-set scheduler + cap + halt-on-fail; CLIRunner (exact argv,
ENV SCRUB, timeout, JSON parse); FakeRunner + full scheduler unit tests (no real
claude in CI); artifact handoff (default) + session handoff (simple one→one);
success_check (exit_zero + result_matches); RunLedger table + total cost;
CLI oh-my-graph run <graph.yaml> --input k=v and oh-my-graph auto "<goal>"
(planned graphs — see Auto mode); nodes run in real cwds with session
persistence ON (so every node stays an ordinary, readable claude session in
~/.claude/projects — do NOT pass --no-session-persistence).
DEFERRED (say so in README): retries beyond flat max:1 (the per-cause filter
retry.on, over the closed cause set, shipped later — see "Execution
engine"); parallel-group sugar /
any DSL; a terminal TUI (the web views shipped later — both the single-run
live view and the multi-run dashboard; see "Web live view"); worktree
auto-creation (opt-in
per-node worktree: shipped later — see "Worktree isolation"); sub-call /
cross-node budget accounting (per-node
mid-node kill via --max-budget-usd and post-hoc budget halt ARE both enforced
— see "Execution engine").
That list is what was never built. The other half — what IS built and still
falls short of what a reader might assume from this spec — is
docs/LIMITATIONS.md: the platform notes, and the
per-feature gaps each shipped mechanism left behind (a success_check with no
verify is still self-report; a PASS row does not say which of a two-valued
verdict passed; budget_usd is per node only; isolation stops at the
invocation repository). This spec says what the design intends; that file says
where the shipped thing does not reach it, and the two are kept in step
deliberately rather than merged — a boundary stated once, in the document
whose subject it is.
IN: evidence-grounded success_check.verify (#7); gate execution +
oh-my-graph resume (#9); the layered tool ceiling for planned nodes and the
planned-node field dispositions (#11); node-level agent: for hand-written
graphs (PR #6). Each ships as its own PR — see "Implementation sequencing".
cmd/oh-my-graph/{main,flags,argslot,init,resume,gateresume,runs,show,watch,serve,chat,goal,lint,dryrun,liveview,verifycmd,runleg,runlock,version}.go + _test CLI: parse flags, load, inject CLIRunner+ShellVerifier, init/run/auto/resume/runs/show/watch/serve/chat, the `auto --max-cycles` goal loop (goal.go — ADR 0011) and the GateResumer serve's gate routes call back through (gateresume.go — ADR 0014), the `--verify-cmd` pre-flight, shared by `auto` and `resume`, its two disclosures and the build-evidence gate one directory scan feeds (verifycmd.go — ADR 0016, ADR 0030), print ledger
internal/graph/{graph,validate,feedback,feedback_reach,fragment}.go + _test + testdata/{pre-migration,golden}/ Graph/Node value objects, YAML, DAG validation, ReadyGiven, feedback edges + the advisory sweep for an arc that misses a fan-in producer (feedback_reach.go — advisory on purpose; ADR 0010's alternatives record why the escalation is neither sound nor complete), and the load-time fragment resolver (LoadFile/LintLoadFile, one read per path — ADR 0013)
internal/schedule/{scheduler,errors,feedback,retryfeedback}.go + _test ready-set engine (drives FakeRunner — keystone) + typed errors + the bounded runtime re-run of a feedback edge (ADR 0010) + the fenced, one-deep quote of the attempt a retry repeats (retryfeedback.go — ADR 0020)
internal/runner/{runner,runtime,cli,claude_protocol,codex_protocol,preflight,sessionlimit,fake}.go + build-tagged procgroup_{unix,windows}.go + _test interface + ToolPolicy + CLIRunner(ENV SCRUB) + the one runtime selection (runtime.go — ADR 0025) + the two protocols beneath it, each owning binary/argv/session/output (claude_protocol.go mints the session id before spawn, codex_protocol.go learns its thread id from thread.started) + the per-runtime graph preflight (preflight.go) + the subscription session-limit recognizer (sessionlimit.go — ADR 0009, Claude-shaped by decision, not by omission) + FakeRunner
internal/verify/{verify,shell,fake}.go + build-tagged {shell,procgroup}_{unix,windows}.go + _test Verifier seam — ShellVerifier is the second of the four exec seams (ADR 0002)
internal/worktree/{worktree,git,fake}.go + _test worktree Provider seam — GitManager is the third exec seam (ADR 0005): per-run managed checkouts + work-preserving cleanup
internal/browser/{browser,exec,fake}.go + build-tagged argv_{darwin,unix,windows}.go + _test browser Opener seam — ExecOpener is the fourth exec seam (ADR 0006): default-browser launch, wired behind run/auto's TTY gate
internal/invariants/exec_seam_test.go test-only: asserts only the four exec seams' files import os/exec — 8 files, since a seam's platform-specific procgroup files belong to it (a ninth importer fails CI — ADR 0002/0005/0006). A separate, shorter list names the 4 spawn CALL SITES (one per seam, procgroup files excluded — they mutate an already-built *exec.Cmd) and asserts each scrubs its child env through internal/childenv
internal/childenv/childenv.go + _test the shared "delete billing-switching vars" child-env policy (all four spawners)
internal/fence/fence.go + _test the shared data fence: a per-call crypto/rand nonce for both markers of any quote of untrusted text into a prompt, plus the head+tail bound on the quoted material. Its callers live in coordinator and schedule, and their number is stated in fence.go alone — internal/invariants counts the real ones repo-wide against that one sentence, so a second copy here would be a number nothing checks
internal/coordinator/{coordinator,router,agentmap,agentstage,skillscan,skillstage,goal,assess,repair,verifycmd,unisolated}.go + _test auto mode: goal → planner call (NodeRunner seam) → validated graph + ToolPolicies; chat routing; post-validation subagent mapping with its definition staged (agentmap.go/agentstage.go — ADR 0022) and skill activation over a staged plugin directory (skillscan.go/skillstage.go — ADR 0017, superseding ADR 0012's inlining); the shared nonce fence (internal/fence, used by Assess and by the re-plan); the bounded plan→execute→assess goal loop (goal.go/assess.go — ADR 0011); the bounded re-plan a validation refusal buys (repair.go)
internal/handoff/{handoff,placeholder_lint,session_lint,verdict_lint,tool_grant_lint,verify_inline_lint,feedback_quote_lint}.go + _test interpolation, artifact persist/resolve, session pick, Seed for resume — plus the advisory lint sweeps `lint`/`run --dry-run` print — and a plain `run` does NOT (unresolvable {{placeholders}}, session-handoff `--resume` that may not deliver the parent conversation, a prompt demanding a verdict token no `result_matches` reads, a `result_matches` that silently dropped the node's exit-code guard, a node that declares neither an `allowed_tools` grant nor a `success_check.verify` and so can observe no tool denial — #154 — a `success_check.verify.command` splicing a model's own text into the shell command line the engine runs: `{{ artifacts.<id> | inline }}`, whose filterless form would be the engine's own file path, or `{{ feedback.<id> }}`, which has no filterless form — and a feedback loop whose body never quotes `{{ feedback.<declarer> }}`, so the re-run repairs nothing: ADR 0028)
internal/gate/gate.go + _test Decision + PauseController/RecordedController
internal/runstate/{runstate,recorder,lock}.go + build-tagged flock_{unix,other}.go, pidprobe_{unix,other}.go and fstype_{darwin,linux,other}.go + _test state.json snapshot — atomic write, schema version, resume load — plus the run lock: an flock(2) a leg holds for its duration (AcquireLock) and a reader may probe without writing anything (ProbeLock — ADR 0015 §1)
internal/runfeed/{runfeed,reader}.go + _test events.jsonl append-only lifecycle event stream — the consumer contract (docs/RUN-FEED.md) — plus the in-repo consumer readers (InFlight, Follow)
internal/runstatus/{runstatus,skipped}.go + _test the one shared rule (ADR 0015 §2): open leg AND held lock ⇒ in flight, open leg AND free lock ⇒ abandoned — composed once for `runs list`, the dashboard card, ResolveRun, the single-run view's /api/graph and `watch`, plus the recovery wording those surfaces print; `skipped.go` is the other half of the same argument — a run directory this build cannot READ has no status at all, and how that damage is CLASSIFIED (incompatible schema vs. unreadable bytes) and REPORTED (the counted coverage line, the per-run detail sentence) is one shared value here rather than composed per surface
internal/serve/{serve,dashboard,card,resolve,transcript,gate,build}.go + ui/ + _test `serve`: 127.0.0.1-only web views — the dashboard (`dashboard.go`/`card.go`: one live mini-DAG card per run, run views mounted at /run/<id>/) and the live view of one run — embedded static UI (go:embed) + vendored cytoscape.js; a run-feed consumer with token-guarded gate actions — every route reads the contract (plus the live transcript tail of a running node's own session) except the mutating pair (`gate.go`: approve/reject the paused gate through the injected GateResumer — ADR 0014); `build.go` names the build answering the page, stat'd once per process
internal/ledger/ledger.go + _test RunLedger summary + total cost
graphs/haiku-smoke.yaml, graphs/dev-review-pr.yaml, graphs/self-dev.yaml, … + graphs/embed.go the shipped pipelines, embedded with `//go:embed *.yaml fragments/*.yaml` (globs, so a new template or fragment ships automatically; the second pattern is required because `*.yaml` does not descend, and a template citing `use:` needs its fragments/ sibling on disk) — `oh-my-graph init [dir]` walks that payload and unpacks it into <dir>/graphs/, nested paths included (dir defaults to `.`), never overwriting: an existing target is kept untouched and reported as `kept` while the missing ones are written (so a re-run delivers a payload addition and an edited template survives it), a kept file whose bytes differ from the payload is marked `DIFFERS` and counted (a top-up can pair a freshly written template with a kept older fragment, which fails at load, not at `init`), and a failure partway through removes the files AND directories it created — `graphs/` itself included when that run made it
graphs/fragments/{e2e-verify,review-security,review-style,pr-publish,repair-round,gated-lane}.yaml the shipped node shapes and loops the templates cite with use: (ADR 0013, ADR 0027, ADR 0029); cited by self-dev.yaml, dev-review-pr.yaml, backlog-batch.yaml and adr-driven-dev.yaml (+ internal/graph/shipped_graphs_test.go asserts every shipped graph loads BOTH from the checkout and from the binary's own unpacked payload — the second is what proves `init` emits graphs that load)
docs/adr/00{01..30}-*.md (0020 is the retry ADR, renumbered from the 0016 it collided on; the build-evidence ADR kept 0016, so every bare "ADR 0016" in the tree resolves)
docs/measurements/{*.md,probes/<adr>-<name>/} the raw record behind a measured claim: pre-registrations written before the first spawn, the runner scripts, every prompt file verbatim, and one line per spawn — so a number in an ADR or a CHANGELOG entry is re-derivable rather than quotable
README.md, SECURITY.md, LICENSE(MIT), go.mod, Makefile(build/test/lint)
graphs/haiku-smoke.yaml: node write ("write a 3-line haiku about graphs to
haiku.txt", acceptEdits) → node critique (depends_on write, reads
{{artifacts.write}}, plan). mkdir -p /tmp/omg-smoke && oh-my-graph run graphs/haiku-smoke.yaml --input dir=/tmp/omg-smoke.
Proves: subscription auth (succeeds with API key unset/scrubbed), sequential edge +
artifact handoff, JSON capture (2 session_ids + costs), RunLedger, and both
sessions landing in ~/.claude/projects. CI stays free — real-claude smoke is a manual
make smoke, never in CI (all logic tested via FakeRunner).
Personal/local tool re-using YOUR OWN logged-in claude session (same standing as
claude-squad / running claude -p yourself). NOT a hosted/redistributed product
authenticating others via subscription OAuth (that violates ToS). Never ships
credentials, never proxies auth, never runs as a shared service. Scrubs
ANTHROPIC_API_KEY/AUTH_TOKEN plus OPENAI_API_KEY/CODEX_API_KEY from every child
(one list, no runtime branch; unit-tested). Never --bare, never an Agent SDK. Least privilege per node (allowed_tools + permission_mode); bypassPermissions
opt-in per node with a loud warning, never a default. For auto-planned graphs
(untrusted LLM output run unattended under auto, ADR 0034), least privilege is not
just a prompt convention and not just a declaration check:
coordinator.validatePlannedNodes rejects a planned node whose allowed_tools
is empty or names a tool outside the fixed allowlist, or that sets cwd,
agent, or a planner-authored success_check.verify (trusted code attaches the
user's --verify-cmd after validation, ADR 0016 §2); and Plan.ToolPolicies
imposes a per-node
execution ceiling (settings-source isolation + scoped allow under default-deny +
tool narrowing + strict MCP + residual denies) so the user's own standing tool
grants cannot widen an unreviewed plan — unless that user says at launch that
they should, which is --accept-loaded-user-config and drops the first and
fourth of those five. All of it, and the gaps that remain, are
in "Auto mode" above.
- artifact interpolation: substitute file PATH by default,
| inlinefilter for content. - budget: enforced at two layers. Post-hoc — a node whose actual cost
exceeds its
budget_usdfails exactly like a failed success_check (NodeBudgetError, ledger FAIL carrying budgeted-vs-actual, halt-on-fail by default), so its dependents never start; the RunLedger carries the declared budget alongside actual cost, making the delta derivable per node (Record.BudgetDeltaUSD). Mid-node kill is now real too, not deferred: a positivebudget_usdis passed to the subprocess asclaude --max-budget-usd, and the CLI aborts the node the moment its own spend crosses the budget (verified on claude 2.1.220 — a parseable JSON envelope withsubtype: error_max_budget_usd, mapped toBudgetExhaustedand raised as the samebudget_exceeded*NodeBudgetError). The kill is perclaude -pinvocation = one node (a resumed session does not re-count its parent's spend), so it bounds a runaway node without a fabricated $/minute heuristic. Still deferred: sub-call cost observation (the one in-flight call past the threshold can overshoot, which the post-hoc layer backstops) and any cross-node/whole-graph budget — those would need--output-format stream-jsonincremental parsing, an ADR-level change to the one-envelopeNodeRunnercontract. See "Execution engine" for the full two-layer detail and why a budget-derived wall-clock timeout was rejected as fake enforcement. - parallel nodes sharing one cwd can race edits → v0.1 parallel nodes should be
read-only (plan) reviews (the motivating fan-out case); parallel edits want
worktrees — now available as per-node
worktree:(see "Worktree isolation"). - session-handoff + multi-parent fan-in conflict → validation rejects
handoff: sessionon a node with 2+ session-parents; multi-parent must use artifact.
--help prose is not ground truth. PR #5 already found the CLI's real deny/allow
precedence did not match what --help implied, so the layered ceiling above
separates what has been read out of the shipped CLI from what took a real
invocation to settle. Every claim below is one or the other; nothing here rests
on documentation.
- V1.
--setting-sourcesparsesuser/project/local;""yields the empty list; anything else is a hard error.flagSettings(--settings) andpolicySettings(enterprise) are unioned on top and cannot be dropped. So--setting-sources ""= "load none of the user's settings files". - V2. Under
dontAsk, a tool call whose rule evaluation lands on ask is converted to{behavior: "deny", decisionReason: {type: "mode", mode: "dontAsk"}}— the CLI is default-deny under that mode. This reading stands and no longer describes the default. Since ADR 0034 an undeclared node runsauto, where the same call reaches a model classifier that approves or denies it; an explicit deny rule is still evaluated first, and neither mode prompts a node that has no TTY. Read out of claude 2.1.241 rather than 2.1.220, and recorded separately indocs/measurements/0034-what-auto-mode-does-on-disk.md. - V3.
--toolsfeeds a distinct permission-rule source labelled "CLI tool narrowing", i.e. it narrows the tool set rather than adding an allow. - V4. An enterprise
allowManagedPermissionRulesOnlypolicy causes--allowedToolsrules to be ignored entirely. On such a machine the ceiling is the managed policy, not ours. Worth a line in SECURITY.md.
Run as one-off manual invocations against a real machine whose
~/.claude/settings.json grants Bash(*), Write(*), WebFetch(*) — the
exact power-user configuration the ceiling exists for. Evidence is the
filesystem and the envelope's own permission_denials array, not the model's
narration of what it was allowed to do. These are NOT part of make test; the
automated suite stays spawn-free.
-
E1 — CONFIRMED. Layers 1+2 are a real ceiling. Same node declaration (
--allowedTools "Bash(git *)",--permission-mode dontAsk), one variable:- without
--setting-sources "":touch <path>ran (file created,permission_denials: []). This is the documented gap, reproduced. - with
--setting-sources "": the same command was denied (permission_denials: [{tool_name: "Bash", …}], no file on disk). - with
--setting-sources "", in-scopegit init <path>: allowed.
So the scoped pattern binds —
Bash(git *)means git and nothing else — rather than being a declaration. This is what licenses narrowing the Bash-gap wording in README/SECURITY.md for planned nodes only. - without
-
E2 — ANSWERED: yes for DISCOVERY, and that is not the same as
--agent.--setting-sources ""disables discovery of the user's agent definitions.--agent code-reviewerunder isolation fails at startup with "not found. Available agents: claude, Explore, general-purpose, Plan, statusline-setup" — only built-ins survive. Re-confirmed at claude 2.1.228 on 2026-08-12, and widened: that same list names neither~/.claude/agentsnor the repository's.claude/agents, so a repository cannot supply a mapped node's system prompt by discovery (docs/measurements/0017-staged-agent-restores-layer-1.md, armK-NEG; re-run against this build's own argv as (l)'sL-NEG, with the repository's definition committed in the node's cwd).Staging is a second channel and this entry does not cover it. The sentence above said "cannot supply a mapped node's system prompt" without the last two words until 2026-08-12, which generalized a discovery result onto the pipeline that ships: a scanned definition is COPIED into a
--plugin-dir, andDefaultAgentDirsscanned<cwd>/.claude/agentswith the project shadowing the user. Measured, the repository's definition was the system prompt that ran, 2 of 2. The scan is now~/.claude/agentsonly (docs/measurements/0022-repo-planted-agent-and-the-agents-only-dir.md).What E2 does NOT say is that Layer 1 and
agent:cannot be combined, and that inference — which this entry drew, and which cost the ceiling on every mapped node until 2026-08-12 — is refuted by the same measurement. A--plugin-dirsupplies definitions without reopening Layer 1, and a plugin directory can carryagents/: with the matched definition staged there,--agentresolved 3 of 3 under--setting-sources "", attributed by a marker token, with removingagents/from an otherwise identical directory as the control (exit 1). So a coordinator-MAPPED node keeps Layer 1 and stages its agent (ADR 0022) rather than trading the ceiling for the mapping. The constraint that remains is narrower and still real: Layer 1 cannot be extended to a HAND-WRITTEN graph'sagent:, whose definition oh-my-graph does not stage and whose node is the user's own reviewed artifact. -
E3 — CONFIRMED SAFE.
--setting-sources ""does NOT affect subscription OAuth. WithANTHROPIC_API_KEYandANTHROPIC_AUTH_TOKENabsent from the environment,claude -p '…' --output-format json --permission-mode plan --setting-sources ""returned a normal envelope (is_error: false, aresult,provider: "firstParty"). Since no API key existed to fall back to, a successful call can only have resolved OAuth. Credentials are not settings, and dropping settings does not touch them. Independently, the run confirmed the flag was in effect: themodelpin from the user's settings.json was not applied. The project's #1 invariant is intact. -
E4 — CONFIRMED:
--toolsREPLACES, and--allowedToolscannot resurrect an omitted tool.--tools "Glob" --allowedTools "Read", asked to read a file: the model reported having no read tool and made zero tool calls (num_turns: 1,permission_denials: []— it never got as far as a permission decision). Layer 3 is therefore a genuine narrowing and must list every tool the node needs; it is not additive with Layer 2. The two axes compose as an intersection:--toolsdecides what exists,--allowedToolsdecides what is permitted. -
E5 — NOT MEASURED. No project
.mcp.jsonwas available to test whether MCP servers survive--setting-sources "".--strict-mcp-configships as Layer 4 regardless, since oh-my-graph never passes--mcp-configand the flag is therefore free; but no claim is made that MCP is closed, and SECURITY.md says so rather than implying coverage that was not observed. -
E6 — MEASURED, and now load-bearing for one real path. With
--agent code-reviewer(frontmattertools: Read, Grep, Glob, Bash) plus--tools "Read", the node could not run a shell command: zero tool calls, no permission denial. So a resolved subagent's frontmatter does not widen past--tools.A coordinator-MAPPED planned node (see "Node-as-subagent") runs exactly this configuration —
--agentplus the node's--tools/--allowedTools— so E6 is the measurement that bounds it, with the coordinator's own frontmatter-subset check on top. The result still does not transfer to the hand-written path, which never passes--toolsat all: there the precise composition between a subagent'stools:and a node'sallowed_toolsremains unmeasured, and oh-my-graph states no reconciliation rule for it. -
E7 — CONFIRMED:
--setting-sources ""drops the project CLAUDE.md. In a directory whoseCLAUDE.mddefined a codeword, a plainclaude -preturned the codeword and the same call with--setting-sources ""returned "NOCODEWORD". This was measured rather than assumed because a design decision rests on it (the planner call is deliberately NOT isolated, so it keeps the user's CLAUDE.md — seecoordinator.Plan) and because it is the concrete cost the README promises to disclose. Settings hooks were not separately measured: they are defined inside the settings files that V1 established are not loaded, so "no settings, no hooks" follows from V1 rather than being an independent claim.
Two corrections these measurements force on text written before them: the
Bash-gap disclosure is now false for planned nodes and must be narrowed
rather than repeated, and the claim that an unresolvable --agent "falls back
to plain claude" was wrong — see "Node-as-subagent".
internal/schedule/scheduler.go, internal/graph/graph.go,
internal/runner/*.go and internal/coordinator/coordinator.go are shared. The
constraint that matters is not "same file", it is same function.
| work | owns | scheduler footprint |
|---|---|---|
| #8 budget | ledger.go, flags.go |
runNode post-outcome, Options |
| #11 + PR #6 | runner/*.go, coordinator.go |
buildInvocation, Options |
| #7 verify | verify/*, childenv/* |
runNode post-outcome, Options |
| #9 gate+resume | runstate/*, gate.go, handoff.go |
Run seeding + runNode gate branch, Options |
- #11 + PR #6 runs in parallel with #8, starting now. Its scheduler change is
confined to
buildInvocation; #8's is confined torunNode. They collide only on adding a field toschedule.Optionsand a line tomain.gowiring — textual, not semantic. One coordination point: #11 owns theNodeInvocation/buildArgsrefactor intoToolPolicy, so if #8 implements a mid-node kill via--max-budget-usdit must add a plain scalar field and not restructure the invocation type. - #7 waits for #8 to merge. Both rewrite the same ~20-line post-outcome
region of
runNode, and both introduce a "the node returned, but the run should stop" concept. Sequencing them stops two developers inventing two halt vocabularies that then have to be reconciled. - #9 goes last, after #8 and #7. It restructures
Run's ready-set seeding and adds the drain-don't-cancel pause path — merging that under two in-flightrunNodechanges is how a halt path gets quietly broken. It also has to snapshot the final per-node record shape, which #8 (cost) and #7 (verify verdict) both change; designingstate.jsonagainst a moving target buys a schema migration on day one. - #9 can be split to start immediately.
#9a—internal/runstate,handoff.Seed+ session-id persistence,graph.ReadyGiven— is all new files plus two additive methods, touches no shared function, and is mergeable at any time.#9b— the scheduler pause path,GateControllerdecisions, theresumesubcommand and exit code 2 — is the part that must go last.