/ooda-prs
Drives N PRs concurrently through observe → orient → decide →
act until each halts. /ooda-prs is a fork of /ooda-pr and
duplicates the per-PR pipeline source code module-by-module; the
recorder module diverges (per-PR thread-local instead of
process-global) so concurrent worker threads do not alias their
tool-call sinks. The on-disk model is the shared ooda-state
crate (<state-root>/runs/<run-id>/{events.jsonl, blobs/} plus
live/<run-id> markers); decide/act semantics are unchanged from
/ooda-pr. Each invocation returns one MultiOutcome; the caller
dispatches on the aggregate exit code, parses the per-PR JSONL
records on stdout (each record carries an opaque run_id that
keys back to the per-run audit trail), and surfaces stderr to
humans for triage.
Names
| Name | Refers to |
|---|---|
/ooda-prs |
The skill (this document). |
ooda-prs |
The compiled Rust binary at target/release/ooda-prs, sibling to this SKILL.md in the source tree. |
run |
The wrapper script at ~/.claude/skills/ooda-prs/run. Runs (cd "$DIR" && cargo build --release --quiet) >&2 in a subshell so the parent shell's cwd is untouched — that's what preserves the user's cwd into the execd binary (cwd-slug inference via gh repo view depends on it). Then execs "$TARGET_DIR/release/ooda-prs" "$@", where TARGET_DIR honours CARGO_TARGET_DIR and defaults to $DIR/target. The wrapper uses set -euo pipefail, so a cargo build failure causes run to exit with cargo's non-zero exit code before the binary executes — that exit code is NOT one of the Outcome exit codes in the contract below; treat cargo build failures as a build-system error class distinct from BinaryError. |
| Suite | The non-empty, distinct Vec⟨(RepoSlug, PullRequestNumber)⟩ parsed from the suite grammar (duplicates are rejected as UsageError, not silently de-duplicated). Drives run_loop per pair in loop mode; drives run_inspect per pair in inspect mode. |
/ooda-pr |
The single-PR sibling skill, installed at ~/.claude/skills/ooda-pr/. /ooda-prs does not depend on /ooda-pr being installed at runtime — the per-PR pipeline (observe / orient / decide / act / runner) is a code-level duplicate inside /ooda-prs/src/. The recorder module differs from /ooda-pr's: /ooda-prs uses a thread_local! recorder cell so per-PR threads do not alias their tool-call sinks. Both binaries write the same on-disk ooda-state model (<state-root>/runs/<run-id>/{events.jsonl, blobs/} plus live/<run-id> markers), so the two skills' audit trails coexist on the same state root. |
Always invoke run; never the binary directly.
Type spine
Per-PR boundary types are defined in the ooda-core library
crate (/home/cory/code/skills/ooda-core/) and shared with the
three sibling OODA binaries. ooda-prs depends on ooda-core
via path dep and instantiates each generic type over its
domain-specific ActionKind enum (identical to /ooda-pr's):
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.
The suite-level MultiOutcome type stays per-binary — it's
specific to /ooda-prs's aggregate priority projection over N
PRs (see "MultiOutcome" below).
Variant name ≠ stderr / JSONL header. Rust variant names
(DoneSucceeded, DoneAborted, Paused) are internal. Stderr
headers and the JSONL outcome field both emit the PR-domain
strings (DoneMerged, DoneClosed, Paused) via the
per-binary render_outcome and outcome_variant_name
functions — so the documented regex
^(DoneMerged|DoneClosed|Paused)$ and the JSONL contract are
unchanged. The Outcomes table below shows both representations.
Per-binary code (not lifted): runner.rs (per-PR
iteration loop), recorder.rs with thread_local! cell for
parallel workers, multi_outcome.rs, decide/action.rs::ActionKind
and its ActionKindName impl, and the per-binary
From<LoopError> for Outcome.
Shared crates the recorder depends on: ooda-state (the
domain-agnostic on-disk model — runs/<run-id>/{events.jsonl, blobs/}
plus live/<run-id> markers) and ooda-core (boundary types).
The recorder is a thin per-PR adapter onto ooda-state::RunWriter.
See ooda-core/README.md and ooda-core/src/lib.rs for the
shared-spine design rationale.
Calling discipline
$? MUST reflect ooda-prs's exit when ooda-prs runs. The same
two concerns from /ooda-pr apply verbatim:
ooda-prs must actually run.
false && ooda-prs ...short-circuits and ooda-prs never executes.Nothing may inject another exit code into
$?. Pipes (ooda-prs | jq) replace$?with the last pipeline element's exit code unlessset -o pipefailis in effect (or you read${PIPESTATUS[0]}in bash /${pipestatus[1]}in zsh). Backgrounding (ooda-prs &) replaces$?with the background spawn's status. Any subsequent command (ooda-prs; echo x) replaces$?with the subsequent command's status.Command substitution (
out=$(ooda-prs …)) preserves$?— the inner command's exit code is what$?reads immediately after the assignment. It is a safe capture pattern. (File redirection> prs.jsonlis also safe.)
Capturing stdout (the JSONL records) safely: either capture via
command substitution (out=$(ooda-prs ...)) or redirect to a file
(ooda-prs ... > prs.jsonl); both preserve $?. Pipes through jq
require either set -o pipefail first, or the
${PIPESTATUS[0]} (bash) / ${pipestatus[1]} (zsh) idiom, or a
tempfile staging step.
# Capture stdout to a file (preserves $?), then dispatch on $?.
# Loop-mode invocation; no `2)` arm — exit 2 (WouldAdvance) is
# inspect-only and cannot occur in loop mode.
~/.claude/skills/ooda-prs/run acme/widget 1 2 > prs.jsonl
case $? in
0) echo "all done (all PRs terminal/Paused)" ;;
1) echo "all PRs Paused (re-invoke later)" ;;
3) jq -r 'select(.outcome=="HandoffHuman")' prs.jsonl | notify_human ;;
4) jq -r 'select(.outcome=="HandoffAgent") | .prompt' prs.jsonl | dispatch_agents ;;
6|7|70) jq . prs.jsonl >&2; escalate ;;
64) echo "fix invocation" >&2 ;;
130|143) echo "signal-killed ($? — SIGINT/SIGTERM)" >&2 ;;
*) echo "unknown exit $? — likely a cargo build failure (see 'run' wrapper)"; \
exit 1 ;;
esac
The *) default arm catches non-Outcome exit codes the run
wrapper itself can produce. Cargo build failures inside the
wrapper's subshell propagate through set -euo pipefail with
cargo's own exit code — commonly 101 for compile errors and
1 for many cargo-cli failures.
The redesigned exit-code scheme separates information-bearing
halts (1–7) from system errors (64, 70). Cargo's 1 no longer
aliases any Outcome variant (StuckRepeated is now 6), and
the *) default arm cleanly catches cargo-1, 101, and any
128 + signal code the dispatch table doesn't enumerate.
Wrapper failures fall through to *) predictably.
Surface per-PR handoffs to the user verbatim. When a downstream
consumer (notify_human, dispatch_agents, an interactive
wrapper agent) presents a per-PR HandoffHuman / HandoffAgent
record to a human, the surface MUST be verbatim: the JSONL
record's prompt field already carries the dashboard preamble +
per-action body that explain why this PR halted. Do not collapse
into a one-line summary; the human needs the full body to
(a) understand each halt without opening per-PR state files,
(b) verify the orchestrator's interpretation before approving the
next action, and (c) catch cases where the orchestrator is about
to act on a wrong reading. Format is the consumer's choice
(verbatim fenced block, structured render, collapsible per-PR
section, etc.) — fidelity is the constraint, not format. The
per-PR handoff blob at
<state-root>/runs/<run-id>/blobs/<sha>.md (pointed at by the
stderr see: line) carries the same content for any consumer
that prefers files over JSONL field reads. See
/ooda-pr SKILL.md §Handoff* prompt format → "Surface to
the user" for the single-PR rationale; the same applies per-PR
in the suite.
Suite grammar
The grammar is token-scan-based: any argv token that begins
with -- (or is exactly -h) is consumed as a flag at the
position where it appears; an unrecognized --<name> is rejected
as UsageError; any other token accumulates into a positional
vector that the parser then splits on ,. (Single-dash tokens
other than -h — e.g. -x, -1 — are NOT recognized as flags;
they fall through to positional and fail later as malformed PR
numbers.) Recognized flags may interleave anywhere — before the
first group, between groups, between PR tokens within a group, or
trailing — without changing the parsed suite.
The compact production below uses set notation rather than sequence notation (Kleene-star over alternation) to capture this:
<argv> ::= ⟨ tokens ⟩ where:
flag-tokens ⊆ <flag>+ (any positions, free interleave)
inspect-token ∈ {'inspect'}? (at most one; if present,
must appear before any positional token)
positional-tokens, when split on ',' and re-tokenized
on whitespace, yield ≥ 1 <group>
<group> ::= <slug-or-pr> <pr>*
<slug-or-pr>::= <slug> | <pr>
-- the FIRST token of a group containing '/' MUST satisfy
<slug>'s shape (`RepoSlug::parse` rejects 0 or ≥2
slashes); a non-first token containing '/' attempts
<pr>::parse and fails. Tokens with no '/' parse as
<pr>.
<slug> ::= owner '/' repo
-- both `owner` and `repo` are non-empty and contain no
'/' (validated by `Owner::parse` / `Repo::parse`).
Whitespace exclusion comes from the upstream
whitespace tokenization, not the slug parser itself.
<pr> ::= [0-9]+ -- parsed as u64, then
rejected if value = 0
(PullRequestNumber::new
requires > 0). Leading
zeros are accepted at
the parser ("07" → 7).
<flag> ::= '--max-iter' POS_INT | '--concurrency' POS_INT
| '--state-root' PATH | '--trace' PATH
| '--status-comment' | '-h' | '--help'
POS_INT ::= [0-9]+ -- parsed as u32 via
`v.parse::<u32>()`, then
rejected if value = 0
(must be ≥ 1). A leading
`-` sign is detected
(via `starts_with('-')`)
and rejected with a
distinct "got negative
value" diagnostic. Leading
zeros are accepted at
the parser ("07" → 7),
same as `<pr>`.
PATH ::= any string -- path on the host filesystem
--max-iter=10 and --concurrency=2 are NOT accepted; the parser
expects the flag and value as separate argv tokens (via
iter.next()). Each flag (other than -h / --help) may appear
at most once; a repeat is a UsageError. -h / --help is
consumed by a pre-scan that runs before the main parse loop:
if either token appears anywhere in argv, usage is printed to
stdout and the process exits 0 — no other validation runs, so
--help --help, --max-iter 0 --help, etc. all exit 0. This
makes the grammar's "at most once" rule structurally inapplicable
to -h / --help. The inspect keyword may appear at most
once and only before any positional token; a second inspect
falls through to the positional vector and fails as a non-numeric
<pr>.
The parser's left-to-right scan: any token that begins with --
(or is -h) is consumed as a flag together with its value if
any; inspect is consumed as the mode subcommand only if no
positional has yet been pushed (otherwise it attempts to parse
as a PR token and fails); all other tokens become positionals.
After the scan, the positional vector is joined with spaces and
split on , to form groups; commas may
therefore appear as standalone tokens or as suffixes/prefixes on
adjacent tokens, all of which the split treats uniformly.
Slug resolution:
- A
<slug>is detected by the presence of'/'in the first token of a group only. Once a slug is consumed, every remaining token in that group is parsed as<pr>; a/in a non-first token causes the PR parse to fail (UsageError). - A group with no explicit slug inherits the prior group's
slug. The very first group, if it has no explicit slug, falls
back to inferring the cwd's repository via
gh repo view --json nameWithOwner --jq .nameWithOwner. - Cwd inference failure (no
gh, not a github repo, malformed output) is aUsageErrorwith the diagnostic.
Distinct elements: the parser rejects duplicate (slug, pr)
pairs as UsageError("duplicate PR: <slug>#<pr>") — duplicates are
not silently de-duplicated. Comma-separated groups must be
non-empty (lone ,, rejects).
The table below shows just the suite portion of the argv for
brevity. Always invoke the wrapper at ~/.claude/skills/ooda-prs/run
in real use (see "Calling discipline" above).
| Suite portion of argv | Parsed suite |
|---|---|
42 45 |
[(cwd, 42), (cwd, 45)] |
765 777 983 |
[(cwd, 765), (cwd, 777), (cwd, 983)] |
acme/widget 42, acme/infra 100 |
[(acme/widget, 42), (acme/infra, 100)] |
acme/widget 42 43, acme/infra 100 |
[(acme/widget, 42), (acme/widget, 43), (acme/infra, 100)] |
acme/widget 42, 43 |
[(acme/widget, 42), (acme/widget, 43)] (slug inheritance) |
42, acme/infra 100 |
[(cwd, 42), (acme/infra, 100)] |
How to call
~/.claude/skills/ooda-prs/run [options] <suite> # loop mode
~/.claude/skills/ooda-prs/run inspect [options] <suite> # one pass per PR
| Flag | Meaning |
|---|---|
--max-iter N |
Per-PR iteration cap. Default 50. Must be ≥ 1. Inspect mode runs once per PR (cap unused, but --max-iter 0 still rejects). |
--concurrency K |
Maximum simultaneously-active PRs (workers). Default is the suite size (no cap). Must be ≥ 1; K = 0 is rejected at the parser. K > suite size is silently clamped to the suite size. With K < suite size, K worker threads pull PRs from an atomic counter — a worker may handle multiple PRs sequentially while another is on its first PR. |
--status-comment |
Post status comments to each PR every iteration. Per-run dedup at <state-root>/runs/<run-id>/status_comment_dedup.json. Dedup is scoped to a single run — re-invoking the binary opens a fresh run with no prior dedup memory. |
--state-root PATH |
Override the always-on state root. Resolution chain (first that yields a value wins): (1) --state-root PATH if given, (2) $OODA_STATE_HOME if set and non-empty, (3) $XDG_STATE_HOME/ooda if $XDG_STATE_HOME is set and non-empty, (4) $HOME/.local/state/ooda if $HOME is set and non-empty, (5) std::env::temp_dir().join("ooda") (e.g. /tmp/ooda on Linux). Empty env vars are treated as unset (a ="" value falls through). The state root is domain-agnostic — one root per machine, shared by every OODA agent. PR identity lives only inside event records (target.{forge,slug,pr}), never in the on-disk 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. One path covers the whole suite — ooda-prs drives many PRs but only one local working tree at a time. Pinning is required so gt sync cannot rewrite a sibling repo's stack when the binary is invoked from elsewhere on disk. |
-h, --help |
Print usage to stdout, exit 0. Pre-scan short-circuits all other validation including flag-repetition checks (--help --help exits 0). |
Repeating a flag (--max-iter, --concurrency,
--status-comment, --state-root, --repo-root) is a UsageError, except
-h / --help, whose pre-scan short-circuits all parser
validation.
Always-on state
An invocation that parses successfully opens one per-PR run
through [ooda-state]. On UsageError (parser failure) no run is
opened. A per-PR recorder-open failure turns into that PR's
Outcome::BinaryError; other PRs proceed independently.
Layout
The on-disk model is domain-agnostic: paths carry no PR identity. PR slug / PR number / mode / etc. live only inside event records.
<state-root>/
├── runs/<run-id>/
│ ├── events.jsonl # source of truth (append-only)
│ ├── blobs/<sha>.<ext> # content-addressed payloads
│ └── status_comment_dedup.json # per-run mutable; present only
│ # when --status-comment fires
└── live/<run-id> # empty marker; presence = "active"
One runs/<run-id>/ directory per worker. Two PRs in the same
suite yield two distinct <run-id> directories under the shared
<state-root>/runs/; the <run-id> is opaque (<YYYYMMDDTHHMMSSZ>-<nanos>-p<pid>
shape, see ooda_state::RunId::generate).
Event stream
runs/<run-id>/events.jsonl is the append-only source of truth.
Each line is a typed event with a kind discriminator:
kind |
Carries |
|---|---|
run_started |
domain: "pr", target: {forge, slug, pr, mode, max_iter, …} |
iteration_observed |
iteration, blob (normalized observation snapshot) |
iteration_oriented |
iteration, blob (oriented snapshot) |
iteration_decided |
iteration, decision_kind |
iteration_executed |
iteration, action_kind |
iteration_waited |
iteration, action_kind, interval_ms |
iteration_handoff |
iteration, variant, action_kind, blob (prompt body) |
run_halted |
outcome (variant name), exit_code |
domain_specific |
kind_suffix, payload — observe / action / wait / tool-call frames |
The domain_specific kind is the catch-all the recorder uses for
PR-domain observability events that don't have a typed counterpart
in ooda-state. The kind_suffix distinguishes them at read time
(observe_started, observe_finished, action_started,
action_finished, wait_started, wait_finished,
tool_call_started, tool_call_finished,
status_comment_rendered, status_comment_result, decision_envelope,
dashboard, outcome, trace_line).
Blobs
Iteration snapshots (normalized observation, oriented state),
handoff prompt bodies, and tool-call stdout / stderr captures are
written as content-addressed blobs in runs/<run-id>/blobs/. Each
event referencing a blob carries a BlobRef (sha, size,
ext). Dedup is per-run automatic — repeated identical bytes
write only one file.
The handoff prompt path surfaced on the stderr see: pointer
targets a blob directly:
<state-root>/runs/<run-id>/blobs/<sha>.md.
Live marker
<state-root>/live/<run-id> is an empty file present from
run_started to terminal (run_halted / run_stalled /
run_cap_reached). Audit / cockpit tooling can enumerate
live/ to find currently-active runs; on terminal events the
marker is removed.
Concurrency
Each worker writes a distinct runs/<run-id>/ directory; no two
workers share a subtree. The state root's runs/ and live/
parents are shared but every leaf write is scoped to its own
run-id, so there is no cross-worker contention or torn-write
hazard at the recorder layer. Two simultaneous invocations
across overlapping PRs each get distinct <run-id>s; the
per-run dirs are disjoint by construction.
Joining JSONL records back to the audit trail
Each per-PR stdout JSONL record (see "Output channels") carries a
run_id field. The corresponding on-disk audit trail lives at
<state-root>/runs/<run-id>/events.jsonl. There is no separate
suite-level manifest or pointer file — the (slug, pr, run_id)
triple on each stdout record is the index.
MultiOutcome
MultiOutcome =
UsageError(String) -- parser failure; no PRs ran
⊕ Bundle(Vec⟨ProcessOutcome⟩) -- every PR reached a halt state
ProcessOutcome = (RepoSlug, PullRequestNumber, Outcome)
Outcome = -- per-PR; identical to /ooda-pr
DoneSucceeded -- stderr: "DoneMerged"
⊕ StuckRepeated(Action)
⊕ StuckCapReached(Action)
⊕ HandoffHuman(Action)
⊕ WouldAdvance(Action)
⊕ HandoffAgent(Action)
⊕ BinaryError(String)
⊕ Paused
⊕ DoneAborted -- stderr: "DoneClosed"
⊕ UsageError(String) -- parser-only; never appears
in a ProcessOutcome
Each PR's Outcome is bit-equivalent to running /ooda-pr on that
PR alone. The suite boundary lifts these per-PR Outcomes to a
single binary boundary for shell dispatch.
Outcome::UsageError is listed in the per-PR Outcome type for
shape-completeness, but /ooda-prs constructs it only at the
suite parser and lifts it directly to MultiOutcome::UsageError —
it never appears inside a ProcessOutcome. Stdout JSONL records
therefore carry only the 10 reachable variants (see Output
channels).
Aggregate exit code ($?)
MultiOutcome::exit_code() is a priority projection:
| Condition | $? |
Mode reachability |
|---|---|---|
MultiOutcome::UsageError |
64 | both (parser-level) |
any ProcessOutcome carries SignalInterrupted{143} |
143 | both |
else any carries SignalInterrupted{130} (or other u8) |
130 | both |
else any carries BinaryError(_) |
70 | both |
else any carries HandoffAgent(_) |
4 | both |
else any carries HandoffHuman(_) |
3 | both |
else any carries StuckCapReached(_) |
7 | loop only |
else any carries StuckRepeated(_) |
6 | loop only |
else any carries WouldAdvance(_) |
2 | inspect only |
else any carries DoneAborted |
5 | both |
else every PR ∈ {DoneSucceeded, Paused} |
0 | both |
Priority order (highest first): UsageError > SignalInterrupted(143) > SignalInterrupted(130) > BinaryError > HandoffAgent > HandoffHuman > StuckCapReached > StuckRepeated > WouldAdvance > DoneAborted > non-actionable. Priority is
semantic, not numeric — the first matching condition wins,
even though StuckRepeated's code (6) is numerically smaller
than HandoffAgent's (4). SignalInterrupted ranks above
every other per-PR outcome so a wrapper polling $? learns the
shutdown happened without parsing stdout; SIGTERM (143) beats
SIGINT (130) so the higher-urgency token surfaces on a mixed
bundle. DoneAborted (exit-5 per-PR, exit-5 at suite level) is
not in the non-actionable class — a closed-without-merge PR
is operationally distinct from a merged one, and the suite
exit code surfaces the closure so harness callers can route on
the difference. The "non-actionable" class is the union
{DoneSucceeded, Paused}: every PR is either fully merged
(exit-0 per-PR) or has no candidate action this pass (exit-1
per-PR — "Paused" is not a terminal lifecycle state, just a
poll-back-later signal). Both collapse to suite-level $? = 0;
per-PR JSONL records disambiguate which of the two each PR
landed on.
This contract is coarser than /ooda-pr's 1:1 variant→exit
mapping by design: a single byte of $? cannot encode N PRs
losslessly when N > 1, so the suite boundary projects per-PR
state into the action class the harness needs ("any agent work? any
errors?"). The fine-grained per-PR state lives on stdout (JSONL,
PR-keyed by construction).
Output channels
Three channels, structurally distinct:
Stdout — JSONL records (the agent-harness contract)
After all PRs halt, one record per PR is emitted to stdout in input order. Each record is a single line of JSON:
Loop-mode example (no inspect subcommand; only halt-state
outcomes appear):
Records below are schematic in two ways: (a) prompt strings
are abbreviated, and (b) field order is shown in a reader-friendly
order (slug, pr, outcome, exit, …) to match the schema table.
The live binary serializes via serde_json without the
preserve_order feature, so the actual on-disk order is
alphabetical by key. Parse the records as JSON; do not rely on
field position. Live alphabetical-order forms of the records below
would be: keys sorted lexicographically (e.g. action, blocker,
exit, outcome, pr, prompt, slug for HandoffAgent
records).
{"slug":"acme/widget","pr":1,"pr_url":"https://github.com/acme/widget/pull/1","run_id":"20260517T142500Z-000000123-p4242","outcome":"DoneMerged","exit":0}
{"slug":"acme/widget","pr":2,"pr_url":"https://github.com/acme/widget/pull/2","run_id":"20260517T142500Z-000000456-p4242","outcome":"HandoffAgent","exit":4,"action":"AddressThreads","blocker":"unresolved_threads","prompt":"Address 2 unresolved review threads.\nCopilot: 2 issues.\n\n1. Copilot @ src/foo.rs:42\n > body line 1\n\n…"}
{"slug":"acme/infra","pr":100,"pr_url":"https://github.com/acme/infra/pull/100","run_id":"20260517T142500Z-000000789-p4242","outcome":"HandoffHuman","exit":3,"action":"RequestApproval","blocker":"not_approved","prompt":"Request or self-approve"}
Inspect-mode example (ooda-prs inspect …; advancing actions
become WouldAdvance because act is skipped):
{"slug":"acme/widget","pr":1,"pr_url":"https://github.com/acme/widget/pull/1","run_id":"20260517T142500Z-000000123-p4242","outcome":"WouldAdvance","exit":2,"action":"MarkReady","blocker":"draft","effect":"Full"}
{"slug":"acme/infra","pr":100,"pr_url":"https://github.com/acme/infra/pull/100","run_id":"20260517T142500Z-000000789-p4242","outcome":"WouldAdvance","exit":2,"action":"WaitForCi","blocker":"ci_pending: build","effect":"Wait(1m)"}
The two modes do not mix in a single invocation. Loop-mode bundles
never carry WouldAdvance. Inspect-mode bundles carry no
StuckRepeated (no second iteration to compare) and no
StuckCapReached (no cap is consulted). They CAN carry
WouldAdvance (the inspect-only artifact for an Execute(action)
decision), every Handoff* variant, every terminal/Paused
variant, and BinaryError.
Schema (the automation field is rendered by format_automation,
which delegates to format_duration for the Wait{interval} arm;
format_duration produces <seconds>s, <minutes>m, or
<minutes>m<seconds>s, picking whichever form is non-redundant for
the duration's value):
| Field | Type | Always present? | Notes |
|---|---|---|---|
slug |
string | yes | <owner>/<repo> |
pr |
integer | yes | positive integer |
pr_url |
string | yes | https://github.com/<owner>/<repo>/pull/<pr> |
run_id |
string | yes | Opaque [ooda_state] run id. Joins the record back to <state-root>/runs/<run-id>/events.jsonl. Empty string when the per-PR recorder failed to open (the same condition that produced Outcome::BinaryError for the PR). |
outcome |
string | yes | variant name; 10 reachable values in stdout: DoneMerged, StuckRepeated, StuckCapReached, HandoffHuman, WouldAdvance, HandoffAgent, BinaryError, Paused, DoneClosed, SignalInterrupted. UsageError is suite-level only and never appears here. |
exit |
integer | yes | per-PR exit code in {0, 1, 2, 3, 4, 5, 6, 7, 70, 130, 143} — the 1:1 mapping inherited from /ooda-pr, extended with the two SignalInterrupted codes. 64 does not appear in JSONL records (UsageError emits no stdout); a per-PR SignalInterrupted lands when a worker traps the shutdown signal mid-loop and folds into the suite-level priority projection (see Aggregate exit code). |
action |
string | conditional | present iff outcome ∈ {StuckRepeated, StuckCapReached, HandoffHuman, HandoffAgent, WouldAdvance} — the ActionKind::name() (e.g. "Rebase", "AddressThreads") |
blocker |
string | conditional | same condition as action — the BlockerKey payload, a non-empty stable identifier. Typical values are ASCII with : and spaces (e.g. "ci_fail: Build / test"), but no surface form is contractual; consumers must not parse it. |
prompt |
string | conditional | outcome ∈ {HandoffAgent, HandoffHuman} — verbatim agent/human prompt from Action.description. Multi-line content is JSON-string-escap |
…(truncated)