/ooda-pr
Drives one PR through observe → orient → decide → act. Each
invocation returns one Outcome; the caller dispatches on the
exit code alone.
Names
| Name | Refers to |
|---|---|
/ooda-pr |
The skill (this document, invoked from a Claude Code agent prompt). |
ooda-pr |
The compiled Rust binary. The run wrapper resolves the symlink via pwd -P and locates the binary at target/release/ooda-pr inside the resolved source directory. Callers should invoke run, not the binary directly. |
run |
The wrapper script at ~/.claude/skills/ooda-pr/run. |
Always invoke run; never the binary directly. run performs
the rebuild step (cargo build --release --quiet) before
exec'ing the binary, so the binary path is fresh whenever run
last completed. Invoking the binary directly skips the rebuild
and may serve a stale build relative to the current source
tree.
Sibling binaries
| Name | Refers to |
|---|---|
ooda-attest |
Companion CLI that writes attestation files at <state-root>/<pr-id>/<axis>_attest.json for four axes: pr-meta, doc-review, claude-review, closeout. Each Sync* / Review* / Address* / Closeout handoff (exit 4) instructs the receiving agent to run the matching subcommand after completing the work. closeout is the convergence gate — it fires only when every other axis is silent and gates HandoffHuman on an explicit agent sign-off. Run ooda-attest --help for the full surface. |
Type spine
Boundary types are defined in the ooda-core library crate
(/home/cory/code/skills/ooda-core/) and shared with the three
sibling OODA binaries. This binary depends on ooda-core via
path dep and instantiates each generic type over its
domain-specific ActionKind enum:
pub type Outcome = ooda_core::Outcome<ActionKind>;
pub type Decision = ooda_core::Decision<ActionKind>;
pub type DecisionHalt = ooda_core::DecisionHalt<ActionKind>;
pub type HaltReason = ooda_core::HaltReason<ActionKind>;
pub type Action = ooda_core::Action<ActionKind>;
Automation, Urgency, TargetEffect, BlockerKey, Terminal,
and the ActionKindName trait are re-exported from ooda-core
directly. ActionKind is per-binary — this binary's variants
cover the PR-merge domain (FixCi, AddressThreads, Rebase,
…) and implement ActionKindName so the loop can render variant
tokens for iter-log lines and stderr headers without leaking
payload internals.
Variant name ≠ stderr header. The Rust variant names
(DoneSucceeded, DoneAborted, Paused) are internal — the
neutral verbs that fit every binary in the family. The stderr
header strings (DoneMerged, DoneClosed, Paused) are this
binary's caller contract and are emitted by the per-binary
render_outcome function. The Outcomes table below shows both
columns; callers dispatch on $? and read the stderr header,
not the variant name.
Per-binary code (not lifted):
runner.rs::run_loop— iteration sequencing, stall detection, cap detection. Each binary's runner diverges enough on side-effects / flock / refresh logic that lifting is premature.state.rs— thin adapter over the sharedooda-statecrate. PR-specific event vocabulary (action_started,status_comment_rendered,tool_call_finished, …) lives here; the generic on-disk layout (events.jsonl + content-addressed blobs) is owned byooda-state.decide/action.rs::ActionKindand itsActionKindNameimpl.From<LoopError> for Outcome—LoopErrorshape differs per binary.
See ooda-core/README.md and ooda-core/src/lib.rs for the
shared-spine design rationale.
Calling discipline
$? MUST reflect ooda-pr's exit when ooda-pr runs. Two
distinct concerns:
- ooda-pr must actually run.
false && ooda-pr ...short-circuits and ooda-pr never executes;$?will reflect the left side's exit, not ooda-pr's. Structure invocations so ooda-pr runs unconditionally. - Nothing may inject another exit code into
$?. Pipes (ooda-pr | foo), stderr-merging pipes (ooda-pr |&,ooda-pr 2>&1 | foo), backgrounding (ooda-pr &), command substitution (out=$(ooda-pr ...)), and any subsequent command (ooda-pr; echo x) replace$?with another process's exit.
Safe patterns:
- ooda-pr alone on the line:
~/.claude/skills/ooda-pr/run owner/repo 42 - ooda-pr as the last command after
;:pwd; ~/.claude/skills/ooda-pr/run owner/repo 42 - ooda-pr on the right of
&&after a reliably-succeeding left side:cd /tmp && ~/.claude/skills/ooda-pr/run owner/repo 42
Capturing stderr: redirect to a file (ooda-pr ... 2>file)
for a single invocation. For repeated invocations, use append
redirection (2>>file), a fresh file per run, or --trace PATH.
File redirection does not affect $?. Process substitution
(ooda-pr 2> >(tee file)) also preserves $?. The forms that
break $? are stderr piping (|&, 2>&1 |). --trace PATH
is now an extra compatibility sink; the durable audit trail is
always written under the state root.
Separate Bash tool calls in the same agent turn are fine —
each Bash call is an independent shell with its own $?.
Driving discipline
Loop mode is meant to run to a halt. The only correct stopping
points are the halt-class exit codes: 0 (DoneMerged), 1
(Paused), 3 (HandoffHuman), 4 (HandoffAgent), 5
(DoneClosed), 6 (StuckRepeated), 7 (StuckCapReached),
and 70 (BinaryError). Stopping anywhere else — including
after exit 2 (WouldAdvance) — is premature.
Anti-patterns that stop the loop early. These are common agent mistakes; the binary is not at fault.
- Probing repeatedly with
inspect. Inspect is a one-shot snapshot. After the first inspect (or as the very first call on an unfamiliar PR), dropinspectand re-invoke as the loop. Re-inspect-ing in place of running the loop is the most common way agents stall a PR. - Treating
WouldAdvanceas a halt. Exit 2 is inspect-only. The action shown is what the loop would do; re-invoke withoutinspectto actually do it. Do not reportWouldAdvanceto the user and stop. - Re-
inspect-ing after aHandoff*action completes. The action's effect needs to land in fresh observation, which the next loop iteration will do — not the next inspect. After a Handoff returns and you complete the requested action, re-invoke in loop mode (noinspect). - Shrinking
--max-iteras a "safety" cap. The default (50) exists becauseWaititerations (15s/30s/60s) are how the loop polls slow external systems (CI runs, bot reviews, scheduled jobs). Capping at 3, 5, or 10 routinely converts a normal wait into a spuriousStuckCapReached(exit 7). Use the default unless you have a specific reason; if exit 7 fires from a wait-heavy run, re-invoke with a higher cap, not a lower one. - Inferring "stuck" from long wait runs. A run that spends
minutes in
Wait(1m)polling for a CI check or a bot review is working correctly. The wait is the action. Let it finish. - Surfacing to the user mid-loop after a
Handoff*. When you complete a Handoff action, the next tool call MUST be either another~/.claude/skills/ooda-pr/run …invocation (re-enter the loop) or an explicit user-directed action that genuinely requires user input. Do NOT summarize the situation, offer options, or ask whether to continue. The user invoked/ooda-prbecause they don't want to make those decisions per-round. Editorializing about "diminishing returns" or "marginal value" is an anti-pattern — those judgments are encoded in the halt-class exit codes, not in agent vibes. - Treating your own context budget as a halt signal. The harness handles compaction; trust it. The agent should never stop the loop because it is worried about its own context — that's confusing the agent's resource constraints with the user's intent. If context is genuinely tight, finish the in-flight Handoff action, re-invoke, and let the natural halt code (or compaction itself) be the pause point.
- Reading "each round still finds something" as divergence. Convergence under loop mode is bugs-fixed-per-round, not findings-per-round. A loop that finds and addresses 6 → 5 → 4 → 3 → 2 → 1 → halt is converging exactly as intended. Don't bail because the bot reviewer keeps surfacing new threads — that's the loop working.
After a Handoff* (exit 3 or 4). Surface the handoff to the
user (header + iter-log + handoff blob; see Handoff* prompt
format → "Surface to the user"). Then perform the requested
action and re-invoke /ooda-pr in loop mode (no inspect).
The loop's first iteration re-observes the now-modified state and
either selects the next action or halts.
Time budget. ooda-pr is iteration-bounded, not wall-clock-bounded.
A loop run can legitimately take 30+ minutes if external systems
are slow. Plan for that; don't artificially cut it short. If you
genuinely need a wall-clock deadline, impose it externally — but
expect that doing so will produce spurious StuckCapReached
results, not faster convergence.
How to call
~/.claude/skills/ooda-pr/run [options] <owner/repo> <pr> # loop mode
~/.claude/skills/ooda-pr/run inspect [options] <owner/repo> <pr> # one pass
Argument rules:
<owner/repo>and<pr>are required, in that order.- Flags may interleave between or after the positionals.
inspect, when present, must come before either positional. Flags may appear beforeinspect. The parser consumes the firstinspecttoken (when no positional has yet been seen) as the mode subcommand; any laterinspecttoken falls through to the positional vector. The resultingUsageErrortext depends on the positional vector that builds: "invalid pull request number: not a number: inspect" when a subsequentinspectlands in the<pr>slot (e.g.inspect owner/repo inspect), "invalid repo slug: missing '/'" when a duplicateinspectbecomes positional[0] before the slug (e.g.inspect inspect 99— the secondinspectlands in the slug slot), or "expected exactly 2 positionals (owner/repo, pr); got " when the total positional count ends up ≠ 2 (e.g.owner/repo inspect 99produces 3 positionals).-h/--helpshort-circuits all other validation via a pre-scan: if either token appears anywhere in the argument list, usage is printed to stdout and the process exits 0 before any other flag is parsed.- Repeating
--max-iter,--status-comment, or--traceis aUsageError. Repeating--state-rootis also aUsageError.
The run script rebuilds the release binary on demand and execs
it. The wrapper invokes cargo build --release --quiet, so
successful incremental rebuilds are silent; only warnings and
errors reach stderr from cargo. Stderr emitted before
ooda-pr starts (i.e. before any line documented in "Stderr
surface" below) is wrapper / cargo diagnostic noise, not part
of the binary's contract. The binary's own per-iteration logs,
stack note, comment status lines, and final variant block
are the contract — see "Stderr surface" for the full
inventory. If the cargo build fails, run exits with cargo's
exit code (typically 101 for compile error) and ooda-pr does
not execute — treat such codes as BinaryError-equivalent
(see catch-all).
| Flag | Meaning |
|---|---|
--max-iter N |
Loop iteration cap. Default 50. Must be ≥1; --max-iter 0 (or any non-integer / negative) is rejected as UsageError regardless of mode (validation runs before mode dispatch). Inspect mode runs exactly once and does not consult the cap value. |
--status-comment |
Post a status comment to the PR each iteration. Deduped per-PR under the always-on state root at index/pr/<owner>/<repo>/<pr>/status-comment-dedup.json; the hash input is the renderer's dedup_key field, so progress re-posts when the typed rendered state changes. |
--state-root PATH |
Override the always-on state root. Default resolution is $OODA_STATE_HOME, then $XDG_STATE_HOME/ooda, then ~/.local/state/ooda, then $TMPDIR/ooda. One root per machine, shared by every OODA agent regardless of domain; domain identity (pr, codex-review, …) lives in events, not in the path. |
--repo-root PATH |
Target working tree for every gt / git subprocess. Default: derive from CWD via git rev-parse --show-toplevel. Invocations from outside any git tree are rejected as UsageError unless --repo-root is supplied. Pinning is required so gt sync cannot rewrite a sibling repo's stack when the binary is invoked from elsewhere on disk. |
--trace PATH |
Also append the compact trace to PATH. Creates parent directories when needed. Each run appends a run header, binary-owned diagnostic lines, the final Outcome block, and exit=<code>. Trace-open failure emits BinaryError (exit 70). Later trace writes are best-effort and do not change the Outcome. |
-h, --help |
Print usage to stdout, exit 0. The only invocation that writes to stdout. Short-circuits all other validation via a pre-scan: appears anywhere in argv → exit 0 immediately, bypassing the Outcome construction path. |
Always-On State
Every invocation with valid <owner/repo> and <pr> writes a
host-local audit trail before observation begins. The state model
is domain-agnostic and lives in the ooda-state crate; ooda-pr is
one of several writers sharing the same on-disk layout.
The state root resolves via this chain:
--state-root PATH$OODA_STATE_HOME$XDG_STATE_HOME/ooda~/.local/state/ooda$TMPDIR/ooda
One root per machine. Domain identity (pr slug, codex-review
level, etc.) lives inside event records, not in the path:
<root>/
├── runs/<run-id>/
│ ├── events.jsonl # source of truth (append-only typed events)
│ └── blobs/<sha>.<ext> # content-addressed payloads
├── live/<run-id> # empty marker; presence = active run
└── index/pr/<owner>/<repo>/<pr>/
└── status-comment-dedup.json # cross-run dedup memory
<run-id> is opaque (<YYYYMMDDTHHMMSSZ>-<entropy>-p<pid>); the
GitHub slug + PR number live in the target payload on the
run_started event, not in the path.
Event vocabulary. Each line in events.jsonl is a typed event
with a kind discriminator. Ooda-pr emits:
run_started— first event;domain: "pr",targetcarries forge / slug / pr / mode / max_iter / status_comment / cwd / argv.iteration_observed/iteration_oriented— references to ablobcontaining the JSON snapshot.iteration_decided— carriesdecision_kind(e.g."Execute::Rebase","Halt::HumanNeeded").iteration_executed/iteration_waited— post-act markers.iteration_handoff— references the handoff prompt blob.run_halted— terminal event;outcomecarries the boundary variant name,exit_codecarries the numeric exit. After this event thelive/<run-id>marker is deleted.domain_specific— catch-all for events the core vocabulary doesn't model (observe lifecycle, status-comment lifecycle, raw tool-call telemetry, action_started/finished).kind_suffixnames the event;payloadis opaque JSON.
Agent entrypoint. To audit a run: pick the runs/<run-id>/
directory (or watch live/ for active runs), parse events.jsonl
line-by-line, and dereference any blob field by joining
runs/<run-id>/blobs/<sha>.<ext>. Cockpit (/cockpit) provides a
live tail; for one-shot audits, walk runs/ directly.
Outcomes
Each successful invocation produces exactly one Outcome and
emits it as the final stderr block (header + variant payload).
Dispatch on $? alone — no stderr parsing required for
dispatch. Handoff* callers additionally consume the prompt
block (stderr content following the header), and UsageError
callers may surface the usage text — but neither parses stderr
to determine which variant fired; that's $?.
The --help short-circuit is an exception: it exits 0 without
constructing an Outcome at all (stdout receives the usage
text; the binary writes nothing to stderr on this path —
though the run wrapper may have already emitted cargo
warnings/errors per the wrapper-diagnostics caveat above).
Stderr surface. Stderr is divided into a diagnostic prefix (varies by mode and flags) and a final variant block (the Outcome's emission). Listed by emission site:
- Loop mode, per iteration (interleaved in iteration order):
[iter N] <ActionKind> (<Automation>) blocker: <BlockerKey>for Execute decisions — note the parentheses, distinct from the colon-separatedWouldAdvance: <ActionKind>:<Automation>header form (a single regex over both surfaces will mis-parse). Example:[iter 3] WaitForCi (Wait(1m)) blocker: ci_pending: Build.[iter N] halt: <DecisionHaltName>for halts with no action payload. ForAgentNeeded/HumanNeededhalts, the line is[iter N] halt: <DecisionHaltName> blocker: <BlockerKey>(e.g.[iter 5] halt: AgentNeeded blocker: unresolved_threads).<DecisionHaltName>is one of a finite five-element set of strings (two of which contain parentheses, so paren-splitting tokenizers or\w+-style regexes will split them):Success,Terminal(Succeeded),Terminal(Aborted),AgentNeeded,HumanNeeded. Each maps to a boundaryOutcomevariant:Success→Paused(exit 1),Terminal(Succeeded)→DoneSucceeded(exit 0, stderr headerDoneMerged),Terminal(Aborted)→DoneAborted(exit 5, stderr headerDoneClosed),AgentNeeded→HandoffAgent(exit 4),HumanNeeded→HandoffHuman(exit 3). Payloads are not expanded in the iter-log line; the boundary emission carries them —Handoff*in a content-addressed blob atruns/<run-id>/blobs/<sha>.md(pointed to by the stderrsee:line),Stuck*in the:<BlockerKey>projection, terminal/Paused with no payload.- When
--status-commentis set:[iter N] comment: posted,[iter N] comment: <PostError>, or silently skipped on the common dedup-no-change case. (See "comment lines" below.)
- Inspect mode, before the variant block (at most one each,
in this order):
stack: <base> → <root>if the PR's immediate base differs from the resolved stack root used for branch-rule lookups. Inspect-only by design: one-shot diagnostics get the stack note for context; loop mode does not emit it at all (it's static for a given PR, and per-iteration repetition would be noise).- When
--status-commentis set:comment: posted,comment: skipped (unchanged), orcomment: <PostError>.
- Final variant block (last emission, both modes): the
Outcome header, optionally followed by a single pointer line
see: <abs-path-to-handoff-blob>(Handoff*) or the usage block (UsageError). The path points at a content-addressed blob (runs/<run-id>/blobs/<sha>.md); the prompt body lives in that file, not on stderr — seeHandoff*prompt format below.
The durable audit trail lives in runs/<run-id>/events.jsonl —
structured, typed events rather than a free-form text trace.
When --trace PATH is set, the binary additionally appends the
compact human-readable trace lines (stack note, iteration log,
comment status lines, final Outcome block, and exit=<code>)
to that path as a compatibility sink. Stderr remains the binary
boundary, and $? remains the dispatch contract.
Comment lines (when --status-comment is set):
| Mode | Posted | Dedup skip | Error |
|---|---|---|---|
| Inspect | comment: posted |
comment: skipped (unchanged) |
comment: <PostError> |
| Loop | [iter N] comment: posted |
(silent — no line) | [iter N] comment: <PostError> |
Stderr placeholders:
<ActionKind>— the action's variant name (e.g.Rebase,AddressThreads). Payload-stripped:WaitForBotReview, notWaitForBotReview { reviewers: [...] }. The renderer usesActionKind::name(), a hand-written&'static strper variant.<BlockerKey>— the action's blocker identifier (defined below). The type enforces only non-empty. Construction sites interpolate typed payloads (CheckName,GitHubLogin, etc.) into format strings, so values can include any characters those types allow — typical values are ASCII-only with colons and spaces (e.g.ci_fail: Build / test), but unicode is possible if upstream payloads carry it. See theBlockerKeysection for sample values and the consequences for parsing the<ActionKind>:<BlockerKey>projection.<Automation>—FullorWait(<duration>). The renderer has arms for all 4Automationvariants, butdecideroutesAgent/Humanto halts (HandoffAgent/HandoffHuman) before they could reach aWouldAdvance. OnlyFull/Wait(_)appear here in practice — invariant established at the decide boundary, not the render boundary.<duration>is rendered as<seconds>s(under 1 minute),<minutes>m(whole minutes), or<minutes>m<seconds>sfor the mixed case. Current actions only construct intervals of 15s, 30s, or 60s — so the surface forms callers will actually see areWait(15s),Wait(30s), andWait(1m). The<minutes>m<seconds>sform (e.g.Wait(1m30s)) andWait(0s)are representable by the formatter but no current action constructs them.
Header format. The stderr headers with no payload — exactly
DoneMerged, DoneClosed, Paused (underlying variants
DoneSucceeded, DoneAborted, Paused) — emit only the
header token on the line (no colon, no trailing space). All
other variants emit <Header>: <details> (colon and one
ASCII space, then payload). A regex matching the header must
allow both forms: ^(DoneMerged|DoneClosed|Paused)$ for the
no-payload headers, ^<Header>: for the rest. There is no
StuckCapReached: (bare-colon) form — StuckCapReached
always carries an Action and always emits the
<ActionKind>:<BlockerKey> payload.
| Exit | Outcome variant | Stderr header | Caller's response |
|---|---|---|---|
| 0 | DoneSucceeded |
DoneMerged |
Stop. PR merged. |
| 1 | Paused |
Paused |
Stop driving. Internally maps from DecisionHalt::Success — per the source comment, "No actions to dispatch, no blockers — PR has reached its target state." The boundary name Paused reflects the operational meaning for the caller: stop driving, re-invoke later only if PR state may have changed (e.g., a reviewer acts, CI re-runs, auto-merge fires). |
| 2 | WouldAdvance(action) |
WouldAdvance: <ActionKind>:<Automation> |
Inspect-only — not a halt. Re-invoke without inspect to drive the action. Do not report WouldAdvance and stop; that's the most common agent error against this binary. The automation tells you what act would do (Full runs immediately; Wait(d) sleeps then re-observes). See "Driving discipline" for the full anti-pattern list. |
| 3 | HandoffHuman(action) |
Hand off to human: <prompt headline> (followed by see: <path> pointer) |
Read the prompt body from the pointed-to handoff blob (runs/<run-id>/blobs/<sha>.md). Surface the handoff to the user (see "Surface to the user" below). Re-invoke /ooda-pr after they resolve it. |
| 4 | HandoffAgent(action) |
Hand off to agent: <prompt headline> (followed by see: <path> pointer) |
Read the prompt body from the pointed-to handoff blob (runs/<run-id>/blobs/<sha>.md). Surface the handoff to the user (see "Surface to the user" below), then dispatch an agent with the prompt body as input. Re-invoke /ooda-pr after the agent finishes. |
| 5 | DoneAborted |
DoneClosed |
Stop. PR is closed without merge (e.g., abandoned). Treat per the caller's policy (often: notify owner). |
| 6 | StuckRepeated(action) |
StuckRepeated: <ActionKind>:<BlockerKey> |
Do not auto-retry. Diagnose stderr; fix the underlying issue or escalate. |
| 7 | StuckCapReached(action) |
StuckCapReached: <ActionKind>:<BlockerKey> |
Re-invoke with a higher --max-iter, or escalate. The action shown is the last action act ran successfully (Wait or non-Wait). Binary is stateless across runs (except --status-comment dedup). |
| 64 | UsageError(msg) |
UsageError: <msg> (followed by full usage block) |
Fix the invocation. The usage block (same content as --help writes to stdout) is written to stderr immediately after the header, so callers don't need to re-invoke with --help to see syntax. |
| 70 | BinaryError(msg) |
BinaryError: <msg> |
BSD sysexits EX_SOFTWARE. Caught external failure (gh subprocess, network, IO). The msg is a single-line human-triage string; do not parse it. Retry once for transient cases or escalate per caller's policy. Distinct from uncaught panics — see catch-all. |
| 130 | SignalInterrupted |
Interrupted: exit code 130 |
SIGINT (128 + 2). The loop polls SHUTDOWN_SIGNAL at each iteration boundary; on a trapped signal it appends a terminal run_halted event, releases the live marker, prints the header, and exits 130 itself. Treat as clean shutdown, not a crash. The shell synthesizes the same 128 + 2 for an uncaught signal, so callers cannot distinguish trapped from kernel paths on $? alone. |
| 143 | SignalInterrupted |
Interrupted: exit code 143 |
SIGTERM (128 + 15). Same handling as SIGINT; exits 143. |
1:1 variant-to-exit-code mapping is the design rule. Each
variant has a unique exit code; $? is sufficient for dispatch.
Exit codes 8–63 and 65–69 are unassigned. The binary never
emits them in current source. They are held in reserve for
future Outcome variants (additions follow the assigned-table
style: a new variant gets a new code in this range, no code is
ever reused). Codes ≥64 follow BSD sysexits starting at
UsageError = 64.
Payload conventions
Each variant carries exactly the evidence its caller needs:
Stuck*carries the action whose(kind, blocker)pair triggered the halt. The<ActionKind>:<BlockerKey>projection on stderr is informational only — the action is the witness.StuckRepeatedcarries the repeated non-Wait action.StuckCapReachedcarries the last actionactran successfully (Wait or non-Wait) — the most recent triage anchor when the cap fires.HandoffAgent/HandoffHumanare spelled as separate variants (rather thanHandoff(Recipient, Action)) so the recipient is observable from the variant name and exit code, preserving 1:1 dispatch.WouldAdvance(Action)carries the action; the action'sautomationfield is rendered on stderr to tell the caller whatactwould do. No separatepacepayload — it lives on the action.BinaryError(String)is intentionally opaque at the boundary. Loop mode flattens a typed loop-error union (observe failure or act failure) into the string; inspect mode can only surface observe failures (no act call). Invariant: the string contains no newlines — any newline in the underlying error is replaced with a space at construction, so the stderr header always occupies exactly one line.Pausedcarries no payload. Paused means decide selected no candidate action — there is no action to carry. Diagnostic context for "why no candidate" lives in the orient log (surfaced via--status-comment), not in the Outcome.UsageError(String)carries the parser's diagnostic, also newline-free.
Catch-all (uncaught exit codes)
ooda-pr deliberately produces only the exit codes assigned in
the table: {0..=7, 64, 70, 130, 143}. Codes 8–63 and 65–69 are
unassigned and never emitted by current source. Any exit code
outside the assigned set indicates an uncaught binary failure:
typical causes are Rust panics (101), OS signal kills the loop
did not trap (128 + signal — e.g. 137 for SIGKILL/OOM, 139
for SIGSEGV), or run wrapper failures (cargo build error).
The caller should treat such codes as BinaryError-equivalent
for dispatch (alert; do not interpret stderr as a structured
contract — it is a panic message or shell diagnostic, not a
BinaryError: header). Retry policy depends on the suspected
cause: panics are deterministic (do not retry), OS signal kills
may be transient (single retry reasonable), build failures
require source repair.
Handoff* prompt format
For HandoffAgent(action) and HandoffHuman(action), the prompt
body is written to disk as a content-addressed blob at
<state-root>/runs/<run-id>/blobs/<sha>.md, and the only
stderr emission after the header is a single pointer line of the
form:
see: <absolute-path-to-handoff-blob>
The pointer line begins with the literal 7-byte sequence: two
ASCII spaces (0x20 0x20), the ASCII word see (0x73 0x65 0x65),
an ASCII colon (0x3a), and one ASCII space (0x20). The
remainder of the line (until \n) is the absolute path. There is
no inline prompt body on stderr.
Caller protocol: read the path from the pointer line, then
read the file in full. The file is content-addressed (filename is
its SHA-256) and immutable; its size is observable via stat, so
callers should Read the whole file rather than tail-truncate
stderr (which would lose the dashboard preamble that explains why
this action was selected). Do not interpret an EOF on stderr
after the pointer line as a boundary on the prompt — the prompt
lives in the file.
The prompt content is non-empty by convention but
Action.description is a plain String and the type does not
enforce non-emptiness; the file may be zero-length. Embedded
newlines in the description appear verbatim in the handoff blob.
Surface to the user. After reading the handoff blob, the
calling agent's next user-visible response MUST expose the handoff
content to the human. At minimum, surface: (1) the final variant
block (stderr header + see: pointer line), (2) the
per-iteration stderr trail emitted during the run (bounded by
--max-iter; default cap yields ~50 [iter N] ... lines, surface
in full), and (3) the handoff blob body in full. The body already
carries the dashboard preamble (tier-grouped candidates, per-axis
signals, blockers) and the per-action prompt — surfacing the file
verbatim discharges items (2)-(3) for the blockers / signals
portion.
This requirement applies to both HandoffHuman (exit 3) and
HandoffAgent (exit 4) — even when the caller dispatches a
sub-agent, the human must see the dispatch context. The
calling agent MAY add a one-line framing summary above the
surfaced content; it MUST NOT replace the surfaced content with
a summary. Format is the caller's choice (verbatim fenced block,
structured render, collapsible region, etc.) — fidelity is the
constraint, not format.
Rationale: the dashboard preamble + per-action body encode why this halt fired and what state the PR is in. A summary that omits them loses the audit surface that lets the human (a) understand the halt without opening state files, (b) verify the agent's interpretation before approving the next action, and (c) catch cases where the agent is about to act on a wrong reading. This is not in tension with the "do not editorialize" anti-pattern under Driving discipline — that rule forbids agent vibes displacing the halt-class exit codes; this rule requires the loop's own reasoning to reach the human.
Fallback (rare): if the recorder cannot write the handoff
blob (IO failure, recorder absent), the binary falls back to the
legacy inline form — a prompt: <body> line (10-byte sentinel
␣␣prompt:␣) followed by the prompt content streamed to EOF on
stderr. Callers SHOULD prefer the see: form when present; the
fallback exists only so the prompt is never lost. The Surface
to the user requirement above applies to the fallback path
verbatim: when the inline form fires, the inline body (the bytes
after the ␣␣prompt:␣ sentinel through EOF) substitutes for the
handoff blob in item (3); items (1)-(2) are unchanged.
In inspect mode, the Handoff* prompt has the same
directive form as in loop mode — the content tells the
recipient what to do, not what would have been requested.
Single-line prompt example:
HandoffAgent: Rebase
see: /home/user/.local/state/ooda/runs/20260516T120000Z-000000000-p1234/blobs/a3f1c2…d4.md
Blob contents:
Rebase onto the latest base branch
Multi-line prompt example. The blob carries the dashboard preamble + per-action body verbatim:
HandoffAgent: AddressThreads
see: /home/user/.local/state/ooda/runs/20260516T120000Z-000000000-p1234/blobs/b7d2e9…1f.md
Blob contents:
Recommended (blocking fix): AddressThreads: 2 unresolved [blocker: unresolved_threads]
...
Blockers:
1. unresolved_threads: AddressThreads
Address 2 unresolved review threads.
Copilot: 2 issues.
1. Copilot @ src/foo.rs:42
> <body>
Mode-restricted variants
inspect emits the same Outcome variant the loop's first
iteration would produce, with **one substitution rule for t
…(truncated)