# Ooda Prs

> Drive N PRs through observe → orient → decide → act in parallel. Each invocation produces one MultiOutcome the caller dispatches on. Aggregate priority projection on `$?`; per-PR records on stdout (JSONL); per-PR variant blocks on stderr.

- Skill: `corygabrielsen/ooda-prs` (Agent Skill, multi-file: 103 files)
- Install (CLI): `npx skillmds@latest add corygabrielsen/ooda-prs`
- Raw SKILL.md: https://api.skillmd.com/api/skills/corygabrielsen/ooda-prs/raw
- Safety review: pending (external: skill-scanner PASS, skillspector WARNING)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: corygabrielsen (https://skillmd.com/u/corygabrielsen)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/corygabrielsen/ooda-prs

---


# /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 `exec`d binary (cwd-slug inference via `gh repo view` depends on it). Then `exec`s `"$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):

```rust
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:

1. **ooda-prs must actually run.** `false && ooda-prs ...`
   short-circuits and ooda-prs never executes.
2. **Nothing may inject another exit code into `$?`.** Pipes
   (`ooda-prs | jq`) replace `$?` with the last pipeline element's
   exit code unless `set -o pipefail` is 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.jsonl` is 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.

```bash
# 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 a `UsageError` with 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

```bash
~/.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.

```text
<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).

```jsonl
{"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):

```jsonl
{"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)
