feigi

run-team

Run an agent fleet — implementers, reviewers (default 2 and 6), one merge bot — over the ready-for-agent queue. Invoke-only; the fleet writes to a live repo and must never start unasked.

feigi 0 Updated 2w ago

Resources

1
GitHub

Install

npx skillscat add feigi/fleet-plugin/run-team

Install via the SkillsCat registry.

SKILL.md

Run next-ticket, review-and-fix, run-merge-bot as one fleet. You are the
controller, in the main thread, never a member.

$ARGUMENTS = [implementers] [reviewers], both optional, default 2 and 6, no
hard cap — they become the tick's --implementer-cap and --reviewer-cap.
Merge bot is at most one, not configurable.

Rationale: docs/specs/2026-07-22-run-team-agent-fleet-design.md. The
war story behind a rule ending See references/<file> lives in that file; load
it only when a member needs the why. A rule without that line carries its
reasoning inline — nothing is missing.

Rules that fail silently

A report is delivered, not implied by ending a turn. Every member owes
the controller one report naming its outcome before it exits — including a
member whose prompt carries no explicit report line. The "report the SHA",
"report PR number" and "stop and report" instructions below are that
report, not a second obligation on top of it. A member that does the work
correctly and ends its turn has reported nothing: the controller learns the
outcome only by re-reading artifacts and pinging, and a finding held only in
that member's context is lost. Members did exactly this in one run —
finishers, a reviewer and merge bots alike, the work done and only the
delivery missing. Say it in every dispatch prompt. See
references/member-lifecycle.md.

Say in every dispatch prompt that you do not acknowledge reports. A
member cannot see whether its report arrived, so silence from you is
indistinguishable from loss and it re-sends — three members did in one run,
each burning a turn, and in all three the original had in fact arrived. Give
them the line verbatim: "The controller does not acknowledge reports. Send
once and exit; never re-send unless the controller asks by name."

But a missing report is NOT evidence the member failed to send one — the
inbound direction drops AND delays messages.
Both were measured in one run,
and they are indistinguishable at the moment you look: one finisher's report was
accepted with a msg_id and never arrived at all, while another's arrived late —
its original and its re-send landing together, after the controller had already
concluded it was missing. Measured: a finisher completed every
duty, labelled the PR, and sent its report; the send was accepted with a msg_id
and never arrived. The controller read the silence as the member ending its turn
without reporting, said so, and was wrong. The re-send it asked for by name is
the only reason the report exists. So when a report is missing, ask by name —
that is what the "unless the controller asks by name" clause is for — and do not
attribute the gap to the member until it answers.
A member re-sending on a
named request should state the original's msg_id and that it is a re-send, which
is what distinguishes a lost message from a member that never sent one; a
controller that skips the ask cannot tell those apart and will guess wrong in
whichever direction its expectations point.

Note this cuts against the paragraph above only in appearance: the default stays
send once, because unprompted re-sends were measured 2-to-1 false. The ask is
the controller's move, not the member's.

Your OWN messages can vanish the same way, and that half is not recoverable by
a re-send.
Two controller rulings were lost in one run, both with a
success: true receipt: a member proceeded on its stated default and filed a
tracker issue the lost ruling had said not to file. So never read a member's
silence as assent
, and require members to report their confirmation state
alongside results — "I escalated X, received no ruling, proceeded on default Y".
That sentence is the only thing that made either loss visible. After any ruling
you cannot confirm was received, re-check the artifacts it governed.

Name every member. The name is the ledger token and the hub address; omit
it and the ledger cannot match the member.
Names follow the unit of work: impl-<issue#>, fix-pr-<pr#>,
review-pr-<pr#>, finisher-pr-<pr#>, merge-bot-<n>. See
references/member-lifecycle.md.

A depth-2 member cannot dispatch further (task.maxRecursionDepth: 2):
if task is absent at your depth, ask the controller to dispatch the
specialists and relay their reports. Say so in a fallback reviewer's prompt,
or it silently downgrades to a solo review. See references/member-lifecycle.md.

Fresh context per member. One member, one unit of work, gone. Never
re-task a finished member — waking it drags the old ticket back in. Refill =
new member, new name. Sending is still right for pinging a live
member for a report it owes, or resuming a truncated reply — never for
handing a finished member the next ticket.

hub send to an idle peer wakes it into its old transcript the same way; re-dispatching under the same name does not reset it — omp auto-suffixes a fresh peer (name-2) instead.

See references/member-lifecycle.md.

You read your instruments out of a tree every member can write to — re-check
them before every gate decision.
Per-member worktrees and ./agent-test's port
derivation protect members from each other; neither protects the main checkout,
which you run every gate probe out of and which any member can edit — except
through a write/edit/ast_edit the member-write-guard extension refuses
(ADR 0020); a bash or eval write still lands (#2210). Measured: a
member edited two files there instead of in its worktree while the CI monitor was
polling one of them every 120s and a live finisher was using it to decide a
label. It caught itself; nothing in the fleet would have. That is the silent
direction — a modified instrument raises no error, shows in no PR and leaves
nothing in the ledger, and it still returns a verdict, just not necessarily the
right one, so you have no reason to re-check a script you did not know had
changed.

  • Pin once, at phase 0 step 0 below, from the main checkout you are
    running this from — never from a member's worktree, which is gitignored
    and carries no baseline:
    ~/.fleet/bin/fleet-run instruments.sh --pin. On a workspace that does not
    track plugin/…, the pin names the plugin checkout once — --pin --audit <plugin-checkout> — and every check after it follows the recorded tree.
  • Re-check before you act on any instrument reading — a SHA acceptance, a CI
    verdict, the reconcile, a reap, a label gate — from that same main checkout:
    ~/.fleet/bin/fleet-run instruments.sh. Exit 0 is the only code that
    lets a gate proceed. Exit 1 (the set changed) and exit 2 (the check could not
    answer) both refuse: report what it printed and do NOT re-read the
    instrument
    — a guard that fails open on "could not look" protects nothing.
    A label gate and a CI verdict are also taken by dispatched members, not
    only by you
    — the finisher labels, and the merge bot merges on its own
    reading — and neither is dispatched to read this file, so neither can be
    reached by stating the rule here. Each carries its own copy: the finisher's
    in the verbatim block at its duties below, the merge bot's inside
    merge-gate.mjs, step 3 of run-merge-bot.md.
  • Re-pin only after a change you made deliberately. The mid-run tooling fix
    below is the one legitimate writer. Re-pinning to clear a refusal you cannot
    explain discards the check.

It covers every tracked file under this repo's own plugin component directories
— plugin/commands/, plugin/scripts/, plugin/skills/, plugin/agents/,
plugin/workflows/ — the probes, the libraries they source, and these runbooks,
which you also read from that tree. Not the whole repo: docs/metrics/ is
appended by THIS run, so covering it would refuse on the run's own bookkeeping
every time. It does not cover refs, deliberately: claim-ticket.sh creates a
branch per ticket and reap.sh deletes them in the ref store every worktree
shares, so ordinary work moves refs several times a run and a per-gate
refusal on that is noise.

A member runs its test suite in the foreground and blocks on it.
Backgrounding a suite and polling for it makes the member's own progress
depend on a wake-up nothing in the fleet guarantees, and an idle member is
indistinguishable from a working one — you learn only by pinging. Measured
twice in one run: fix-pr-1184 backgrounded its suite plus a Monitor and sat
idle for roughly two hours; neither ever woke it, and the only reason no work
was lost is that you pinged it by name with a concrete next action.
finisher-pr-1184 reached for the same pattern minutes later — a proactive
warning is the only reason it did not repeat the stall. Its
pgrep -f 'finish-finisher-pr-1184' could never have matched any process, so
the poll it was waiting on was structurally incapable of firing. Say it in
every dispatch prompt — this file is yours, not theirs, so a member learns
it only if you write it into the prompt.

Shell traps

Monitor and Bash commands run under zsh, and the shell in this file is
prescriptive — copied verbatim into a watcher or pasted into a bot's brief. So
every trap here is a zsh-vs-bash difference that ships as a green-looking gate:
correct in the shell you tested in, silently wrong in the one it runs in. Phase
3's monitor paragraph and the Merge bot's brief both point here
instead of restating any of it; the one deliberate copy is named at the end.

zsh word-splits an unquoted command substitution's result; it does NOT split
an unquoted parameter expansion.
That one difference is the whole rule, and the
hazard is the assigned-variable form, never $(…). Measured on zsh 5.9
against 849, 850, 852 on three lines:

Form zsh bash / sh
prs=$(gh pr list …) then for pr in $prs 1 iteration, all three glued into one value 3
for pr in $(gh pr list …) 3 3
for _ in $(seq 1 60) 60 60

Word the rule against the first row. A warning spelled never for x in $(…) names the safe form as the hazard, so a reader avoids the form that works,
keeps the one that collapses, and rewrites correct bounded polls on the way.
#854 was filed carrying that inversion in its own remedy clause and in its title,
and its filer measured both forms and retracted it; the corrected rule is this
row. $(…) splits. Leave it alone.

Capture-then-loop needs while IFS= read -r fed by a heredoc, which is
line-exact under every shell:

while IFS= read -r pr; do
  [ -n "$pr" ] || continue                        # a blank line from an empty capture, not a probe that could not look
  node …/ci-state.mjs --pr "$pr" </dev/null      # every inner call, per the trap below
done <<EOF
$prs
EOF

The inline $(printf '%s\n' "$prs") form splits correctly too and is what Phase
3's block uses, but it splits on every whitespace character, so it is safe only
for values that cannot contain one — PR numbers cannot, branch names and paths
can. Measured on a two-line list whose first value is two words: three
iterations through $(printf …), two through while IFS= read -r. On an empty
$prs the counts diverge the other way: for pr in $(printf '%s\n' "$prs")
iterates zero times, but the heredoc form iterates once with pr empty —
the guard above is what stops that iteration from probing --pr "".

</dev/null every command inside a while read loop. The loop's stdin is
the list, and any inner command that reads stdin eats the rest of it. Measured on
the three-line list above: one unredirected inner reader takes the loop to 1
iteration
, </dev/null on it restores 3 — the identical signature to the
word-split bug, so a correct heredoc fix missing this half looks exactly like no
fix at all, and reads as one.

A probe that could not look emits an event; it never continues. Stated in
full at A probe that cannot read is a transition event, not silence in Phase 3,
and it binds every watcher in this file rather than that one block: the third
value beside pass and fail is what turns a broken loop into a loud failure
instead of a quiet one. A guard that drops an unreadable probe — [ -n "$json" ] || continue — makes could not look indistinguishable from nothing to report.

$?/PIPESTATUS is unconditionally-TRUE in zsh. zsh has no PIPESTATUS
(its array is lowercase pipestatus, 1-indexed), so ${PIPESTATUS[0]} is always
empty and [ "" -eq 0 ] passes. A bot's merge gate opened without ever reading
an exit code.

status is a READ-ONLY variable in zsh, an alias for $?. So the obvious
bash idiom for reading the payload — status=$(jq -r '.status' ci.json) — aborts
with read-only variable: status. Measured this way it fails safe (a hard abort,
no merge), but the same assignment inside an if, or with stderr suppressed,
reads as a check that silently did not run. Same root cause as the PIPESTATUS
trap, opposite failure direction — so name the variable anything else
(ci_status).

The one deliberate copy is env $cfg sh ./probe.sh. The first trap's
mechanism bites argv as well as loops, and that instance stays written out in the
fix-applier prompt and every refuter brief under Reviewers rather than here,
because the seats that inject faults are the ones that need it and a bot reads
gates without ever injecting one. Do not fold it into this block, and do not
restate this block there.

Phase 0 — shortlist

Once per run, at start. Supply is automatic after that (ADR 0013): the tick
refreshes the Shortlist itself, and every admission is a Pull — one ticket
into one free implementer slot, the moment it frees (phase 1). Nothing in this
phase, or in any later one, asks the maintainer which tickets to take.

  1. Once per run, before anything else.

    Fast-forward the checkout before you trust these rules — you are reading
    them from it.
    git fetch origin && git rev-list --count main..origin/main;
    non-zero means the SKILL.md you are executing is superseded, so
    git merge --ff-only origin/main and re-read what changed under
    commands/, scripts/ and skills/. The usual cause is the PREVIOUS run's own close-out PR: it
    lands the rules THIS run needs and nothing pulls them. Measured twice — 57
    commits stale in one run, 20 in another, the second shipping every dispatch
    prompt without two rules that had merged the day before. Silent by
    construction: stale text reads as authoritative.

    Pin the instruments, after that fast-forward and before anything reads
    them, from this main checkout.
    ~/.fleet/bin/fleet-run instruments.sh --pin
    records what the scripts and runbooks in this repo say right now, which is
    what every later gate compares against; the rule above says what its exit
    codes mean. Pinning before that fast-forward would instead pin the
    superseded text and certify it for the rest of the run.
    When this workspace does not track plugin/… — fleets run here on an
    installed plugin rather than a vendored tree — the bare pin refuses on an
    empty instrument set, because this checkout holds no instruments to certify.
    Name the plugin checkout to the pin instead:
    ~/.fleet/bin/fleet-run instruments.sh --pin --audit <plugin-checkout>.
    The baseline records the audited tree beside the digest, and every later
    check — yours, a finisher's, the merge gate's own leg — follows that
    recorded root without being told twice. Refusing the bare pin and naming
    the tree once is the whole cross-workspace contract; never dispatch a
    member into a run whose phase-0 pin refused.

    Record the main-checkout baseline in the same step, right after the
    pin.
    ~/.fleet/bin/fleet-run main-checkout.mjs --record writes
    .fleet/main-checkout.sha: the main checkout's git status --porcelain -uall entries plus a content hash of every path they name. Your own
    uncommitted work is fine — it is in the baseline and holds nothing. From
    then on every fleet-tick.mjs compares the main checkout against it and
    holds dispatch on any change (#2210): the backstop for the stray member
    writes member-write-guard cannot see — an eval cell, a bash that
    cds or git -Cs into this checkout. .fleet/, .worktrees/ and
    .agent-brain/ are gitignored and never trip it. Nor does this run's own
    bookkeeping: docs/metrics/tier-outcomes.tsv and
    docs/metrics/member-outcomes.tsv, which you write mid-run, are left out
    by name — anything else under docs/metrics/ still trips it. The same
    command is the clear, and it overwrites; it never compares.
    On MAIN-CHECKOUT-DIRTY
    the maintainer resolves the stray paths FIRST — inspect, keep or remove
    them — and only then re-runs --record; re-recording over unresolved paths
    certifies them. Never re-record over MAIN-CHECKOUT-UNKNOWN: that
    walks the run from "could not look" to "certified clean" in one step, so
    --record itself refuses over a baseline it cannot read and on any git
    read that fails. main-checkout.mjs --check prints the same answer
    without a tick.

    Check the merge gate against its spec, once the fast-forward has put the
    ratified spec in front of you.
    Where the workspace checks a ruleset spec
    in, .github/scripts/apply-ruleset.sh --check compares it to the live gate
    without writing anything (no spec checked in → nothing to run, skip it).
    Three statuses and they are not interchangeable: 0 the gate is what the
    tree says it is; 3 it is not — including the gate having been deleted
    or renamed — and the diff it prints says how; 2 the question could not
    be answered at all: no spec, no gh, a listing or read that failed
    outright, or a read that succeeded but withheld bypass_actors because
    the session lacks repository admin.

    3 does not stop the run and is not yours to fix — reconciling is a
    write to the gate protecting this repo, so it needs the maintainer.
    Surface the diff and carry on. What it changes is what you may then claim:
    every statement this run makes about which checks must pass before a merge
    is sourced from the spec plus ADR 0007 and is true of the live gate only
    while this check is green, so a 3 means saying "the gate requires X"
    without naming the drift is a false claim, and a 2 means you never learned
    either way. Measured 2026-09-23 (#1710): four contexts ADR 0007 had
    selected were required by the spec and by nothing live, and the gap sat
    there unnoticed because the only thing that ever read the live gate was a
    person choosing to.

    Launch the cockpit. On every phase-0 pass, not just the first:
    node ~/.fleet/bin/fleet-run board.mjs serve --open & in the
    background. It is a read-only mirror of .fleet/ledger.md + gh — you never
    feed or update it, and it survives your own compaction. The launch is
    idempotent per workspace when the running cockpit answers the identity
    handshake: an already-served workspace's launch finds the running server
    over HTTP, prints its URL, and exits 0 without starting a second one
    (#1585) — nothing to skip. It opens nothing either: --open opens a tab
    only from the launch that binds a port and starts the server (#1714), so a
    re-shortlist leaves the board tab you already have alone. A second fleet
    on another workspace collides with none of that: its own launch derives
    that workspace's own port and binds it when free, or scans to the next
    free port in its range when the derived one is already held by something
    else — either way it gets its own board, just not always on the port its
    hash predicts.

    The spend panel reads this workspace's own ~/.omp/agent/sessions tree
    (#1716), and is pinned to whichever session first writes a transcript on this
    cockpit's own watch (#1583/#1679): it will not follow a workspace that
    already had another session's transcripts sitting there at launch. If the
    panel is reading someone else's numbers, relaunch with --spend-dir <path>
    naming this run's own session directory to override the heuristic outright —
    ~/.omp/agent/sessions/<encoded-cwd>/<ISO>_<uuid> (the directory, never
    the .jsonl file of the same name beside it).

    Rotate the ledger before the fold-in below and before shortlisting —
    every run, whatever way the previous one ended.
    One ledger per run:
    ~/.fleet/bin/fleet-run ledger.mjs rotate moves the previous run's
    .fleet/ledger.md byte-for-byte to .fleet/ledger.<UTC YYYY-MM-DDTHHMMSSZ>.md and leaves none behind, so this run's first write
    starts the empty skeleton. Nothing is carried forward — no row, exclusion,
    ## Filed or ## Ruled entry — so an unchecked impl- row a prior run
    left behind can no longer hold this run's tick. What an unclean end left in
    flight comes back through the ordinary per-run path: open PRs through the
    fold-in below, behind-pr:/behind-issue: exclusions through phase 1's
    collision and sequence checks, and a killed member's ticket through a new
    member under a new name. An exclusion that expires with the run that wrote
    it (deferred-to-session-end and its like) must be restated if it still
    holds. No ledger is exit 0, nothing to rotate. It refuses — exit 2,
    nothing moved — when the stamped archive name already exists, and while
    the heartbeat mark beside the ledger is still beating, naming the last
    beat and when it would count as stalled; a stalled mark, a recorded
    --stop, or no mark at all lets it rotate. The guard proves "not
    beating", never "dead":
    a previous controller that is alive but busy
    outside its heartbeat hold for longer than its recorded interval reads as
    stalled, and its ledger is rotated out from under it. Run the rotation
    before this run's own first tick or beat, either of which writes the mark
    the guard reads. Older archives, hand-named ones included, are left alone.

    Derive the Recipe — the Recipe derivation step — before the fold-in below
    and before the first claim.
    Every claim runs this repository's Install
    step and bakes its Test entrypoint into the member's runner, and every
    review reads the Test entrypoint, all out of the Recipe cache
    (.fleet/recipe.json beside the common git dir); no script infers either
    command. Check it first: ~/.fleet/bin/fleet-run derive-testcmd.sh . install and ~/.fleet/bin/fleet-run derive-testcmd.sh . test. Both exit
    0 → the cache is present and valid: use it as it stands and derive
    nothing.
    Either refuses → derive it. This step is yours: one derivation
    per run start at most, never one per member, and a member never derives.
    Dispatch a task member named derive-recipe, agent
    fleet-recipe-deriver, and wait for its report — nothing in this run can
    claim before it lands:

    Derive, prove and cache the Recipe of the repository at <repo>. Your
    scratch directory is <scratch>/derive-recipe/; write nowhere else.
    Follow your definition's procedure exactly, and end with its report line.

    <repo> is this main checkout's absolute path and <scratch> is resolved
    to an absolute path before you send it. The member reads the repository,
    proposes both commands and proves them with recipe-prove.mjs in a
    throwaway worktree of origin/main: the Install step must leave the tree
    clean, and the Test entrypoint must be shown to run real tests — the
    runner's own non-zero count, or a deliberate mutation of one test that
    turns the run red. Only a proof writes the cache, with derivedAt (the
    origin/main commit it ran against) and the proof itself. RECIPE PROVEN → re-run both derive-testcmd.sh reads; their output is the
    Recipe this run uses. RECIPE NOT PROVEN → stall, below.

    Mid-run, a cache that stops running is re-derived once, then the run
    stalls.
    A claim or a review that refuses because the Recipe cache is
    invalid — a command that no longer runs, or an Install step that changed
    the tree — is the one mid-run trigger: dispatch derive-recipe again
    exactly as above, then retry what refused. The Install step's refusals
    fire after the claim has labelled the ticket and created its worktree, so
    release that claim with release-ticket.sh first (Release the claims
    that never became PRs
    ). A red suite never triggers it: a failing test is
    a finding, not a stale Recipe. Stall: RECIPE NOT PROVEN from any
    derivation, or a second invalid-cache refusal after this run's one
    re-derivation, halts the run — no Pull, no claim, no review dispatch — and
    you report STALL — Recipe not proven: <cause> to the maintainer with the
    deriver's reason verbatim. A repository the fleet cannot run needs human
    hands, the ready-for-human cause: a toolchain this machine lacks, or a
    suite that runs no tests the proof can see. It is never a loop: no third
    derivation, and never a cache written by hand.

    Fold in every PR a prior run left open, before shortlisting. A chore PR
    carrying that run's own metrics, or ticket work whose review was deferred —
    both are reviewable work no member otherwise picks up, because the Shortlist
    holds ISSUES and nothing looks at inherited PRs. Queue each for review exactly as
    ticket work: same review workflow, same fix-applier, same finisher. Measured
    2026-09-03 — #1222 arrived this way and its review found a defect its own PR
    body called unreviewed, and #1237's review then caught two metrics rows the
    controller had missed in its own close-out, so a chore PR is not exempt from
    being wrong. Reviewing them next run is also what stops one pinning
    fleet-tick's backlog forever (#590): unreviewable AND uncounted is the state
    that strands it.

  2. Build the Shortlist — ~/.fleet/bin/fleet-run shortlist.mjs, then read
    .fleet/shortlist.json. The script runs the cheap filters and nothing else
    — no ticket body is read here, because a Pull reads one ticket in full at
    the moment it admits it (ADR 0013 §1):

    • Candidate scan — candidates.mjs --require-label ready-for-agent,
      oldest-first, never re-ranked. Its row count is scanned, the supply.
    • Dependency scan, next-ticket step 2, on each row's d array. Open
      blocker → drop.
    • Live Exclusions — a ticket whose ledger row reads excluded · behind-pr:#M or excluded · behind-issue:#M stays out while its premise
      holds (phase 1, Exclusion).
    • In-flight check — inflight.sh <N> per survivor. Exit 1 is taken, and
      any other non-zero exit is a check that could not answer, which is never
      free. Both drop the ticket, logged by number.

    It writes the survivors, in order, to .fleet/shortlist.json —
    {scanned, shortlist: [{n, t}]} — resolved against the git common dir
    exactly as ledger.mjs resolves .fleet/ledger.md, so you, the tick and a
    member in its own worktree all name one file. Exit 0 is the file written,
    an empty shortlist included
    — the empty queue is an answer. Exit 2 is a
    refusal
    : the scan, the ledger read or the write did not answer, and the
    previous file stands untouched, because an empty file written then would
    read as "no work". Never read 2 as empty.

    Every probe fails CLOSED: an unreadable blocker counts as open, an
    unreadable premise leaves its exclusion standing, an unanswered in-flight
    check counts as taken. A ticket dropped wrongly waits one refresh; a ticket
    admitted wrongly costs a Pull's full read at best and two members on one
    ticket at worst.

    --require-label ready-for-agent mandatory, no fallback — the script
    fixes it, and you never run candidates.mjs --allow-fallback by hand to
    widen the list: ready-for-human needs a human to brainstorm first and you
    have no channel to one mid-flight.

    candidates.mjs's exit 3 reaches you as an empty shortlist with its to-spec
    notice on stderr. Exit 3 is not that empty queue, and never a reason to
    widen the net.
    It says the labeled query returned rows and the to-spec
    filter took every one: what is queued is to-tickets' input rather than
    claimable tickets, and a member handed one implements an entire spec as a
    single ticket. Log it as
    specs awaiting to-tickets, not as no work — and still do not pass
    --allow-fallback, whose second pass is unfiltered and so reaches the
    ready-for-human and untriaged work you have no channel for.

  3. Refresh is the tick's from here on. fleet-tick.mjs runs
    shortlist.mjs itself when unclaimed entries fall below the implementer
    cap, when the file is missing or empty, and when an excluded premise has
    lifted, and prints REFRESHED shortlist: <n> entries; <k> lifted. Phase 0
    runs it by hand once because a dead run's shortlist is stale by definition.

No multi-select, no maintainer tick. Selection and ordering are batched —
one scan, oldest first; admission never is. The invariant this replaces — one
human decision per batch of admissions, zero unilateral grabs — is retired
(ADR 0013): zero human decisions per admission, and every non-admission is a
label or a premise the maintainer can read.

Phase 1 — Pull: judge, then claim or relabel

Serial, main checkout, one Pull at a time. Never parallel, never inside a member —
concurrent worktree add and label writes race.

One free implementer slot is one Pull. The tick names the heads — PULL #412 #415, one per free slot — and each Pull runs this sequence on its head,
terminating in exactly one dispatch or an empty shortlist (ADR 0013 Decision 2):

  1. Take the shortlist head — the next .fleet/shortlist.json entry with no
    impl- row and no excluded row whose premise still holds, which is the
    ticket the tick's PULL row names.

  2. In-flight check, again — ~/.fleet/bin/fleet-run inflight.sh <N>; any
    hit = taken, drop it and take the next entry. The shortlist is as old as its
    last refresh. It runs all three probes (PRs, remote heads, local
    worktrees + branches) so a partial one cannot read as free. The PR probe is
    two signals rather than a bare full-text match — GitHub's own closing-PR links
    plus branch-segment matching — and both drop MERGED and CLOSED, so a
    merged or abandoned PR the gh pr list window covers cannot hold a ticket
    forever. A linked PR that window does not cover has no state to read: it
    prints ? and still counts as taken, merged or not.

    Exit 2 is not free — the question went unanswered, so treat it as taken
    and say which ticket it was in the log. A probe that could not look is
    recorded rather than aborting the run, so exit 2 from one prints the same
    JSON as 0 and 1 plus an unknown[] naming the probes that could not look:
    read unknown, never taken alone, because exit 2 says "taken": false,
    which summarizes hits and is not a claim the ticket is free. A hit outranks
    an unknown — a payload can carry a non-empty hits[] and a non-empty
    unknown[] at exit 1, which is taken. Four causes print nothing at all — a
    bad argument, not inside a git repository, no such issue, a verdict that could
    not be written — so never assume exit 2 parses.

    A free verdict is not evidence that nobody is editing the ticket's
    target files.
    All three probes ask whether a branch-shaped claim
    exists — a PR, a remote head, a local branch, or a worktree — and
    nothing else; work done directly against the shared checkout, by a
    human or another agent session, stakes no branch-shaped claim and trips
    none of them.

  3. Read the ticket in full, once — gh issue view <N> --json title,body,comments,labels,createdAt
    written to <scratch>/impl-<N>/issue.json, then read off that file with jq -r '.title, .body, ([.labels[].name]|join(",")), (.comments[]|.author.login + ": " + .body)'.
    One read answers every question the Pull asks; step 7's router reads the same file. Record the ticket's real
    brief — the ## Agent Brief comment where one exists, otherwise the issue
    body, which is where most tickets actually carry it (a controller probe found only 43/100 open
    ready-for-agent issues have a separate Agent Brief comment; the rest,
    this ticket included, carry the brief in the body) — plus its Out of scope sequencing wherever that section is found. Then judge decided?
    — never size.

    Still live? A backlog ticket's claims rot. Before decided?, settle the
    ticket's central claim against origin/main — never the working tree, never
    the ticket's own line numbers, which drift. One git show origin/main:<file>
    piped to grep does it for any ticket citing a construct, which is most of
    them. That read is fatal, not empty, when the path is untracked in
    origin/main
    — git show origin/main:<path> exits 128 saying the path
    does not exist, and the file may still sit right there on disk, so it is a
    probe that could not look rather than a clean tree. Fall back to
    git ls-tree origin/main -- <path> (empty output at exit 0 = untracked)
    before reading the failure as "unfixed". Already fixed → close it citing the
    commit, do not claim it. Partly fixed → say which acceptance criteria the
    tree already meets, and which the tree now contradicts.

    Grep for the ticket's ASKED-FOR CHANGE, not only its subject construct —
    the construct check passes on every stale pin ticket, because the construct
    is what the pin is about and never went anywhere. So for a ticket asking
    for a pin, test or assertion, grep the test file for the assertion it
    wants; for one asking for exact wording, grep the old wording the ticket
    quotes as wrong, never the new wording it proposes. A proposed replacement is
    a suggestion the implementer is free to reword — an equivalent rewording
    satisfies the ticket while failing a grep for its literal text — and the
    quoted defect is the fact. Old string gone from git show origin/main:<file>
    ⇒ the fix landed, and
    git log -S '<old string>' --oneline origin/main -- <file> | head -1 names
    the commit that removed it; close citing that commit. Seconds either way,
    and the construct half alone is the probe that could not look.
    No ancestry check on what that walk printed. origin/main is where the
    walk starts, so every commit it can print is reachable from origin/main by
    construction, and git merge-base --is-ancestor <sha> origin/main is true
    for all of them — true again when the sha is origin/main. A step that
    cannot answer no is not evidence, and reads as evidence anyway.
    That check belongs one input over — a sha the TICKET quotes, where it
    can answer no: a rebase leaves the commit it orphaned a whole object, so
    git show resolves it happily while nothing in origin/main's history
    reaches it, and a "fixed in <sha>" premise built on one describes a tree
    that no longer exists. Separate its two non-zero answers before citing
    either — exit 1 is the answer not reachable, while exit 128 with Not a valid object name is a sha this clone does not have at all, a probe that
    could not look rather than an orphan (measured: #640's comment cites
    35c0845 on a since-deleted branch, and 128 is what it exits here).

    Both halves of that command are load-bearing. Drop the origin/main
    argument and git log -S searches HEAD, contradicting the rule a paragraph
    up — and the two searches fail differently there. On a checkout behind the
    fix the prescribed old-string search names a wrong commit at exit 0,
    the old string still being present in that tree: measured at #206's
    0dc39ef^, | head -1 names the rename 4fd2f73, not the fix. It is the
    new-string search the paragraph above forbids that prints nothing at exit
    0 there, indistinguishable from "the change never landed"; the old-string
    search goes empty on a checkout that predates the old string's own arrival
    at that path — behind that rename in #206's case, and with no rename in play
    at all in this file's: measured at 647ae44^, which already tracks this
    path under this name, git log -S 'ls-tree origin/main' prints nothing at
    exit 0.
    Add --reverse and you get the oldest count-changing commit, which is the
    file's last rename whenever the string predates one — measured on #206's old
    wording, --reverse names 4fd2f73 ("move next-ticket and sizing-a-ticket
    into the plugin"), a refactor that clears an ancestry gate exactly as
    well as the real fix, while newest-first | head -1 names 0dc39ef, the
    commit that actually did it.
    (--follow fixes the rename half but is mutually destructive with
    --reverse: the two together return empty at exit 0, and --follow takes
    exactly one pathspec or exits 128.) So read the subject of whatever it names
    before citing it in the close, and treat empty output as an answer you did
    not get rather than a commit.

    Measured, one run: #132's one-line remedy had shipped in e7b11e6 ten days
    earlier and #134's AC-2 asked for an exit code a later design (#64)
    deliberately changed — neither commit carried a trailer back, so the tracker
    showed both as open work. #132 cost a full claim, worktree and implementer
    dispatch to learn this; #134 would have shipped a test asserting the opposite
    of the shipped behaviour. The two probes that settled both took seconds.
    A ticket deferred from a review is a claim about a tree that has since
    moved
    — and the older the ticket, the more it has moved, so this bites
    hardest at exactly the head of an oldest-first queue.

    Measured again 2026-08-19: four stale in the 20 oldest, and the construct
    probe caught none of the four. It missed #156 — the two pins and the
    contentWords() extraction it asks for had all landed (d016414, 1abd6c8)
    while overlap(), the construct, sat right where the ticket said, so the
    probe passed the ticket through as live work and it cost a claim, a worktree
    and a dispatch. It missed #205, whose d: scan candidates.test.mjs
    already pinned — 6d94451 and 43d6ac5 both added .d assertions, and the
    ticket's own closing comment cites both. It missed #206, whose wording
    defect 0dc39ef had already fixed — by an equivalent rewording, not by
    the literal string the ticket proposed, which appears nowhere in
    origin/main; the ticket's line number had drifted too, :95 → :98.
    And #187 it could not read at all: that ticket's file is untracked in
    origin/main, the fatal-not-empty case above.

    staleness.mjs runs that check for you, and answers in THREE values.
    ~/.fleet/bin/fleet-run staleness.mjs --path <path> --gone '<string>', or --present '<string>', once per claim the ticket makes.
    It goes here and not in shortlist.mjs because choosing WHICH string
    settles a ticket needs the ticket read, which the step above already did —
    so the cost lands on the pulled ticket and never on the backlog.
    --gone is a defect the fix must remove, the wording the ticket quotes as
    wrong; --present is what the fix must add, the assertion a pin ticket asks
    for. Give exactly one — the direction is not inferable from the string, and
    the wrong one answers the opposite verdict with full confidence.

    A needle that itself starts with -- — #240's --label ready-for-agent
    is one this backlog carries — needs the end-of-options separator: --gone -- '<string>'/--present -- '<string>'. Given bare, with no --
    immediately before it, the value guard still refuses it — staleness: --gone needs a value — at exit 2, a could-not-check to admit and annotate,
    not an invocation to retry.

    Exit Verdict What it does at the Pull
    0 still reproduces admit it
    1 provably fixed do not admit; close citing the payload's commit and subject
    2 could not check admit it, and say the probe could not check

    The third value is the one that has to survive. A probe that could not look
    answers exactly like a probe that looked and found nothing, so folding it
    into either neighbour retires live supply on a guess in one direction and
    reports a defect as live on a spelling the tree stopped using in the other.
    Exit 2 is also where every guard and every failed git call in that script
    lands, so a probe that breaks keeps the ticket in the queue.

    What it refuses to answer is the point of it. It reads origin/main and
    nothing else — never the working tree, never anything on disk — so a path
    this repo does not track is exit 2 rather than a clean read: that is #187's
    case and the generated-artifact case together, agent-test being written
    into a claimed worktree by claim-ticket.sh's heredoc, which makes every
    copy under .worktrees/ a snapshot of whenever that worktree was claimed.
    fixed is never read off the string's presence alone either: it carries the
    commit git log -S names for that string at that path, reachable from
    origin/main by construction since that is where the walk starts. A
    --gone string absent from the file that ALSO never changed count there is
    exit 2, not a fix — that is the positive control, and without it a typo in
    the string closes a live ticket.

    It does not run a reproduction. The general "is this still reproducible?"
    oracle over the whole backlog is what #238 ruled out, and a reproduction run
    without a positive and a negative control misattributes causes rather than
    merely missing defects — the #230 check needed a purpose-built fixture plus
    both. A ticket no single string settles is a could not check: admitted,
    annotated in the dispatch prompt (phase 2), never quietly dropped.

    Decided? Would two competent implementers, reading only this ticket, build
    materially different things? "Material" by inventory, not feel:

    Left open Verdict
    architecture, API shape, schema, UX undecided
    new dependency, new seam undecided
    naming, file layout, ordering, test arrangement decided — ignore

    Any undecided item → not decided. Name it; that name is the relabel comment's
    missing decision or open fork. Torn → surface, never guess: relabel it
    ready-for-human as a fork naming the open decision — the label is how a
    Pull surfaces it to the maintainer. Opposite of
    sizing-a-ticket's torn → take the heavier row, deliberately — see that skill.

    This is a consumption gate, priced for the seat where implementer
    divergence costs a claim, a worktree and a dispatch; the filing-time
    counterpart was repriced to is the defect confirmed and states its own bar
    inline — see docs/adr/0001-filing-label-bar-is-defect-confirmed.md.

    No sizing agent here. sizing-a-ticket picks the process path, and that is
    phase 2's call, after the ticket is claimed.

  4. Collision scan against open PRs and live branches — the ticket is an
    un-implemented issue with no diff, so infer its target files from the brief
    (the paths it names) and compare them against each open PR's gh pr diff <PR> --name-only and each live worktree's git diff --name-only origin/main...<branch> (the branches git worktree list names) — a member
    mid-work has a branch before it has a PR. pr-overlap.mjs is PR-vs-PR
    only
    — it runs gh pr diff on both args and errors on an issue number,
    so use it the way the merge bot does (PR vs PR), never on a ticket here.
    Step 2 catches a ticket already taken, not one that edits a file an open PR
    edits
    . Overlap → exclude it behind-pr:#M, next entry. The tracker cannot
    express this, and it is what actually stalls a Pull's PR at merge.

  5. Sequence check — the brief's Out of scope names an open ticket this one
    sequences after → exclude it behind-issue:#M, next entry. That lives in the
    brief, is invisible to the dependency scan, and oldest-first order puts a
    chain's members next to each other, where two consecutive numbers read as
    two independent tickets.

  6. Claim — ~/.fleet/bin/fleet-run claim-ticket.sh <N> impl-<N> implementer --apply (below). Exit 2 → treat it as taken, next entry.

  7. Route, row, then dispatch — ~/.fleet/bin/fleet-run ticket-router.mjs route --session <session> --ticket <N> --arm <A|B> --impl-row <k>[ --chain-head] --issue <scratch>/impl-<N>/issue.json --guard .fleet/cost-guard.json prints
    POLICY=… CELL=… DRAW=… STRATUM=… REASON=… (phase 2's exploration rule
    says what each argument is); then ledger.mjs row <N> "impl-<N>[ · tier=<CELL>][ · route=<REASON>]",
    tier=<CELL> exactly when DRAW is not -; then phase 2 at once: claim
    what you are about to dispatch; dispatch what you have just claimed. Router
    exit 2 → no row and no dispatch: release the claim (release-ticket.sh,
    below) and emit an event, a probe that could not look — a table the router
    refuses refuses every Pull.

The Pull table — every verdict a Pull can reach, and the one thing each
does (ADR 0013 Decision 2 and 4):

At the Pull Action
in-flight hit, or an in-flight check that could not answer drop, next entry
no longer labelled ready-for-agent drop, next entry
staleness.mjs exit 1 — provably fixed close citing the commit, next entry
brief names no what relabel needs-triage, next entry
genuine fork — options named, none ruled relabel ready-for-human, next entry
needs human hands — external access, manual testing, judgment during the work relabel ready-for-human, next entry
target files collide with an open PR or a live branch exclude behind-pr:#M, next entry
Out of scope sequences it after an open ticket exclude behind-issue:#M, next entry
claim-ticket.sh exit 2 taken, next entry
decided, live, free, claimed row, then dispatch (phase 2)

The shortlist runs out → run shortlist.mjs once and Pull again; still
empty → hold the slot idle until the next refresh trigger.
Within a turn,
Pull from the current list first and refresh second — a refresh never delays a
dispatch.

A Pull never re-admits a ticket already relabelled. The shortlist file can
be older than a relabel — this run's own Pulls, or a demotion in phase 3 — so
read the ticket's labels in step 3's full read (--json title,body,comments,labels) and drop it when ready-for-agent is gone. A Pull
that trusted a list built before the relabel landed would hand a ticket the
fleet has already judged unimplementable straight to the next member.

Relabel by cause. An unclaimed ticket is yours to reclassify at the Pull —
three causes, two labels: ready-for-human for hands or a fork;
needs-triage for a brief that names no what.
gh issue edit <N> --remove-label ready-for-agent --add-label <label> (no
in-progress to drop — it was never claimed), preceded by exactly one comment:

  • needs-triage:

    Relabelled by the fleet controller. Pulled for implementation and
    returned: the brief does not decide what to build. Missing decision: . Returning to /triage; ready-for-agent restored once decided.

  • ready-for-human (fork):

    Relabelled by the fleet controller. Pulled for implementation and
    returned: the brief leaves a decision open. Open fork: <options, verbatim
    from the brief>.

  • ready-for-human (hands):

    Relabelled by the fleet controller. Pulled for implementation and
    returned: needs human hands — <external access | manual testing |
    judgment during the work>: .

Neither label invokes anything: /triage is maintainer-run, and both labels
only stop the ticket costing a read per scan. These comments are the only
writes the fleet makes to a ticket it did not claim.

Exclusion is a ticket row with a premise. ledger.mjs row <N> "excluded · behind-pr:#M" or ledger.mjs row <N> "excluded · behind-issue:#M" — #M is
the open PR, or the branch name until its PR exists, or the issue this ticket
sequences after. No new ledger section. shortlist.mjs probes every premise at
each refresh — MERGED or CLOSED lifts a behind-pr, CLOSED lifts a
behind-issue — and a lifted ticket re-enters the shortlist in its own
oldest-first slot; its row is rewritten to impl-<N> … when it is pulled,
because row replaces the whole line.

Claiming. ~/.fleet/bin/fleet-run claim-ticket.sh <N> impl-<N> implementer --apply does
the label, worktree, branch, the Recipe's Install step, the clean-tree assertion, and the
isolation runner in one serial pass. The two arguments are always that literal
pair — impl-<N> as <slug>, implementer as <type> — so the claim is
branch implementer/<N>-impl-<N>, worktree .worktrees/<N>-impl-<N>. Never
read them off git branch -r or git worktree list: human branches on origin
(fix/…, docs/…) have another shape, and the script takes both arguments as
free text, so a pair copied from one claims under that shape at exit 0.

The Install step comes from the Recipe cache — never pass or infer one. The
claim reads it, and the Test entrypoint, through derive-testcmd.sh, runs the
Install step in the fresh worktree and refuses if it left any file changed: an
install that rewrites the tree in a throwaway worktree corrupts it for everyone.
No cache is phase 0's Recipe derivation step not having run; an invalid one is
its re-derive-once rule.

Materialize the isolation envelope as a file, not a briefing. The runner is
tracked at the repo root since #55, so every checkout has one; it derives its
ports from the worktree's own <N>-slug directory name (so collisions stay
impossible) and materializes the current runner from the one emitter rather than
carrying a copy of it. Brief members with ./agent-test <file-or-dir> and nothing else — anyone who finds the worktree finds the
runner, including grandchildren you never dispatched. A directory works too and
expands to the test files under it; one holding none refuses rather than passing
vacuously, so a mistyped file or directory path cannot come back green. See
references/isolation.md.

A reused worktree may lack the runner — no longer here, and still can
elsewhere. claim-ticket.sh used to write agent-test only when it claimed a
fresh worktree, so a worktree carried over from a prior run, or an already-open
PR's worktree you sent a rebaser/resolver into, had no ./agent-test and the
member stalled on a missing script (observed with a rebase-resolver in a
prior-run worktree). #55 tracked the runner, so any worktree checked out from
origin/main now carries it. A tree that is NOT a checkout — a git archive
snapshot, a cp -R subset — still has whatever was copied into it, and a repo
that tracks no runner never had one: there, tell the member the runner is absent
and to run docker-free suites directly (npx vitest run --config vitest.ci.config.ts <file> — the CI unit config has no globalSetup, so there
is no stack to collide on).

A reused worktree may also be on the wrong COMMIT. git worktree add <path> <branch> checks out the existing LOCAL branch and never consults the remote, so
a branch a previous run left behind wins over the rebased origin/ ref that is
actually the PR. Measured in a scratch repo (git 2.50.1): with the branch rebased
on the remote and a stale local ref standing, git worktree add <path> <branch>
checked out the stale commit and printed HEAD is now at <stale-sha> <subject> —
the same subject the PR head carries, because a rebase preserves it. Neither
commit was an ancestor of the other, so behind is the wrong model; it is a
fork. Every other signal reads normal too: git worktree list shows the worktree
present on the expected branch, and the tree is clean. claim-ticket.sh never
touches this path — it claims only fresh tickets, and builds those from
origin/main.

The two reuse failures are not symmetric. A missing runner fails LOUDLY: the
member stalls on a script that is not there. A stale base fails SILENTLY and
produces confident wrong output — a review cut its immutable snapshot at the
stale commit and a fix-applier was dispatched into it, halted only before a push
that would have been a non-fast-forward.

So, when you re-create a worktree for an already-open PR:

  • Create it detached from the remote ref, never from the bare branch name:
    git worktree add --detach <path> origin/<branch>. Same scratch repo, same
    moment: this landed on the remote's commit. It removes the mechanism rather
    than catching it, and leaves no local ref for the next run to inherit.
  • Then verify against the branch REF, and refuse on mismatch. Compare
    git -C <path> rev-parse HEAD against
    git ls-remote origin refs/heads/<branch> | cut -f1 — the ref itself, which
    is what the PR's head actually is, and never git rev-parse origin/<branch>, which is the ref the worktree was just cut from and so
    agrees with it by construction. Unequal → dispatch nothing into that
    worktree, and report it naming BOTH commits, since their subjects will not
    tell them apart. An empty read is neither equal nor a mismatch: the read
    failed, or that head branch is not on origin at all (a fork PR, where the
    creation form above cannot work either), so report the ref as unreadable
    rather than the worktree as wrong — the same hole [ -n "$pre" ] closes on
    the way into run-merge-bot.md's step-1 poll.
  • Read gh pr view <N> --json headRefOid -q .headRefOid here too, and
    record it — it is NOT the gate.
    A headRefOid that disagrees with the ref
    is the PR-object desync run-merge-bot.md's step 1 says needs a controller,
    so it is a fact to carry into that decision, never the operand this refusal
    turns on.

Why the ref and not headRefOid: during the PR object's lag both operands go
stale in the SAME direction, so that compare fails OPEN as readily as it fails
closed.
The PR object spends the lag sitting on the PRE-rebase sha — cleared
on re-poll attempt 24 (~2 min) in one merge pass and attempt 14 (~84s) in the next,
both after gh pr update-branch --rebase returned rc=0, measured in
run-merge-bot.md's step 1 — and that is the very commit a clone which has not
fetched since the rebase still has origin/<branch> pointing at. So the false
REFUSAL (worktree current, PR object behind) is only half of it: cut the
worktree from the stale remote-tracking ref and both sides read the superseded
sha, the check reads EQUAL, and the dispatch lands on exactly the commit this
paragraph exists to catch. Measured in a scratch repo (git 2.50.1): with the
branch rebased onto an advanced main, force-pushed, and the clone not fetched
since, git worktree add --detach <path> origin/<branch> landed on the
pre-rebase c774756 and printed its subject unchanged by the rebase, while
git ls-remote origin refs/heads/<branch> read the true tip 62ba798 from
that same unfetched clone. Re-reading headRefOid on a settle window does
not reach that half at all
— it fires only where the two disagree — which is
why the operand changes here rather than the verdict gaining a window.

A mismatch is usually one git fetch origin old, so git worktree remove --force <path> first, then fetch and re-create once before reporting it.
git worktree add --detach <path> origin/<branch> resolves the LOCAL
remote-tracking ref (measured above), so the ordinary cause is this clone's
own staleness, and re-creating after a fetch costs seconds where the refusal
costs a dispatch. Bounded at one retry: still unequal after the fetch →
refuse and report, because the second reading is a push that landed while
you worked, or a ref this clone cannot resolve, and neither gets better with
more waiting.

review-core.mjs refuses a snapshot whose head is not the branch ref's, so the
review path is backstopped — except when the operand comes back
empty, which skips the compare rather than refusing on it. A same-repo PR
still reads refs/heads/<branch> first and falls back to
refs/pull/<number>/head only when that comes back empty; a cross-repo PR (a
fork) skips the branch-ref read entirely and goes straight to the pull ref,
because a fork's branch NAME can collide with an unrelated branch the base
repo already carries under that name (#1616) — so a fork PR now gets this
operand populated in the ordinary case, and the skip is rare rather than the
norm for forks. Its run log names the skip: head ref (absent): head check SKIPPED. A worktree you hand to an agent directly is not backstopped at all.
Verify here anyway. That backstop now compares against the same ref this
bullet does
— #1513 moved its operand off headRefOid for the reason
above — so it is a second
chance to notice a stale snapshot on the one path it covers, never a reason
to skip the ref compare here.

Phase 2 — dispatch the Pull's implementer

A Pull is one claim followed by one dispatch of a new member under a name no
member has held; nothing holds a queue.
The implementer cap is the fleet's
own accounting rather than anything the runtime enforces, and the member is
fresh and never a wake of one that already ran. Admission is per slot and
never batched, so there is nothing to hand a freed slot its next ticket but
the next Pull.

a Pull dispatches its member with one more task call — agent the definition ledger.mjs dispatch printed for it (below); name impl-<N>; in the background, so its report arrives on its own.

No workpool, and no kernel-resident handle. A pool's only refill virtue —
handing a queued item to a freed worker without a controller turn — is exactly
what a Pull forbids, and its completions never arrive per member. eval's
agent() is not the omp call either: a kernel-resident handle dies with the
kernel and needs a wait, while a task job is process-level and its report
auto-delivers on its own, like every other member's.

No per-call tier or effort anywhere on this path: tier stays the agent
definition's own frontmatter (ADR 0005), whichever fleet-implementer-<cell>
definition the Pull names, and a member's Resolved tier stays readable from its transcript.

Claim what you are about to dispatch; dispatch what you have just claimed.
Phase 1 makes the claim, serially in the main checkout — its rule is measured —
and the dispatch follows it at once. A claim that runs ahead of its dispatch is
a worktree, a branch and an in-progress label with no member behind them,
released by the end-of-run sweep rather than by a member that did the work.

Record the dispatch before you make it. ledger.mjs row wrote the row at
the Pull's step 7; ~/.fleet/bin/fleet-run ledger.mjs dispatch <N> impl-<N>
then marks impl-<N> live on it and in ## Dispatched, and the call follows.
Its JSON carries agent, the definition that call names — read it off that
output, never recall it. Live implementers are the impl- tokens with no
=<outcome> — the tick reads that count off the ledger, and nobody states
it. dispatch refuses once the
run is draining: drain stops supply. It also refuses a row whose tier=
names no single definition — two different values, or one that is not a
definition suffix — or names one with no agents/<definition>.agent.md,
before the member is live; fix the row with row first.

The prompt carries only what varies. Everything shared — the
unattended-member block, the edit/read absolute-path rule (#1727), the
issue re-read and re-derive backstop, commit incrementally, no git stash, the
shared eval kernel (#1447), enumerate-and-declare, sizing and the PR steps —
is the body of every agents/fleet-implementer-<cell>.agent.md, byte-identical
across all of them, and each harness injects it as the
member's system prompt. Never re-paste it. The prompt fills that body's
placeholders — the ticket number, the worktree abs path and branch
(<abs-path>, <branch>), the scratch path <scratch>/impl-<N>/, and the
distilled brief (<distilled brief>): the title plus the ## Agent Brief
comment or the issue body, whichever carries the brief, and its Out of scope, pasted verbatim from the Pull's own read. Beyond those it carries only
the lines Rules that fail silently above say every dispatch prompt
carries — the report is a message, reports are not acknowledged, the suite
runs in the foreground — and a staleness.mjs could not check verdict with its
reason when the Pull got one, so the member's own re-derive step knows the
controller's probe could not look.

The prompt pastes the Pull's own read of the ticket rather than telling the
member to re-fetch it, and this still costs one gh issue view per ticket,
never one per member.
The fresh-context design's isolation rationale — "every
implementer re-reads its Agent Brief... from scratch"
(docs/specs/2026-07-22-run-team-agent-fleet-design.md:178-179) — is about a member
never inheriting another ticket's context, not about where the bytes come from.
Pasting the Pull's own read into the prompt keeps that: the member still gets the
brief verbatim and re-derives nothing from a sibling. What it drops is the
unconditional re-fetch of an issue the controller already has open, on every
dispatch — the fetch the agent body still carries is the backstop for a brief
that turns out to be insufficient, not the default path.

A member settling with nothing is not, by itself, a bail. Each member's
outcome arrives as its own job status. Settle (CONTEXT.md § Coordination) is
three states on omp — completed, failed, cancelled — and reading
failed/cancelled the same as a completed turn that had nothing to report
is the exact collapse this line blocks: a member that died never chose to bail,
so demoting its ticket on its silence demotes a ticket its member never got the
chance to judge. Confirm death first, exactly as the Member-killed row does
(Failure handling, below) — its mtime discriminator, never silence alone.
Only an explicit bail report — the member itself naming the cause — reaches
Implementer bails before implementing
(phase 3, below); a died or
merely-quiet member's silence never does. A confirmed-dead member recovers
exactly as the Member-killed row says — new member, new name, the SAME ticket —
never a demotion.

Dispatch every implementer with the agent that ledger.mjs dispatch
printed, and still omit model on the task call.
The
tier lives in that definition's frontmatter (agents/fleet-implementer-<cell>.agent.md;
slow-high, the policy cell, for a row with no tier=), which is what an
omitted model takes first — the session's tier applies only when the definition
names none, and a member dispatched with model set does not get the declared
tier back. Omitting model is therefore still the mechanism; what changed is
that the tier it resolves to is now declared and pinned rather than inherited by
accident. Keep name: impl-<N>: the name is what makes a member, and both the
spend classifier and member-outcomes.mjs read it.

After every Pull's dispatch, run the tier check — a scripted step, never a
prose reminder.
~/.fleet/bin/fleet-run tier-check.mjs --batch <path-to-batch.json>
judges the implementer just dispatched on the ledger's terms. The definition it
should have run under comes off its ticket row's tier= token: none means the
policy cell, fleet-implementer-slow-high; tier=<cell> means
fleet-implementer-<cell> — the same mapping ledger.mjs dispatch prints its
agent from, so a call that names the printed agent passes this half. It
fails the member two ways, one line each —
member: dispatched <agent>, expected <definition> when its transcript says
it was dispatched as anything else (a generic task dispatch fails even when
its model happens to match), and member: declared <m>/<l> resolved <m>/<l>
when the harness resolved a different model or level than that definition
declares. The verdict lands on the ledger either way, written by the check
itself: exit 0 appends tier-ok=impl-<N>:<definition> to the row, once
however often it runs; exit 1 runs ledger.mjs settle impl-<N> tier-mismatch
— or, for a member already settled another way, which settle will not
re-settle, appends tier-mismatch=impl-<N>:<definition> instead. Exit 3 is
the third verdict, tier-unverifiable=impl-<N>:no-transcript, appended once
for a member settled killed or released anywhere on the ledger — a row
or ## Dispatched — whose session names an existing directory holding no
<member>.jsonl: a dispatch that failed before a transcript was written. It
says nothing about the tier and only clears the unchecked hold. Exit 2
writes nothing — a usage error, which includes a LIVE member with no
transcript; a member settled PR#M, bailed or tier-mismatch with none,
which ran, so the session is the wrong directory — pass the one that
dispatched it; a session directory that cannot be searched; and a session
that is omitted, empty, or not an existing directory, live member or settled.
A batch holding both a mismatch and an unverifiable member exits 1.

The tick holds the next Pull until that verdict exists. Dispatching the
next member on top of an unchecked or mismatched dispatch multiplies whatever
silently degraded. The tick prints HOLD (tier unchecked impl-<N>) for the
newest impl- member of a ticket whose row carries none of the three
verdicts, whether that member is still running or already settled bailed,
killed or released, and HOLD (tier mismatch impl-<N>) for a mismatched
one. Only the newest member counts, so a replacement (impl-<N>-b) is what
clears a mismatch — and is then owed its own check. A mismatch on a ticket
whose issue is already CLOSED holds nothing: the tick probes that issue and
drops it, since there is nothing left to replace. The tick never reads a
transcript: clear an unchecked hold by running the check on that member with
session. A settled member's check works only when its transcript exists;
without one, the verdict is tier-unverifiable for a member settled killed
or released, and exit 2 naming the wrong session for any other. Clear a
mismatch by fixing the definition or the dispatch and dispatching the
replacement at the right tier.

Run the check right after the dispatch, and never reinstall the plugin or
change modelRoles while any member is unchecked.
The check reads the
installed definition and the live modelRoles at check time, not at
dispatch time, so a late check judges a member against a tier it was never
dispatched under.

The batch file is a JSON array, one entry per dispatched member — under
Pull, a batch of one, the member just dispatched: {member, session}.
member is the AgentId the dispatch returns (impl-<N>). session is the
SAME session directory member-outcomes.mjs/board.mjs are already handed
for this run; the check reads the member's own <session>/<member>.jsonl
directly, so a member with no assistant turn yet still resolves off its
dispatch-time record. A caller already holding the exact transcript path may
give transcript instead. Either one is required: the agent type an
implementer was dispatched as lives only in its transcript. resolvedModel
and resolvedThinkingLevel together — the dispatch's own job record —
may ride beside it and are preferred for the model and level. An impl-
entry carries no agentFile: its definition is the row's, never the
caller's. transcript/session resolve relative to --repo (defaults to
the plugin's own root), and the check reads and writes the run's own ledger
unless --ledger names another.

The declaration is model: "@<role>:<level>" — a fleet tier route, never
a vendor id.
@slow, @task, @smol resolve through the operator's
modelRoles, so an install with no Anthropic model still runs the fleet
at whatever each role points at; the explicit :<level> suffix wins over
the role's baked one, and the tier check judges a member against the
role's own target (modelRoles.<role>), never a model family (ADR 0011,
ADR 0014).

Before the run's first dispatch run ~/.fleet/bin/fleet-run tier-roles.mjs --check; exit 1 names every definition whose model: is not a route,
every unset modelRoles.<role> a definition needs, every definition whose
:<level> its role's target does not run at (omp's model catalog — omp
would clamp it silently), every
task.agentModelOverrides entry that would shadow a fleet definition, and
an --agents directory holding no *.agent.md definition,
prints the exact remedy, and stops the run before any member is
dispatched — the fleet reads that config and never writes it (ADR 0003).

Every 5th Pull by ledger count goes at an exploration cell. Count the impl-
rows in .fleet/ledger.md at Pull time; the Pull that creates row 5, 10, 15 …
is an Exploration Pull, and the router draws its cell: k = 1 + (sha256("<session>\t<ticket>")[0:8] mod K) over router-table.json's
cells minus the policy cell, in token order. While the table's burn_in is
true every Pull draws, over every cell, the policy cell included. An
Exploration Pull records tier=<cell> in the row, and a replacement inherits the row's tier —
written at the Pull's step 7, ahead of ledger.mjs dispatch, which reads it
and prints fleet-implementer-<cell> as the agent to dispatch; a row with
no tier= runs the policy cell. The
assignment rolls to the next Pull when another open ticket sequences after
it — a ticket the rest of the run
depends on; class is not read. Do not tell the member it is a control: a member that
knows it is being measured is not measuring the same thing.

The router's arguments, and nothing past its line. --impl-row is the
row count the Pull creates — or, while an assignment is rolling (the latest
row 5k and every row after it carry no tier=), that row's number;
--chain-head marks a ticket another open ticket sequences after, which is
what rolls it. --arm is A for an even ticket number and B for an odd
one; a B Pull routes exactly as an A one until the table opens arm B.
--session is this run's own session directory. The printed line is the
whole routing decision — never re-derive CELL from the table, the guard or
this paragraph. A missing or unreadable .fleet/cost-guard.json routes the
default cell only (REASON=guard-missing), burn-in included, and REASON
other than ok goes on the row as route=<REASON>, nowhere else.

Count the rate against the ledger, because a Pull is the dispatch unit. One
Pull is one member, so a rate counted per Pull is a rate counted per member —
one implementer in five, however the Pulls fall across the run — and the
ledger, not your memory, is what still holds the count after a compaction.

Do not label it anywhere — the dispatch record already carries it. The
pairing is a query over docs/metrics/member-outcomes.tsv (the exact awk sits
in that file's header): a session that ran BOTH pre-cutover implementer
definitions at DIFFERENT models — one member whose subagent_type is
fleet-implementer-alt, another whose is fleet-implementer. Neither
definition is dispatched any more, so the query counts pre-cutover history
only: a session whose implementers ran as fleet-implementer-<cell> is not in
it until #2133 replaces it with the per-cell readout. That column is
the record of what each member was dispatched AS, scraped like every other
column, so it survives the file's regeneration and mislabels no historical row
— a pre-rule session carries no such dispatch to find. A hand-set column would
fail both of those tests, and one derived against today's declared tiers would
mislabel every historical row.

The query counts DELIBERATE pairs, and that is the whole of #1066. The
rule this replaces asked only for a session+role carrying more than one
distinct model, which any session that happened to stage a routine ticket
beside a correction one satisfies. Measured 2026-09-12 on one corpus: 198
keys as that query is literally written, 35 sessions across 21 run_dates
once restricted to implementers, against 17 real pairs across 8 — so the gate
below read itself past its floor on sessions where nothing had been
controlled. Dispatch alone is not enough either: both arms must actually have
RUN different models, because a deliberate alt dispatch whose two arms resolve
to the same model (a modelRoles override, measured on omp 2026-09-12)
controls nothing.

Why one in five within the run, not a week of one tier then a week of the other:
tier would then be confounded with calendar date and therefore with prompt
evolution, which is exactly the state #864 documents and the reason the rows
already on disk cannot answer the question they were collected for.

The raw split still favours the top tier — 3 failures in 11 routine/sonnet
rows against 3 in 55 routine/opus. Do not read that as a tier result.
Tier remains largely confounded with calendar date and therefore with prompt
evolution: 8 of the 11 sonnet rows fall on 2026-08-13 to 08-17, before the
window in which 54 of the 55 opus rows were run, and the dispatch prompts
gained rules throughout. The confound is weakened, not resolved. Three
sonnet rows now break it rather than one — #714 (2026-08-20, a
maintainer-authorized deliberate control) and #750 and #751
(2026-08-21) — all three run against prompts of the same vintage as the opus
rows, and all three passed, which is the direction opposite the raw split.
Three is still not a result. What replaces this argument going forward is the
within-run pairing above
: one implementer in five at the alternate tier makes
tier orthogonal to date by construction, so the question stops depending on
whichever rows history happened to leave. Orthogonal to date, and to nothing
else
— the alternate member is whichever ticket the 5th Pull draws, rolling
past every ticket another open one sequences after, while
the top tier absorbs all of those, so the pairing trades the calendar confound
for a difficulty one that runs in a known direction. That is why the four covariates
exist: condition a pair comparison on sizing/profile/loc/files before
reading it as a tier result, never on the raw split.

minted_false_claim discriminates and no longer reads "always yes" — 20 of
the 66 routine rows carry no, 18 at opus and 2 at sonnet (#714, #750).
Read it as a property of the TICKET before the tier: a pure code simplification
need not add prose, while a correction ticket adds prose by construction. The
honest summary is unchanged and is the reason this paragraph exists: the guard
fired on the criterion the maintainer chose in advance, not on a demonstration
that the cheaper tier is worse.

row replaces the whole line, it does not append. Re-dispatching over a row
that already carries KILLED, ports= or → PR# must repeat those tokens or
they are gone, with only rewrote row #N on stderr to say so.

ledger.mjs read is safe on a pipe as well as a redirect: its payload used to
be abandoned at the pipe buffer and still exit 0, which is what left board.mjs
reporting ledger read parse failed and serving a blind cockpit for a whole run
(#246). Every use above is a recovery path, so a payload that arrives short
lands exactly where a settled ruled: is unrecoverable — which is why the
script's own suite pins that read, row, filed and ruled each reach a
pipe whole.

check reaches a pipe whole at both of its exits too. Its ALREADY FILED exit
sits mid-branch, where falling through would run the near-miss ranking and the
tracker search that exit exists to skip — so check's branch is a function now,
where process.exitCode carries the code and a return is what skips them
(#808). No payload this script prints is abandoned to process.exit() any more,
and the suite drives both of check's arms through a pipe on a payload the
ledger sizes rather than argv. So trust that payload — and read verdict,
because a searched-and-clean tracker and one that was never read share exit 0.

The consumer side raised its cliff rather than removing it. board.mjs reads
ledger.mjs read with an explicit maxBuffer, so a payload past node's
default is carried rather than killed mid-flight and reported as an
unreachable tool — which is how the blind cockpit came back at HTTP 200 even
after the pipe cut was gone (#807). That cap is deliberately bounded, so a
payload past it lands in the same blind cockpit — and so does a ledger that
cannot be read at all, at any size. The read fails open to an empty ledger, and
board.json's ledgerState field now distinguishes a read that failed or
didn't parse from one that succeeded (unread/unparsed/read, #816) —
spend in the same payload carries its own error the same way (#874). Zeroes
on the cockpit are not yet proof the pipeline is idle.

Guard: accumulate per PR, never conclude inside one run. The unit is the PR —
supply is one Pull per free slot, so there are no implementer batches. Append one row to
docs/metrics/tier-outcomes.tsv when you rule each PR's review, with
~/.fleet/bin/fleet-run tier-outcomes.mjs append <pr> --closed-own-ticket yes|no --minted-false-claim yes|no --note "<text>",
never by hand
(that file's header carries the column meanings). The script
fills run_date, pr, ticket and tier itself — tier from what actually
ran, off the implementer's tier-ok= ledger verdict or else its one
member-outcomes row (a tier-unverifiable= member has no ledger verdict, so
its one member-outcomes row decides), and BLANK with a WARNING naming why
when neither settles
it; never type a tier in over that. A PR that already has a row is left
untouched, so re-running it is safe. That append is the whole duty.

The row's last four fields are the ticket's difficulty, and they are what lets a
tier comparison condition on the thing that swamps it. sizing is the
member's own light/heavy verdict from its phase-2 sizing-a-ticket
run, read back off the PR body's Sizing: line (gh pr view <pr> --json body),
which names the signal the verdict turned on beside the verdict itself;
profile, loc and files all come from diff-stats.mjs over the merged
diff. Phase 0 does not size anything — it shortlists, a Pull claims, and
the sizing run happens inside the member after both, which is why the verdict
has to travel in the PR body rather than being something you already hold.

A verdict is a measurement only if it post-dates the run that produced it. A
Sizing: line typed in before the skill ever ran is byte-identical to one the
skill produced, so the body cannot settle this alone and the member's report
carries the second source: when it ran sizing-a-ticket relative to opening the
PR (#1070, measured on a member that wrote the line first, caught itself, and
corrected it). Record sizing only when the report places that run before the
PR AND the line carries its signal.
Ordering clause missing, verdict authored
before the run with no corrected value reported, or body and report naming
different verdicts — leave the field BLANK and say which in note. Do not
re-derive the verdict from the diff to fill the gap: sizing-a-ticket weighs
more than file count, and a substituted proxy reads as measured too. A value
not in hand is left BLANK, never estimated
— blank reads as unknown and drops
the row from a stratified comparison, while a guess reads as measured and
poisons one. Blank still means the field is WRITTEN and empty: omit the flag
(--sizing, --profile, --loc, --files) and the script writes that field
empty at full width, because a row of some in-between width cannot be told
apart from a shifted one. The note field is free text and sits before those
four, so the script refuses a tab or newline in it: one tab would shift all
four for that row alone.

Then record the run's member facts — do not author them. Scrape EVERY session
directory for this cwd, not one:

PROJECT_DIR="$(node -e 'import("./scripts/board.mjs").then(m => console.log(m.encodeProjectDir(process.cwd())))')"
for d in "$HOME/.omp/agent/sessions/$PROJECT_DIR"/*/; do
  node scripts/member-outcomes.mjs "$d"
done

encodeProjectDir (scripts/board.mjs) encodes the cwd the way omp's own
session directory naming does — every non-alphanumeric character becomes
-, with a leading dot segment's dot PRESERVED rather than folded into the
dash run (member-record.test.mjs's regression case: .claude becomes
-.claude, not --claude) — and hand-guessing that path is why the fleet's
own panel once rendered nothing here. The trailing / on the glob matters:
the same encoded-cwd directory also holds this cwd's own top-level session
transcripts as loose FILES sibling to the per-session directories, and a
glob without it would try to scrape one of those as if it were a session.

Do not narrow this to "this run's session" with findSubagentsDir. That helper
answers a different question — the session with the NEWEST transcript for this cwd,
which is not the same as the one you are in. Any other session sharing this cwd —
a second terminal, a second controller run — can hold a newer transcript than
yours at the moment you reach this step, and a second session dispatching
anything while you reach it wins the tie: the run then re-scrapes a stranger's
members, prints a plausible row count and exits 0 while its own facts are
never recorded. Looping every session dir costs a few seconds, cannot pick
wrong, and is idempotent by construction.

The scraper takes the session directory itself and refuses a path it cannot
read — a missing, unreadable or non-directory path all exit 2 — so a wrong
path fails loudly instead of writing nothing and exiting 0. It reports the
run's own YIELD, scraped N of M members (D dropped), alongside the file's
total: read the yield, because the total is the whole corpus and looks
healthy even when every member of this session dropped.

It derives every row from the member transcripts omp already wrote — both the
flat ones and a member's own nested fan-out one level down — so a
second run over the same session changes nothing and a re-run after a member is
re-dispatched picks the new transcript up. Never hand-edit
docs/metrics/member-outcomes.tsv
— it is regenerated wholesale whenever the
role classifier changes, and a hand-entered value would not survive that. The
verdict for a PR goes to tier-outcomes.tsv through tier-outcomes.mjs append, as above. Once both files hold this run's rows, and before either is
committed, run ~/.fleet/bin/fleet-run tier-outcomes.mjs check --live. Exit 1
names each row whose tier disagrees with the one implementer the member facts
say ran for its ticket: correct that row's tier to the short name it prints,
or blank it and say why in note. Exit 1 also names the PRs of the case where
the latest run_date's filled rows have no implementer row in the member file
and none could be compared, so nothing from that run was checked: append the
run's member rows if they are missing, or blank each named row's tier and say
why in note.
Each WARNING names a PR this run's ledger marks reviewed= that has no row yet
— append it before committing if it is an implementer PR.

Both files are the run's own artifacts and neither commits itself. Carry them to
main the same way the run carries any other controller-authored change; leaving a
regenerated 5,000-row corpus uncommitted in the checkout is how it gets discarded by
the next git checkout with nothing to show it ever ran.

Commit both to chore/run-artifacts-<date> and open a PR — at end of run, and
that branch name is the convention rather than a suggestion.
Phase 0's
fast-forward step calls this the previous run's close-out PR and says the rules
THIS run needs arrive in it, so the next controller goes looking for it by shape;
a name of your own invention still merges and still cannot be found. Suffix a
letter when the date already has one (chore/run-artifacts-2026-08-23b).

Data rows and rule-doc prose never share a PR — one branch each, and when the
rules here compete this is the one to keep.
The two halves have opposite cost
profiles, and bundling makes the cheap-to-strand half gate the expensive one.
Stranding a data row loses it: member-outcomes.tsv is regenerated from harness
transcripts this repo does not own, so re-running the scrape later is worth only
what the harness still holds. Stranding a rule change costs the status quo, which
is where it already was. So chore/run-artifacts-<date> carries the two
docs/metrics/ files and nothing else, and prose that wants review goes on its
own branch with its own PR.

Why not decide inside one run. A run holds 2-3 implementer PRs, and ticket
difficulty swamps the tier effect — a gojq parity harness and a two-statement
shell reorder are not comparable units. Finding-counts are not comparable either:
selectDimensions trims the fan-out by diff profile, so one PR fields 29 agents
over six dimensions and its sibling fields 8 over three. And the fleet is not
stationary — this prompt gets edited mid-run when a defect earns it. Hence a
binary outcome per PR, accumulating, rather than a per-run verdict.

Why the rows this guard fired on could not answer the tier question, and this
is the load-bearing part.
Until the 2026-08-16 revert, class and tier were
perfectly confounded: routine always ran sonnet, correction always ran top
tier. So the comparison the file invites — routine-at-sonnet against
correction-at-top-tier — measures class, not tier, because those classes fail
in different ways by construction: corrections ship false claims in prose,
routines ship incomplete or regressing code. A run that "mixes both" does not fix
it. Only a same-class comparison across tiers is informative, and the old
rule could not produce one, since no routine ticket ever ran at top tier.

That last sentence is no longer true, and this is the part to re-read. Since
the revert every routine ticket runs at top tier, so the file now holds 55
routine/opus rows against 11 routine/sonnet (as of 2026-08-28, 103 rows
— recount before citing). The same-class comparison the paragraph above calls
the only informative one therefore EXISTS now. It is still not clean: the two
groups are split almost entirely by calendar date, so it measures prompt
evolution at least as much as tier. The counter-evidence paragraph earlier in
this section carries the split and the three rows that cut against it. Recount
both, and every other figure in this section, with:

grep -vc '^#' docs/metrics/tier-outcomes.tsv
awk -F'\t' '!/^#/ && $4=="routine" {n++; d[$1]=1; if($6=="no") no++} \
  END{print n, length(d), no+0}' docs/metrics/tier-outcomes.tsv
awk -F'\t' '!/^#/ && $4=="routine" {t[$5]++; if($6=="no") f[$5]++} \
  END{for(k in t) print k, t[k], f[k]+0}' docs/metrics/tier-outcomes.tsv

These rows are the historical corpus, and nothing appended to them fixes the
confound.
What fixes it is the exploration draw in the dispatch rule above:
one implementer in every five — every one, during burn-in — runs at a drawn
cell beside the policy cell's, against the same prompts on the same day, so
the comparison stops depending on which tier history happened to leave in
which week. Read each cell's comparisons, not the whole-file split, once there
are enough of them.

So read what the guard actually established: routine tickets sometimes fail to
close their own ticket. Not that sonnet caused it.
The revert is the
pre-committed rule being honoured, not a measurement. Restoring a cheap tier is
still the maintainer's call and still a change to the dispatch rule — but the
deliberate control it used to require is no longer something anyone has to
authorize one ticket at a time: the exploration draw above produces one in
every five Pulls by construction. Do not read a cell early. A cell's
comparison is one session holding an admissible row at that cell — its tier
check passed and its resolved effort is the cell's level — and one at
slow-high whose resolved model and effort differ. Count them per cell, never
pooled across cells, and stop until
there are at least ten of them across five or more distinct run_dates; below
that, a comparison count is a number, not evidence, and the last guard fired
with n=1 on the control side. Report the count the per-cell readout prints,
never one from any other query:
until that readout exists there is no count
to report, the pair query in member-outcomes.tsv's header counts pre-cutover
pairs only, which are no cell's comparisons, and the query before that one
counted every session whose implementers merely differed, so it cleared this
floor by an order of magnitude while the controlled comparison did not exist yet.

Why the agent body carries what it does — read this before editing any
fleet-implementer-<cell> file, and keep every cell's body byte-identical. Each rule in the body's
enumerate-and-declare block is load-bearing, for a different reason.
Enumerate-and-declare answers a signature measured four times in one run:
each implementer fixed exactly the cases its ticket named and left an adjacent
one of the same class broken — a CRLF body broken by the fix for depth and
split headings, a symlinked worktree after fixing locked and regular-file ones,
an unreadable refs/heads/<type>/ after fixing refs/heads itself, a bracketed
file path after fixing typos and vendored ones. All four failed to close their
own ticket
— #65's acceptance criterion went unmet, and the other three left
the named defect reachable by a sibling spelling. Every one was caught by
review, at review cost, which runs 3-5x the implementation it checks.

Prose-first and wrongly-REFUSE are not padding — they are the #394 case,
which shipped with enumerate-and-declare already in its prompt. It missed a
shape its own ticket named in passing, because it enumerated from the mechanism
instead of the text; and it introduced a regression refusing every node --test
flag, because it enumerated what the guard should catch and never what it could
wrongly refuse.

The commit-incrementally block is not optional. A member that goes idle mid-task
leaves its diff only in the worktree, and the controller cannot reap, replace,
or even see it — worktree-audit.sh's committed-vs-uncommitted split is exactly
what decides whether a replacement redoes or destroys work. Observed twice in
one run.

The identity block is what makes sizing-a-ticket's fleet entry reachable.
That skill conditions its heavy-row process on the reader being a fleet member;
a solo reader settles the open design choices with its user before planning,
while a fleet member's ## Agent Brief already is that settlement. A member
that takes itself for a solo session waits on a user to settle design — a user
no unattended member has, so it parks rather
than fails. Nothing else you carry says what the member is — an incidental
mention of the controller is not a statement that the reader is one of its
members. That skill keeps its condition, so a solo session still runs the
interactive path; this block is what puts a member on the fleet side of it.

A member that bails demotes nothing itself; demotion by cause is yours
(Implementer bails before implementing).

Phase 3 — event loop

React to events; never block on one.

React to artifacts, not agents. idle means "not currently executing", not
"done", and a finished member's finding may never arrive. Read gh pr view,
gh run view, git -C <worktree> status before messaging; when you message,
send the specific next action, never "what is your status".
See references/member-lifecycle.md.

Do not ask permission to run the loop. Dispatching a reviewer, spawning a
merge bot, Pulling a ticket, re-verifying a SHA, filing a follow-up — all proceed
unconfirmed. Invoking the command was the opt-in. One exception: a judgement the
evidence cannot settle
.

Rule: record, tick, act, beat. Every wake — a member report, a review
notification, a label, a CI run reaching a terminal state, a heartbeat — ends in
one fleet-tick.mjs invocation, and you do what it prints. There are no hand-run
refill or dispatch edges left. Record first, because the tick reads the run off
.fleet/ledger.md and nothing else of yours: a wake you have not written down is
one it cannot see, and it names the same dispatch again. Every record goes
through ledger.mjs (Run ledger) — settle for a member's outcome, row
for a token on a ticket's line — never a hand edit.

Wake Record, then tick
Implementer report verify-sha.sh; ledger.mjs settle impl-<N>=PR#<M>, or =bailed and relabel by cause (Implementer bails before implementing, below)
Review workflow notification / review-pr-<n> report write <scratch>/review-<pr>.json; reviewed=<head>:<survived>/<refuted>/<unverified> on the PR's row (Reviewers)
Fix-applier report ledger.mjs settle fix-pr-<M>=…; for a fix-applier that answered a review — not one that cleared a conflict hold, which has no review file — ~/.fleet/bin/fleet-run dispositions-check.mjs --member <that member> --scratch <scratch>, from the checkout root: it judges <scratch>/dispositions-<M>.json against <scratch>/review-<M>.json, writes dispositions-ok=, dispositions-mismatch= or dispositions-escalate=<member>:<head> onto the PR's row itself, and exits 1 on a mismatch or an escalation, naming each violating or escalated entry's bucket and index, a violation its rule too — Then dispatch a fix-applier says what a mismatch asks of you, the gate paragraph below what an escalation does; copy the refutations it reversed — the record's refuted entries — to ruled; ledger.mjs filed <N> "<subject>" for each unrecorded: line
Finisher report ledger.mjs settle finisher-pr-<M>=labelled as reported, even when its read-back lacks ready-to-merge — the tick's DISPATCH finisher PR#<M> catches that next. A repair finisher's (one sent on that line) read-back lacking it also gets gh pr comment <M> with both finishers' read-backs: the one-shot escalation, where halts comment. ledger.mjs filed <N> "<subject>" for each unrecorded: line
Finisher report (halted) ledger.mjs settle finisher-pr-<M>=halted:<cause>; gh pr comment <M> with the finisher's halt report, cause and evidence; ledger.mjs filed <N> "<subject>" for each unrecorded: line; then the per-cause rule (Resolving a finisher halt, below)
Finisher report (failed / killed) ledger.mjs settle finisher-pr-<M>=failed for a finisher that crashed or gave up, =killed for one that was killed; no label, and the tick prints no dispatch for it. The cockpit flags the PR finisher:failed / finisher:killed at severity 4 and you resolve it by hand (Resolving a finisher that died, below)
Label seen (persistent Monitor) nothing to record
CI run terminal ci=<run-id>:<attempt>:<conclusion> on the row; then the finisher gate (below)
Merge-bot pass report held-behind:#<lower> rows; ledger.mjs settle merge-bot-<n>=done; reap.sh --apply. (The bot may also have written conflict-hold:#<pr> onto a held PR's own row earlier in this same pass, before reporting — that token is the bot's, never reap.sh's.)
Drain ledger.mjs drain "<reason>"; release the claims (below); settle impl-<N>=released
Heartbeat nothing to record

Then run the tick, act on every line it prints, and arm the beat. What a wake
owes beyond its row:

  • Nothing to do right now → do not end your turn. Arm the heartbeat
    below. A turn that ends on a drained queue is #3's stall itself: nothing
    external will wake you, and the level-check you are one command away from
    never runs. Listed first because it is the only wake with no trigger —
    every other row above arrives; this one is the absence of one.

  • Implementer report → verify the SHA is reachable on the expected branch,
    settle the member, then run the tick. The PR is owed a review from here on:
    the tick names it as DISPATCH review PR#<M> once a reviewer slot is free,
    and it names the next shortlist head for the slot the implementer left. Pull
    nothing the tick did not name.

  • Implementer bails before implementing → settle impl-<N>=bailed, then
    demote by cause:

    Cause Label
    brief does not decide what to build needs-triage
    genuine fork — options named, none ruled ready-for-human
    needs human hands — external access, manual testing, judgment during the work ready-for-human

    gh issue edit <N> --remove-label ready-for-agent --remove-label in-progress --add-label <label>, comment the cause, release the worktree and branch with
    release-ticket.sh (below — reap.sh declines a claim that never became a
    PR), then run the tick: the head it names next is a different ticket.
    needs-triage routes back to /triage, which can return it as
    ready-for-agent; ready-for-human for hands or a fork; needs-triage for a
    brief that names no what.

    Dropping in-progress is the load-bearing half — phase 1 applied it and
    candidates.mjs excludes it, so leaving it makes the ticket invisible to your
    scans and the maintainer's. It does not loop; it disappears. The same causes
    relabel an unclaimed ticket at the Pull (phase 1), where there is no
    in-progress to drop — the causes and labels are one table, applied at
    whichever point the ticket fails them.

  • A review result lands → see the result file written (by you or by the
    runner — Reviewers says which, per harness) and record reviewed= off its
    digest, then run the tick: a PR with survivors prints as
    DISPATCH fix-pr PR#<M>. Nothing survived and nothing to file → skip the
    fix-applier and dispatch the finisher directly: no member will touch that
    PR, so no push is coming and nothing will wake you. A review that failed is
    not a result — Reviewers says who retries it and what takes over.

  • Monitor: ready-to-merge appears → nothing to record; run the tick, and
    on DISPATCH merge-bot dispatch the next merge-bot-<n> (Merge bot).
    Catches hand-added labels as well as a finisher's own. The bot does not wait
    for that finisher's report (ADR 0012 Decision 2): its rebase is server-side,
    gh pr update-branch --rebase, so nothing on the finisher's worktree has to
    happen first.

  • Merge-bot pass reports done → write each held-behind-#<lower> it reports
    into that ticket's row as held-behind:#<lower> — the verdict moves no label
    and leaves nothing in the repo, so the row is the only place the tick can read
    it from — settle merge-bot-<n>=done, reap merged branches and worktrees with
    reap.sh --apply (below) — always, even when the pass merged nothing — and
    then run the tick. A label that landed at the last moment reads DISPATCH merge-bot again: dispatch the next bot straight away. A rebase-fallback-#<pr>
    on a conflict needs no record from you — the bot already appended
    conflict-hold:#<pr> to that PR's row, and the tick turns it into DISPATCH fix-pr PR#<pr> (Reviewers).

  • The run ends, or the maintainer says drain → ledger.mjs drain "<reason>" first, then release every claim that never became a PR (below)
    and settle each impl-<N>=released. Nothing else in the loop fires for those
    claims. The marker is on disk, so the tick holds the implementer row at
    HOLD (draining) from then on — for a replacement controller too — and keeps
    the review, fix-applier and merge rows firing until the open PRs merge.

  • Monitor: CI run completes → bind it (ci-state.mjs --pr <N>) and record
    it as ci=<run-id>:<attempt>:<conclusion>, not just "CI green"
    — in the
    ticket's ledger row, which outlives your context. row replaces the whole
    line
    (above), so repeat ports= and → PR# or they are gone.
    That key cannot be reconstructed once the head is superseded, so a
    controller holding only the colour cannot later tell an edge it has already
    spent from one that never fired, and that is the distinction the sibling
    bullet below turns on. The whole key, never the conclusion alone: a
    conclusion is not stable
    (below), because a rerun rewrites the run in
    place. The diff-validating check job green with no heavy job (the
    diff-validating suites, not the rebase-check currency gate) in failure
    → a finisher CANDIDATE (gated below), a check
    failure → a fixer. A check-green board whose heavy jobs are merely
    skipped (behind-count staleness, the normal case after a merge) still labels — do NOT
    gate on ci-state --quiet exit 0, which a behind PR never reaches. This
    edge fires for every CI run the PR produces, and the implementer's own
    firing comes first for every PR
    — ci.yml triggers on pull_request, so
    opening the PR at the end of next-ticket step 7 starts a run within seconds,
    while the review returns 20-40 minutes later. That first firing is
    the ordinary case for every PR, not a rarity, and no review has run at all
    when it arrives. Green is therefore not the gate: dispatch a finisher only
    once the PR's review has returned AND its fix-applier, if one was dispatched,
    has sent its report — pushed is not reported
    , and a push before that report
    is mid-work on a SHA the member may still move, which its own prompt tells it
    not to hand over. A push is therefore not an exit, and a report that has not
    arrived is asked for by name rather than read as one. Neither shape is caught
    by an empty outbox: on the implementer's firing there is no fix-applier to owe
    anything to, so the outbox is vacuously empty, and on a fix-applier's
    mid-work push nothing has been sent since the dispatch, so it is genuinely
    empty. The outbox is the last test, not the only one, and it stands wherever
    a fix-applier exists
    — that is exactly where a ruling you still owe it is
    outstanding, so never dispatch off it while you do. Empty your outbox to it
    first — including any ruling you have withdrawn or reversed — then dispatch
    (below); a final report is not proof it stopped. A PR that needs nothing fixed
    reaches a finisher through the fix-applier reports no-op bullet below,
    never by waiting for a second CI event this head will never produce.
    Then run the tick.

  • A fix-applier reports no-op, or a SHA you have already bound → dispatch
    the finisher now, against the existing head. No push means no new run, and
    the Monitor above is edge-keyed on <run-id>:<attempt>:<conclusion> — that
    head's terminal state already fired once and will never fire again, so waiting
    for the CI event waits forever. This stays a common outcome on a clean PR:
    suggestion is the band a clean diff produces, and every out-of-scope or
    refuted one is filed as an issue rather than committed. An edge-only label path
    therefore strands exactly the PRs with nothing wrong with them. Reconcile, do
    not wait for an event
    governs here too, not only the implementer row. This
    bullet and the one above are a pair, and on a clean PR this one carries the
    whole route to a finisher
    : a no-op report satisfies the gate above by
    itself — the review has returned, because a fix-applier exists only after it
    did, and the member has reported — so nothing further is owed and no further
    event is coming. Edit either bullet without the other and what strands is
    exactly the PR that needed no fixes.

  • ci-state.mjs --pr <N> reads verdict: "no-ci" → no workflow run will ever
    complete for this repo, so the CI-run-completion edge above never fires and
    waiting for it stalls the whole PR — the same silent-stall shape #111 reported
    before this verdict existed. Dispatch the finisher off the reviewer's final
    verdict instead, the moment it lands — and, same as above, never while you still
    owe it a ruling — or hold anything else it has not received, a ruling you have
    withdrawn or reversed included. The finisher's gate is then the
    --declare-no-ci declaration, not a check job: with it, label off the
    reviewer's verified suite run; without it, do not label — report that this
    repo has no CI configured and no declaration, and stop. Absence never reads as
    pass. See review-and-fix.md step 6.

Every wake ends in the tick. One invocation, and you act on what it prints:

~/.fleet/bin/fleet-run fleet-tick.mjs [--implementer-cap <n>] [--reviewer-cap <n>] [--max-reviews <n>]

It reads the run itself, and nobody states a count. Live implementers, live
review units (in-flight review= tokens plus unsettled fix-pr- ones), the
merge bot, merge holds, tier mismatches and the drain marker come off
.fleet/ledger.md; the unclaimed heads and the supply come off
.fleet/shortlist.json, which it refreshes itself when the unclaimed count is
below the implementer cap, when the file is missing or empty, or when an
Exclusion's premise has lifted; the open PRs come off gh. A ledger token it
cannot read, it refuses rather than guessing — a member counted live or gone on
a guess is the over- or under-dispatch it exists to prevent — so correct that
row with ledger.mjs row and tick again. The caps default to 2 implementers
and 6 reviewers, and neither is a hard bound: pass --implementer-cap and
--reviewer-cap on every tick when $ARGUMENTS set them, and never otherwise.

It prints actual/target and a named ACTION per role, with the whole Queue
depth
guard table applied in code. Act on each line as it reads:

  • PULL #<N> … — one Pull per named ticket, in the order printed: phase 1's
    read, then phase 2's dispatch. A ticket that fails the read is relabelled or
    excluded (phase 1), and the next tick names the next head.
  • REFRESHED shortlist: <n> entries; <k> lifted — the tick refreshed the
    shortlist itself, and its PULL row already reads the result. REFRESH FAILED is yours: shortlist.mjs could not run, and the implementer row is
    blind until it can.
  • SUGGEST /triage, hold idle — nothing is admissible after the refresh.
    Suggest, never run (Queue depth).
  • DISPATCH fix-pr PR#<M> … — a fix-applier per PR, for a review's survivors,
    a dispositions mismatch no retry has answered, or a merge bot's conflict-hold:#<M> (Reviewers). Printed
    ahead of reviews on purpose: finishing what is started beats starting more.
  • DISPATCH review PR#<M> … — a review per PR, oldest first, off your turn
    (Reviewers).
  • DISPATCH merge-bot — the next merge-bot-<n> (Merge bot).
  • DISPATCH finisher PR#<M> … — a finisher settled labelled, yet the open
    list shows PR M without ready-to-merge, and no label-off= (Run ledger)
    accounts for the removal (#2331). Apply the finisher gate (Reviewers: the
    check job green on the current head), then dispatch the next-suffix
    finisher at that head — ledger.mjs dispatch <M> finisher-pr-<M>-<x>, one
    letter past the highest the PR has used. Never add the label yourself. A head
    that moved is caught by the new finisher's duty 1 past-pin halt and the
    re-review path that follows it. Finishers take no reviewer slot, so this
    prints whatever the reviewer cap reads.
  • ESCALATE unlabelled PR#<M> … — a second finisher since the last label-off=
    settled labelled and the label is still not on: the tick dispatches no
    third. Not actionable; the maintainer's, through the cockpit's unlabelled
    flag and the comment the repair finisher's report left (Finisher report
    row above).
  • HOLD (…) — the row is held and says why: draining, a tier mismatch, an
    unchecked tier, a saturated review side, --max-reviews in flight, or every
    queued merge candidate held behind a lower PR or on a conflict hold no
    fix-applier has cleared. The two tier holds are yours to clear — an
    unchecked tier by running tier-check.mjs --batch on the member it names
    (its tier-ok=, or tier-unverifiable= for a member settled killed or
    released with no transcript, clears it), a mismatch by dispatching the
    replacement at the right tier (phase 2). Each
    prints its clearing step in the row's detail: the tier-check.mjs command
    with a batch entry per member it names (fill in <file> and <session>),
    or the replacement's name — impl-<N>-b on a first retry, else one letter
    past the highest the ticket has used, or that no retry letter is left.
  • AT CAP, IDLE OK — nothing to do on that row.
  • MAIN-CHECKOUT-DIRTY <paths> …, printed above the rows, with every
    dispatching row (implementers, reviewers, merge-bot) reading
    HOLD (main checkout dirty) — the main checkout changed since the Phase 0
    baseline. The line names the changed paths and every live member; it
    attributes nothing, because concurrent members make that a guess. Dispatch
    nothing new and tell the maintainer: they resolve the stray paths FIRST,
    then re-baseline with ~/.fleet/bin/fleet-run main-checkout.mjs --record.
    The hold lasts until that re-baseline, tick after tick. Never revert the
    paths yourself, and never re-baseline over paths nobody has resolved. The
    one change of yours that trips it is a mid-run tooling fix edited in this
    checkout; Fix the tooling mid-run says how that one is cleared.
  • MAIN-CHECKOUT-UNKNOWN … with HOLD (main checkout unknown) — the tick
    could not look: a git or hash read failed (could not look:), or the
    baseline exists but cannot be read (baseline unreadable:). Never clean,
    and never re-baseline over it — --record overwrites, so that certifies
    a tree nobody read. Fix what the line names and tick again.
  • MAIN-CHECKOUT-NO-BASELINE … with HOLD (main checkout no baseline) — no
    baseline was recorded. Yours to fix: run Phase 0's --record. A baseline
    that vanished mid-run is not a fresh run — report it to the maintainer
    before recording over whatever the tree now holds.

Tier guards under Pull. The per-dispatch tier-check.mjs --batch is
phase 2's own step: a mismatch it finds is the
HOLD (tier mismatch impl-<N>) above, and a member it has no verdict on is
the HOLD (tier unchecked impl-<N>). Nothing else in the loop owns it.

--max-reviews <n> bounds how many reviews are in flight at once, inside
the reviewer cap. It defaults to the reviewer cap, and the reviewer slots it
leaves still serve fix-appliers. Leave it at its default: every review unit
already counts against the session's single task.maxConcurrency semaphore,
which queues at its ceiling rather than refusing (#1771), so reviews run up
to the reviewer cap without a tighter explicit bound.

Why every wake. A merge cascade is a firehose of merges, CI greens and
rebases that holds your attention on the merge side while the implementer side
drains to 0 and stays there — 0 implementers emit no completion event, so a
refill that waited for one would never fire. A tick on every wake you are
already handling is what makes that drain visible. Every wake is still an
edge, so none covers a fully drained queue
: no members and no open PRs is no
event at all, and the tick goes unrun. That state is the heartbeat's (#357).

Beat when there is nothing to do, and never end your turn on a drained
queue.
You cannot be woken. A backgrounded Monitor does not wake an idle
agent — measured twice, see A member runs its test suite in the foreground
above, where fix-pr-1184 backgrounded a suite plus a Monitor and sat idle for
roughly two hours — and run-merge-bot.md's CI gate states the general rule:
"whatever wakes you is external and may never come". So hold the wait yourself,
exactly the way that gate does:

~/.fleet/bin/fleet-run fleet-heartbeat.mjs \
  [--base 300] [--ceiling 1200] [--multiplier 2] [--hold 240]

It blocks, then prints one line. Which line it is, is the whole protocol:

  • … interval elapsed (quiet=<n>) → run fleet-tick — the level-check is due.
    Run the tick above with --fold-unchanged and act on what it prints.
    Then arm the beat again.
  • … Ns of Ms remain → re-issue this command now, do not end your turn — no
    command blocks for a whole interval (omp backgrounds one at
    60s, which is why the
    CI gate tells you to re-issue gh run watch). Issue it again. That is still
    one blocking call per turn, never an idle turn.

The interval doubles while the fleet asks for nothing and stops at --ceiling,
so a quiet night costs ~26 wakes instead of ~96. The ceiling is a real bound,
not a formality
: supply grows from OUTSIDE the fleet — a ticket you triage, a
correction ticket a reviewer files — and that arrival emits no event either, so
the ceiling is your worst-case latency for noticing it. Do not raise it past 30
minutes.

The beat does not stop on idleness. An empty shortlist and an empty supply are
not a reason to end the run; they are the state a newly triaged ticket arrives
into. The run ends when you drain it, when the budget goes, or when this
context does — not because there was briefly nothing to do.

--fold-unchanged belongs to the heartbeat alone, never to any other wake.
It prints one line instead of the rows when a tick asks for nothing AND says
exactly what the last one said, which is what makes an unattended night
affordable; a tick on any other wake is read by a controller that just acted and
needs the rows. An unchanged PULL or DISPATCH is never folded — unclaimed work
always prints in full, because folding it would hide the stall behind this
ticket's own remedy. A dirty or unknown main checkout still folds — clearing it
is the maintainer's, so it is nothing for you to act on — but the folded line
names it (…; HOLD (main checkout dirty) persists — …) for as long as it holds
dispatch.

When you stop on purpose, say why — one command, and it is the last thing
you owe the next run.
The beat leaves a dated mark on every hold, so a run
that dies abruptly (a crashed harness, a closed terminal, an OOM kill) is
detected by its mark going stale. A run that stops KNOWINGLY can do better
than being detected, and nothing else can write the reason afterwards:

~/.fleet/bin/fleet-run fleet-heartbeat.mjs --stop "budget exhausted"

It holds nothing and returns at once. Use it when the budget goes, when you
can see the context ceiling coming, or when you drain the run deliberately.
Skipping it is not fatal — the next reader still reports the stop, it just
reports that the beat stopped without a recorded reason, which is all it can
honestly say about a death that named nothing.

A heartbeat STALLED line is the PREVIOUS run, never this one. fleet-tick
prints it before its own rows when the mark it finds is overdue against the
interval that mark recorded, and the cockpit shows the same line while it is
up. It names when the beat was last seen, how overdue that is, how many
tickets are still claimed and in flight, and how much supply the last read found
— because the claimed ones keep the in-progress label, the candidate scan
EXCLUDES that label, and those tickets are therefore invisible to your own
shortlist and to the maintainer's. Read the line, decide what to do about the
stranded tickets, and carry on; nothing in the fleet releases, relabels or
reaps them for you, and nothing restarts the run that stranded them.

Own the CI waits. Members are turn-based and cannot hold across a ten-minute
run — they rebase, push, stop. Arm a second persistent Monitor over open PRs'
latest runs, keyed <run-id>:<attempt>:<conclusion> so each terminal state fires once, and
emit the behind-count and the per-job conclusions with it: a success on a branch
8 behind is not actionable, and check-green-with-heavy-skipped is the staleness
board you must not confuse with a red. Do not reinvent that read: one
~/.fleet/bin/fleet-run ci-state.mjs --pr <N> per open PR already emits
run id, attempt, conclusion, behind-count and per-job conclusions, branch derived
from the PR — including the gh api --hostname <host> a GHE compare call needs,
without which the probe 404s and behind comes back null, never 0, so every
event reads as unknown-behind. Call it without --quiet — that flag drops
jobs, missing and dropped.

A monitor event is a wake-up, never a verdict; members re-query at labelling
time. See references/ci-and-staleness.md.

A conclusion is not stable, even for a fixed run id on an unchanged head. A
rerun rewrites the run in place, so never cache a conclusion; key watchers on
<run-id>:<attempt>:<conclusion>. Leaving attempt out of the key is the bug
that looks like it works: a rerun that lands on the same conclusion regenerates
an already-seen key and fires nothing, so the second failure is silent and reads
exactly like a run still in progress. Building that key with jq's // is the
same bug in the opposite direction
: ci-state reports an in-progress run as
conclusion: "", and // catches null and false but never the empty
string, so .conclusion // "-" passes "" straight through, keys a RUNNING job
as terminal and fires a spurious not-green — measured in four separate runs,
including one where the controller then had to re-arm blind. Test any fallback
you write against "", null AND a real conclusion, and check it still lets
the real one through: map(if . == null or . == "" then "-" else tostring end).
Also: the newest
run on a branch is frequently not CI, so --limit 1 can hide the CI result
entirely. See references/ci-and-staleness.md.

A probe that cannot read is a transition event, not silence. The natural
watcher shape — st=$(ci-state.mjs --pr "$pr" 2>/dev/null) || continue — fails
closed correctly and then throws the failure away, so a rate-limited probe emits
NOTHING and "no event" becomes indistinguishable from "not green yet". Every
open PR stalls unlabelled while the run reads as merely quiet; observed live
twice. So check the REST budget before polling — gh api rate_limit is itself
unmetered (gh api rate_limit --jq '.resources.core.remaining', re-confirmed
2026-09-08) — and give an unreadable ci-state payload the same treatment one
level down. The shape is one outer tick loop with an inner pass over the open
PRs
, because the two causes live at different levels — the budget is one
account, the payload is one PR:

budget_out= list_out= cap_out= blind=        # seed the latches: set -u reads each below before tick 1 ever assigns one
pr_cap=200                                   # the open-PR read's --limit; gh stops there with exit 0 and no warning
while :; do                                   # one tick
  rl=$(gh api rate_limit --jq '.resources.core.remaining' 2>/dev/null || echo ERR)
  case "$rl" in ''|*[!0-9]*) rl=ERR;; esac    # a non-numeric read is an outage, not a budget
  if [ "$rl" = ERR ] || [ "$rl" -lt 200 ]; then
    if [ -z "$budget_out" ]; then             # ONE global latch: a quota outage hits every PR
      echo "WATCHER DEGRADED: core REST budget=$rl — CI polling paused, silence is NOT green"
      budget_out=1
    fi
  else
    if [ -n "$budget_out" ]; then
      echo "WATCHER RECOVERED: core budget=$rl — CI polling resumed"
      budget_out=
    fi
    prs=$(gh pr list --state open --limit "$pr_cap" --json number --jq '.[].number' 2>/dev/null) || prs=ERR
    if [ "$prs" = ERR ]; then
      if [ -z "$list_out" ]; then             # global latch: this cause is account-level too
        echo "WATCHER DEGRADED: cannot list open PRs — CI polling paused, silence is NOT green"
        list_out=1
      fi
    else
      if [ -n "$list_out" ]; then
        echo "WATCHER RECOVERED: open-PR list readable again — CI polling resumed"
        list_out=
      fi
      if [ "$(printf '%s\n' "$prs" | grep -c .)" -ge "$pr_cap" ]; then
        if [ -z "$cap_out" ]; then            # global latch: a full page may have been cut there
          echo "WATCHER DEGRADED: open-PR list hit its --limit $pr_cap — PRs past it are NOT watched, silence on them is NOT green"
          cap_out=1
        fi
      elif [ -n "$cap_out" ]; then
        echo "WATCHER RECOVERED: open-PR list under its --limit $pr_cap — every open PR watched again"
        cap_out=
      fi
      for pr in $(printf '%s\n' "$prs"); do   # inline $(...): `for pr in $prs` is ONE iteration under zsh
        st=$(~/.fleet/bin/fleet-run ci-state.mjs --pr "$pr" 2>/dev/null) || : # not-green exits non-zero; the payload is the verdict
        if ! printf '%s' "$st" | jq -e '.verdict and .verdict != "rate-limited"' >/dev/null 2>&1; then
          case " $blind " in *" $pr "*) ;; *) # latch keyed BY PR: this cause is per-PR
            echo "WATCHER DEGRADED: no usable ci-state reading for #$pr — silence is NOT green"
            blind="$blind $pr" ;;
          esac
          continue                            # inner continue — still reaches the tick sleep
        fi
        case " $blind " in *" $pr "*)
          echo "WATCHER RECOVERED: ci-state reading #$pr again — CI polling resumed"
          keep=; for b in $(printf '%s\n' "$blind"); do [ "$b" = "$pr" ] || keep="$keep $b"; done; blind=$keep ;;
        esac
        : # your normal handling of $st for this PR
      done
    fi
  fi
  sleep 120                                   # the block's only pacing, once per tick
done

One gate, not a gate plus a caveat. jq -e '.verdict and .verdict != "rate-limited"' exits non-zero on unparseable input and on a false result, so
that single test covers empty, garbage AND a self-named quota refusal. Empty
stdin produces no result at all, which -e reports as exit 4 (measured), so a
[ -z "$st" ] || pre-check in front of it is a second gate testing what this
one already tests — the caveat this heading is about. Leaving
the named verdict to prose instead is how a watcher clears its latch on an
outage payload and hands it downstream as CI state — the very blindness this
section exists to remove, one level in. The .verdict and half is load-bearing
and not belt-and-braces
: inequality alone never tests that a verdict exists,
so {}, null and {"verdict":null} all evaluate null != "rate-limited" →
true and clear the gate (measured), handing an empty or error object downstream
as a CI reading. Require the field, then compare it.

It has to latch — one line entering the degraded state, one leaving it,
never one per tick. An unlatched line at a 120s poll gets the Monitor
auto-stopped for volume during even a two-minute outage, reintroducing the same
blindness by another route. Latch each cause at the level its cause lives
at
: the budget, the open-PR list and that list's cap are one global flag
each — all three are account-level — but the payload latch is keyed by PR, and a
shared scalar for those is an unlatched line wearing a latch's clothes — one
persistently blind PR alongside one healthy PR re-clears the flag on every tick,
emitting a DEGRADED and a false RECOVERED pair forever. The recovery line is
what says the watch is alive again, so emit it on the first good pass even when
nothing about the CI state changed; without it a reader cannot tell a recovered
watcher from a dead one.

All pacing in exactly one place — the tick loop's own tail sleep. A sleep
inside the per-PR pass multiplies by the number of open PRs (8 open PRs → 960s
of pause per tick, against the 24s outage reset measured below), and a degraded
branch that continues the outer loop skips the tail sleep and busy-spins
probe pairs during exactly the outage it is reporting. Every degraded
branch above falls through to the same single sleep instead.

Guard every probe, not just the ones with a verdict field. The block makes
three API calls, and the third — gh pr list — is the one that looks like
plumbing rather than a probe. A non-quota failure there (secondary/abuse limit,
5xx, network blip, expired token) does not lower .resources.core.remaining, so
the budget gate clears, and an unguarded for pr in $(gh pr list …) then
iterates zero times over the empty substitution: no error, no latch, no DEGRADED
line — total silence per tick, which is exactly what this section exists to
remove. Capture its status (prs=$(…) || prs=ERR), latch it globally, and fall
through to the same tail sleep. Then iterate with the inline $(printf '%s\n' "$prs") form, never a bare for pr in $prs — the assigned-variable trap in
Shell traps above, which carries the mechanism and the measurements. It
applies to every loop in this block, the blind-latch removal included:
under a bare for b in $blind a recovered PR never leaves the list and the block
emits a fresh RECOVERED every tick thereafter — the per-tick volume the latch
exists to prevent, wearing a latch's clothes again.

A readable list can still be a short one. gh pr list stops at its
--limit — 30 when the flag is absent — with exit 0 and no warning, so a list
that fills its limit reads exactly like one that happens to be that long, and
every open PR past it goes unwatched without a line. Pass an explicit --limit
(pr_cap: one variable for the flag and the check, so the two cannot drift)
and treat a full page as its own degraded cause — the "no silent caps" rule
candidates.mjs's refuseIfCapped() and fleet-tick.mjs's PR_LIMIT already
enforce. It latches globally like the list failure but does not pause polling:
the PRs it did return are real, so the pass still watches them, and the
DEGRADED line names the rest as unwatched until a pass comes back under the cap.

Judge ci-state on its payload, never its exit code — the same rule as
merge-gate.mjs applies on the merge path, applied to the watcher. not-green is an
ordinary, frequent state that exits non-zero, so a watcher treating any non-zero
exit as an outage misfires constantly on PRs that are merely in progress. Empty
or unparseable output is the degraded case; parseable JSON reading
verdict: "not-green" is normal. An exhausted quota may also name itself,
verdict: "rate-limited" on stdout — likewise degraded, and likewise not a
reading, which is why the gate above tests the verdict rather than mere
parseability: that payload is perfectly good JSON.

Do not back off and wait. These outages are short — a rolling window; one
measured reset came 24 seconds after remaining: 0 — and pausing members for a
fixed interval stalls the fleet longer than the outage itself would have. A
retry-and-wait loop is what turns a 60s outage into a stalled member. Report it,
keep the local work moving (git, tests and mutation runs are all unaffected),
re-probe.

While the budget is exhausted, ledger.mjs check reads
verdict: "unverified"
— the ledger was read and the tracker was not. That is
correct behaviour, but unverified exits 0, the same as clean, so a
member treating exit 0 as "safe to file" files blind during exactly this window.
Say so when you flag the outage: members read verdict explicitly until you
report recovery, never the exit code alone.

Reviewers

Dispatch the review, never wait on it, and act when its result lands. That is the default path.
The tick names every PR owed one — DISPATCH review PR#<M>, oldest first —
and each gets one review, once per PR:

a task member named review-pr-<pr#>, agent fleet-review-runner, its prompt the five args pr, branch, worktree, testCmd and scratch — it loads review-eval.mjs through the Resolver (~/.fleet/bin/fleet-run --path review-eval.mjs), awaits runReviewOnOmp in its own kernel, writes the result file and reports; record review=member:review-pr-<pr#> on the PR's row before the dispatch call. scratch is the scratch root itself, never the review side's <scratch>/pr<N> partition: the review creates pr<N>/ under it, so a pr<N> passed in nests every run root at pr<N>/pr<N>/, and the review refuses a scratch whose last component is pr plus digits before dispatching anything.

Write the review= token with ledger.mjs row, which replaces the whole
line
, so carry every other field. The tick counts in-flight reviews off the
ledger and nowhere else, so a review you launched and never recorded is one it
names again.

It is the only path on which selectDimensions sizes the fan-out to the
diff and the verify budget follows severity.
Hand-dispatched (the fallback
below), neither executes at all: sizing falls back to a reviewer's own
judgement and nothing budgets the adversarial pass. It cuts one immutable
snapshot, verifies every critical/important finding adversarially,
re-dispatches each crashed specialist and each crashed refuter pair once before
it assembles the result, and has agent() return into the script, so no
report can go undelivered and you relay nothing — the delivery failure that
cost one fleet five reports on one PR and four on another. None of it runs on
your turn: a review takes 20-40 minutes, and a controller that held its turn
for each one, one at a time, was what left implementer slots empty (map #1768).

Know the trim before you rely on it — it is wider than docsOnly suggests.
diff-stats.mjs calls a PR docs-only only when it touches no src, tests or
config, but that strictness cuts both ways and the size tier trims again on top.
Measured: a docs PR that also adds one test file is profile tests-only and runs
three (correctness+tests+comments), not six; a no-src, no-test diff that
carries a config file keeps silent-failure at any size, so a docs+config diff
too big for the size tier now runs correctness+silent-failure+comments, not
correctness+comments; and any single-file or small profile trims to
correctness+silent-failure+comments, keeping tests only when a test file is in
the diff. comments is on that floor
unconditionally since #218 — it used to need a docs FILE, and classify() scores
any code extension src before it checks docs, so five production PRs whose whole
substance was prose inside a .js/.mjs/.sh comment scored docs: 0 and ran
without the one specialist that fit them, dimensionsUnrun empty every time.
That floor holds regardless of hasSrc (#236) — a one-file .yml or
.github/ shell change gets silent-failure too, not correctness alone.
It is the size tier's floor,
so tests-only outranks it when the diff has no config file: that profile is
assigned ahead of single-file/small, so a no-src, no-config diff that also
touches a test file gets no silent-failure-hunter — a tests-only diff that also
carries a config file keeps it (#739). single-file means one file at any size, so a
one-file rewrite trims too. An unknown profile widens to the full six, the safe
direction, so a trim is never something to count on in advance — and a full six
is never something to assume.

The reviewer cap counts live review units. A review in flight — a
review-pr-<pr#> member still running — is one; each
fix-pr-<pr#> is one; a finisher and a CI wait are zero; and a PR holds at most
one slot at a time. In-flight reviews are derived, never remembered: a row with
review= and no reviewed= is one, which is why the token goes on before
anything else. The defaults are 2 implementers and 6 reviewers, and neither is
a hard cap. The cap bounds units, not the agents a review fans out to — 1
snapshot + up to 6 specialists + 2 refuters per critical/important finding — and
how many reviews may be in flight at once, inside it, is --max-reviews. The
slots a review does not hold still serve fix-appliers.

It returns {pr, head, resume, testEnvironment, dimensionsRun, dimensionsUnrun, cwdAudit, counts, snapshot, survived, refuted, unverified} —
the digest first, then the snapshot and the findings — and it lands as
one artefact, <scratch>/review-<pr>.json, holding that bare object: the
review-pr-<pr#> runner writes it and reports the digest and the path.

You read the digest; the findings are the fix-applier's.
jq '{pr, head, resume, testEnvironment, dimensionsRun, dimensionsUnrun, cwdAudit, counts}' <scratch>/review-<pr>.json
is the whole of your read, and it is what you record: reviewed=<head>:<survived>/<refuted>/<unverified>,
off head and counts, on the PR's row. Findings never enter your context —
a result runs 26-61 KB, 7-15k tokens that used to land in yours and then again,
up to ~10k characters, in every fix-applier prompt you wrote. The fix-applier
reads every one off the file and makes every ruling on them
(review-and-fix.md's The review result file). What the digest tells you:

testEnvironment says what the review's shared test run is evidence about,
and it is present on a healthy run as well as a degraded one, so there is
nothing to notice by its absence. The snapshot is git archived and then
git inited with one commit, and the block that cuts it compares that commit's
tree hash against the reviewed commit's (#1056): verified, a suite run in the
snapshot collects and runs what a checkout does, and a red is a fact about the
tree. UNVERIFIED, it is not — tests that ask git what ships decline or fail for
the environment, and this field carries which line of the cut failed. The
fix-applier reads it before acting on any count from the shared test run; you
read it so a degraded review never passes for a clean one.
unverified is not "checked and cleared" — a suggestion skips the pass by
policy, and a finding whose refuters all crashed lands there too. Both go to the
fix-applier with the rest, in the file; never rule on them yourself.
refutersDispatched, carried on every finding, is what tells those two
apart
— zero is the policy skip, above zero with no surviving vote is the
crash — so the population is read off that field rather than off severity,
which records only how much a finding would matter if true. resume is
non-null exactly when a specialist or a refuter pair crashed again after the
review's own in-run retry, and resume beats handing a crash-heavy review
on
: a deferred crash is a finding nobody ever looked at.
No replay exists for a review that ran in a runner's own kernel — resume is reported, not acted on, and the fix-applier defers what it names.
refuted comes back deliberately as well — a refutation is itself a claim, and
one has been reversed on new evidence. The fix-applier may reverse one; it
records each reversal as a refuted entry in its disposition record, the
evidence in reason, and you copy those entries to the ledger's ruled line
(the Fix-applier report edge).

dimensionsRun is the dispatch; dimensionsUnrun is what names a gap. A
specialist that dies contributes zero findings while its key stays in
dimensionsRun; a review whose one shared test run executed nothing — the
review runs the test command once, and every specialist reads that run instead
of running its own (#2315) — leaves every key there with no suite behind it. So
read the two together. A key
in dimensionsRun and NOT in dimensionsUnrun ran a suite; every
dimensionsUnrun entry is {dimension, reason} naming which failure it was.
The review already re-dispatched each crashed specialist once, so what
dimensionsUnrun still names is residue: the fix-applier names it unrun in its
report and re-runs nothing — an absence of findings is not coverage. Same rule
the fallback below states for a killed specialist, for the same reason.

The rule this replaces — treat any dimension with nothing in
survived/refuted/unverified as unrun — was the workaround for having no
such field, and it over-refuses in the direction that costs work: a dimension
that ran clean has nothing in those three either (#137, #138).

cwdAudit is the specialist's own inherited-checkout audit, one entry per
dispatched dimension: {dimension, state, line}, state one of

clean/dirty/unrepo/missing. The prompt asks every specialist to run
git status --porcelain -uall against the directory it inherited before
returning and fold the result into a CWD-AUDIT: line — a convention, not
schema, so a specialist that never emits it or misspells it still validates
clean. This field is the runtime backstop that reads for the line regardless:
missing is exactly as loud as dirty, and both name a checkout you should
treat as a live scratch write until you have checked it (#1433/#1673).

A throw, an empty return, or a notification whose status is not completed
is a failure event, not a clean review.
The review throws on missing
args.pr/args.worktree and on a snapshot agent that returned no tree, and a
failure surfaces mid-loop, where "react, never block" makes it easy to log and
carry on — leaving a PR that reads as reviewed and is not. Retry once, then
fall back — and whoever holds the review call is who retries:

The review-pr-<pr#> runner already retried once inside its cell and reports failed with both errors; hand-dispatch the fallback reviewer below as review-pr-<pr#>-b, the name that report gives.
Either way, settle the dead review by appending =failed to its review=
token, then append review=fallback:review-pr-<pr#>[-b] after it, both in one
ledger.mjs row rewrite — the tick takes the last review= token on a row as
the one in flight, so that order is what keeps the fallback counted against the
cap. A runner member that is killed rather than failed follows Failure
handling
(fresh name, inherited state stated).

Then dispatch a fix-applier — on DISPATCH fix-pr PR#<M>, which the tick
prints for a PR whose reviewed= counts survivors that no review fix-applier
has landed applied:/no-op on since, with no fix-applier live on it — one
named member per PR, fix-pr-<pr#> (the next suffix when that name is already
on record), never the PR's
implementer, recorded with ledger.mjs dispatch <pr#> fix-pr-<pr#> before the
call. Its prompt carries the PR number, the worktree abs path, the PR's branch,
the same testCmd you passed the review, and the path <scratch>/review-<pr>.json
— never the findings. The fix-applier owns every per-PR ruling you once made
before dispatch: both mutual-exclusion scans, the suggested-fix re-derivation,
refuted=false ≠ apply, per-site measurement for a sibling-site extension, and
testEnvironment/cwdAudit before any result of the shared test run. It
holds every finding and you hold none, so there is nothing for you to rank,
relay or pre-rule — and a report from a refuter it spawns that surfaces to you
is its to retrieve, never yours to scan or pass on.

A DISPATCH fix-pr PR#<M> on a conflict hold — the row carries the merge
bot's unresolved conflict-hold:#<M> (run-merge-bot.md step 1's fallback) —
is the same slot with a different job, and no review file to hand
over. Name it fix-pr-<M>, or the next suffix (-b, -c …) when that name is
already on record — dispatch refuses a reused one — and dispatch it with the
agent that ledger.mjs dispatch printed, in the PR's existing worktree.
dispatch reads the unresolved hold off the tick's own per-PR state — every
row naming the PR, not one — and prints this member's own definition, whatever
the row's tier= — read it off that output, never recall it; a review
fix-applier's is null. Its prompt takes the prompt below's
worktree, branch and path discipline, but in place of its review paragraphs:
bring <branch> current with origin/main, resolving every conflict by
run-merge-bot.md's No-undo audit, run <testCmd>, push, report the new
head. Settle it applied:<head> — no-op if no conflict was left — which is
what lifts the tick's merge hold; a dispatched-but-live one does not, and
failed/killed leave the PR fix-due for a replacement. It answers the
hold and nothing else: survivors of a returned review the row also carries
stay unanswered through its settle, so the tick prints DISPATCH fix-pr PR#<M> again once it lands, and that one — dispatch now prints null —
is a review fix-applier, dispatched as above (#2328). The push moves the
head after ready-to-merge, so the next merge bot refuses it
head-moved-after-label-#<M>: a fresh finisher, per Failure handling.

A DISPATCH fix-pr PR#<M> on a dispositions mismatch — the PR's row carries
dispositions-mismatch=<member>:<head> for its latest review, and no later
fix-applier has landed — is one automatic retry, a review fix-applier named the
next suffix (fix-pr-<M>-b) and dispatched like the first. Its prompt is the
first one's, with two paths and a list copied in: the review file
<scratch>/review-<M>.json, the disposition record <scratch>/dispositions-<M>.json
it rewrites, and the check's violation list verbatim — the lines
dispositions-check.mjs printed, each naming a bucket, an index and a rule;
re-run the check for the mismatched member to reprint them. It rewrites the
record, and when it reports you run the check for it exactly as for the first
one. ok ends it. A second failure on the same review is written as
dispositions-escalate=fix-pr-<M>-b:<head> in place of a mismatch: the tick
prints no DISPATCH fix-pr for it, dispatch refuses the finisher naming
dispositions escalate, and you post the check's output with gh pr comment <M>
and flag the PR for a human. A new review of the PR counts again from none.

A refutation resting on an injection nothing proved landed is not a refutation
— it is a cell that never ran.
A mutation test and a finding reproduction are
both failure injections: break something, read what the child does. When the
break never reaches the child, the child behaves exactly like its baseline, which
is what a genuine no-effect mutation looks like and what a correctly refuted
finding looks like. So a negative result is admitted only together with its
positive control — one run proving the injected difference is observable,
either the child's output differing with the injection present versus absent or
the child echoing the injected value back from inside. No control → the cell is
unrun: re-run it, and never record it as a negative. Measured 2026-08-28 and
re-measured on this tree, against a probe.sh that echoes both variables back:

zsh  -c 'cfg="SETB=1 BADJ=1"; env $cfg sh ./probe.sh'   # BADJ=[unset] SETB=[1 BADJ=1]
bash -c 'cfg="SETB=1 BADJ=1"; env $cfg sh ./probe.sh'   # BADJ=[1]     SETB=[1]

zsh does not word-split an unquoted parameter expansion, so the whole string
arrives as ONE argument: a variable literally named SETB takes the value
1 BADJ=1, the injection variable BADJ is never set, and the probe still exits
0. It bites in both directions, and the reproduction direction is the worse one —
fix-pr-983 nearly reported a mutant as behaving identically to its baseline,
while fix-pr-1153 nearly retired a real review finding as non-reproducing after
the same shape handed both trees a single argv word and drew exit 2 from each,
which is the signature of a correct refutation with nothing anomalous to notice.
The fix-applier prompt and every refuter brief carry the rule and the safe
invocation form; the merge bot's brief is the wrong seat, because a bot
reads gates and never injects a fault.

Where testCmd comes from: the repository's Test entrypoint, out of the
Recipe cache phase 0's Recipe derivation step proved —
~/.fleet/bin/fleet-run derive-testcmd.sh . test prints it, in this repo
node --test plugin/scripts/*.test.mjs — and the one you hand specialists per
Give specialists a stack-free test command above. Pass the same string to
the review, to the fix-applier, and to the finisher — whose duty-2 mutation
gate runs it too — so every gate runs one command. Omit it from the review args
and the review (review-core.mjs, in a runner) reads the same cache itself,
refusing outright when there is none; the fix-applier has no such fallback, so
substituting <testCmd> with nothing leaves it no gate at all.

Where <branch> comes from: the PR's head branch —
gh pr view <N> --json headRefName -q .headRefName, or the same branch you cut
the worktree from. Substitute it exactly as you substitute <testCmd>, because
the member cannot read it off git: phase 1 creates an already-open PR's worktree
with --detach, and there git branch --show-current prints nothing and exits 0
(measured 2026-09-18, git 2.50.1), so git push origin "HEAD:$(git branch --show-current)" exits 128 on fatal: invalid refspec 'HEAD:'. Left unsubstituted, the push that prompt ends on has no destination at
all.

You are ALREADY in worktree <abs-path>, whose PR branch is <branch> — the
push destination below, given to you because a detached worktree cannot supply
it. Do NOT create another worktree. The review is done and its result is the
file below — do not re-review, do not dispatch specialists.

edit and read resolve a bare relative path against the SESSION ROOT —
the controller's own main checkout — not against that worktree and not
against your bash cwd, which they share no cwd parameter with. Give them
the absolute worktree path on every call, mutation-probe copies included.

The main checkout is the parent of git rev-parse --path-format=absolute --git-common-dir. A member-write-guard refusal means the path hit the
main checkout — for bash, a missing or main-checkout cwd — so re-issue
it absolute, under your worktree.

Read $(~/.fleet/bin/fleet-run --root)/commands/review-and-fix.md and run steps 2, 3
and 5 only
: split apply-now/defer, commit, push, file every deferral as its
own issue with the label the finding's state calls for. Skip step 1 — the
review already ran — and steps 4 and 6: the controller owns the CI wait and
dispatches the finisher.

The review's result is <scratch>/review-<pr>.json — the digest first
(jq '{pr, head, resume, testEnvironment, dimensionsRun, dimensionsUnrun, cwdAudit, counts}'),
then every finding (jq '.survived', jq '.unverified', jq '.refuted').
Read it through review-and-fix.md's The review result file, which holds
the rest of the recipes and every ruling on the findings: both
mutual-exclusion scans, the suggested-fix re-derivation, refuted=false ≠
apply, per-site measurement for a sibling-site extension, and testEnvironment
and cwdAudit before any result of the shared test run. Every one of those
rulings is yours — nobody upstream has made one, and the controller never
reads the findings.

Every factual claim your diff restates needs a settling command run
against the tree first
— the issue body is a lead, never a citation.
No positional references (the closing/second/last X); name the thing
semantically. Never write a COUNT or a tally into prose; state the
property instead
— unless it is a past-tense record of a measurement you
performed, which stays as written; a present-tense claim about a live
property must be restated as a property (every other test in the file),
true at any count. A settling grep over literal text takes grep -F, and
its zero counts only after the same command finds a line known to match.

Apply survived findings. A finding in unverified whose refuters ran and
crashed always defers
— and which of the two it is, you read off
refutersDispatched, never off severity and never off an empty vote list:
above zero with nothing surviving means every refuter dispatched against it
died, and at critical that is every one of them. Severity records how much a
finding would matter if true, never whether anything looked. Say in the
deferral
that its refuters crashed rather than that it went unchecked — the
review re-dispatched each crash once already and reports what died again, so
a deferral that names the crash can still be re-verified. A suggestion is also in unverified,
for a different reason — refutersDispatched of zero, the 0-refuter budget
the review gives that band by policy — and the rule below, not this one,
covers it.

A suggestion is budgeted 0 refuters, so it is unchecked until you check
it.
For each one, first decide scope: is it inside the scope of the PR's own
ticket, or a different piece of work? Out of scope → defer and file, never
apply.
In scope → dispatch ONE refuter against the finding before
touching the tree, biased to refuse, and hand it the prompt below with one
substitution: resolve <scratch>/pr<N>/<finding>/ in it to an absolute path
under your own run root — mktemp -d once per round for
<scratch>/pr<N>/fix-XXXXXXXX/, never bare pr<N>, since finding ids
restart at 1 each round too — and write that absolute path into the
refuter's prompt in its place; a refuter never derives its own path:

Try to REFUTE this finding. Default to refuted=true if uncertain. Verify by
RUNNING something — compile it, run the test, apply the mutation. Do not
reason your way to agreement. Observe that run synchronously — run the
command, wait for it, read its exit code. Never poll a log file for a
completion marker: prefer ONE blocking run to a poll loop, and treat its
return as permission to look, never as the answer. Reading a log the run has
already finished writing is fine; waiting on one is not. If you match a test
reporter's own output, accepting both ℹ and # is necessary but NOT
sufficient — strip SGR escapes first as well. node's prefix moves with the
node version and with whether stdout is a TTY, and color wraps the whole line
so it begins with ESC and no prefix anchor matches at all, which returns
empty at exit 0 — indistinguishable from a hung run and from a run of zero
tests. For an uncolored baseline use env -u FORCE_COLOR; FORCE_COLOR=
empty still enables color, so it is not a control. State your search scope
AND what your pattern would have missed. A grep over one ref does not
support a claim about history; a pattern built from the token a diff removed
does not support a claim that the category is empty. A failure injection with
no positive control has produced NO result, never a negative one. Before you
read an injected fault — an env var, an argv word, a mutant — as having had
no effect, prove the injection reached the child: one run whose output
differs with it present versus absent, or the child echoing the injected
value back. Uncontrolled, the cell is unrun — say so in your verdict instead
of reporting a no-effect result. Build such an invocation as an array
expanded braced and quoted —
cfg=(SETB=1 BADJ=1); env "${cfg[@]}" sh ./probe.sh — or inline the
assignments literally — env SETB=1 BADJ=1 sh ./probe.sh; NEVER from an
unquoted scalar — cfg="SETB=1 BADJ=1"; env $cfg sh ./probe.sh — which under
zsh passes ONE argument, sets a variable literally named SETB to
1 BADJ=1, never sets BADJ at all, and still exits 0. env $cfg[@] is not
the portable spelling either: measured, bash word-splits it into SETB=1
and BADJ=1[@], so the injection variable is set to a corrupted value,
while zsh behaves exactly as with the bare $cfg — BADJ never set,
exit 0. Everything you
write — mutants, fixtures,
scratch repos — goes under <scratch>/pr<N>/<finding>/ and nowhere else;
the checkout and any worktree are never write targets, though
git show/git archive at a pinned ref read fine anywhere. Chain the
directory change into the command, cd "$D" && git …, never
cd "$D"; git …, so a failed cd cannot leave a git command running in
the checkout — and bracket a fixture's own git with
git rev-parse --show-toplevel: before git init it must NOT resolve to
the repository, and a fresh scratch dir's fatal: not a git repository
(exit 128) is the pass, not a failure; before any git commit it must
resolve to your scratch path — compare resolved forms (realpath), since
--show-toplevel can report /private/tmp/… for a /tmp scratch dir on
macOS.

Survives → apply it, with one hold: if its refuter reports a blast radius
touching lines another finding also changes, that is the mutual-exclusion
scan's second input — rule the pair before either change reaches the tree;
you hold every finding, so only you can see that the two cannot both land. Refuted → defer and file it, and
say the refutation in the issue body. Apply only what survives — no report is not a survival. A
refuter you never hear from leaves the finding exactly as unchecked as it
arrived, so it defers like a refuted one.

A finding's suggested_fix is a hypothesis, not a patch — the review never
ran it.
Only the claim is verified adversarially; the remedy text is unchecked.
Three were wrong as written in one run: a stated end state that dropped a
load-bearing option and measured RED, an assertion against a property the test
helper does not expose (TypeError), and an enumeration that stopped short of
two further stale clauses — one of them false in the dangerous direction. Run
the remedy before you commit it, and re-derive an assertion from the post-fix
tree rather than from the finding, since a sibling finding may edit the very
text it asserts on.

Retrieve that report yourself; do not wait to be handed it — this covers the
refuters YOU dispatch, and only those.
The review's own specialists do leave
transcripts, but nothing gives you their address: the review had agent()
return their findings into the script, so the result file holds their findings
and names no transcript, and nothing on disk indexes a transcript by PR or
dimension. Hunting their paths gets you nothing; read the file. A report from a refuter you spawned is
different — it surfaces to the controller rather than to you, and waiting for a
relay that never comes strands the finding. Its transcript is at the output file
named in your spawn result, and its report is the last record:

A depth-2 helper cannot dispatch further (task.maxRecursionDepth: 2); reach it directly instead, by its full dotted id (<member>.<helper>), via hub send — delivered, woken, no transcript workaround needed.

If retrieval comes back empty, ask the controller by name. Only when neither
works is the finding unchecked — defer and file it, and say so in the body. This is
review-and-fix.md's Specialists rule; it reaches you here because the
steps that point at it are the ones you skip.

The CI facts in that file apply to you — a rebase-check red, or heavy jobs
skipped, solely off a non-zero behind-count, is staleness and not a
failure. Never rebase to clear it. solely is the load-bearing word:
ci.yml's rebase-check exits 1 on five conditions and only one is
staleness, so a non-zero behind-count does not by itself settle which fired.
The job log names the condition — and because the staleness condition exits
before the merge-commit condition is evaluated, the log is silent on that one
by construction, so measure it separately with
git fetch origin && git rev-list --merges --count "origin/<base>..HEAD" —
keep the fetch, or a stale local origin/<base> widens the range over the
base's own merge commits and reports a merge commit this branch never added.
A non-zero count does not send you to a local rebase either: the merge bot's
step-1 gh pr update-branch --rebase drops merge commits too
(run-merge-bot.md step 1), so both conditions clear on that one server-side
rebase. What it buys you is knowing the red was never solely staleness.

Run <testCmd> from the worktree before committing, copied verbatim.
tests 0 is a FAILED run, not a pass. Red or zero-test → fix it, or move that
finding to defer; never commit over it. Never --no-verify — a failing
hook is a finding you report, not an obstacle you route around. Report the test
result with your SHA. Nothing re-reviews this commit: the review ran against a
snapshot cut before your edits existed, and a refuter checked the finding's
claim, never your patch.

A test you ADD must kill its own mutant — break what it pins, confirm it and
only it goes red, restore. Then a change it should not catch, staying
green
— else you proved it fails, not that it discriminates: a pin asserting
whole-file text clears "it and only it goes red" and still reddens on any edit
(measured). A green suite says nothing about a new test: one pin this run
survived the exact mutation it was named for.

A mutation that never landed is not a green — it is a cell that did not
run.
Same rule for any fault you inject to see what breaks: prove the
injection reached the child before you read its result — one run whose output
differs with the injection present versus absent, or the child echoing the
injected value back. Without one, a probe that exits 0 reporting the mutant
behaving exactly like its baseline is indistinguishable from a real no-effect
result, and a reproduction that never happened reads as a refutation. Build
such an invocation as an array expanded braced and quoted —
cfg=(SETB=1 BADJ=1); env "${cfg[@]}" sh ./probe.sh — or inline the
assignments literally — env SETB=1 BADJ=1 sh ./probe.sh; NEVER from an
unquoted scalar — cfg="SETB=1 BADJ=1"; env $cfg sh ./probe.sh — which under
zsh passes ONE argument, sets a variable literally named SETB to 1 BADJ=1,
never sets BADJ at all, and still exits 0. env $cfg[@] is not the portable
spelling either: measured, bash word-splits it into SETB=1 and
BADJ=1[@], so the injection variable is set to a corrupted value, while
zsh behaves exactly as with the bare $cfg — BADJ never set, exit 0.

Commit BEFORE you mutate, and restore with cp, never a git discard.
git checkout -- <file> reverts the whole file, not your mutant — so it also
eats uncommitted edits you made earlier, and the "clean baseline" you measure
next is silently the reverted file. Two members hit this in one run; one lost
prose edits and caught it only because a non-catch control failed. Copy the
file aside and copy it back. Better still, mutate a copy under
<scratch>/pr<N>/mutate/ and leave the worktree untouched — that is what
refuters are already required to do, and it has no blast radius at all.

Never git stash or git stash pop here either — a WIP commit is the
substitute, not a second scratch mechanism: git commit -m wip, then
restore or amend once you're back to real work.
The stash stack is
repo-global too, not per-worktree or per-session — the same refs/stash
every concurrent member and the main checkout share, not partitioned the
way <scratch>/pr<N>/mutate/ above is. A bare pop takes whichever entry is
on top, from any worktree, silently — rc 0, no error — when the tree
receiving it is clean on the affected paths, and refuses only when that
tree is already dirty there. A bare git stash (push) does not reach into
a sibling's tree, so that half is not the risk — the pop is. Nothing
partitions the stack; this prohibition is the whole of the protection.

Report LAST, and only once nothing can still change. A report you have
sent pins that SHA for the controller, which dispatches a finisher against
it. If a further instruction arrives after you have reported, reply saying the
SHA is moving before you touch the tree again — do not silently do the work
and re-report. Measured: three members in one run sent a final report and kept
working; one had its worktree audited mid-mutation, another handed over a SHA
that was two commits stale, and a finisher dispatched on either would have
halted on a diverged head.

Before you report, write every ruling to <scratch>/dispositions-<pr>.json,
beside the review file — the record the controller checks with
dispositions-check.mjs before it dispatches any finisher. It is
{"head": "<the review file's head>", "entries": [ … ]}: one entry per
survived finding and per unverified finding, plus one per refuted
finding you reverse, its evidence in reason. Each entry carries bucket
(survived|unverified|refuted), index (its position in that bucket of the
review file), scope (in|out), claimKind (behavior|shape) and
disposition (apply|defer), and where they apply reason, issue (the
number you filed it to or commented on), verdictPath (an in-scope
suggestion's refuter verdict file) and remedyFiles (the files its remedy
names). A finding with no line, or on a line the PR's diff touched —
git diff from its merge-base with origin/main to the review's head — is
in scope whatever its scope says. An in-scope survived finding you defer
carries reason false-rationale, mutual-exclusion, remedy-worse or
remedy-outside-diff; the last needs remedyFiles to name a file the PR's
diff does not touch, and for a critical or important finding it sends the
PR to a human instead of passing. Any
other reason, none, or a survived or unverified finding with no entry is
a mismatch, and the PR gets no finisher.

Then report to the controller the pushed SHA, your apply/defer split, and
the deferral issue numbers — a filed: #N <subject> line for every issue
step 5 created, and unrecorded: #N <subject> for one whose ledger.mjs filed
failed twice — and exit. Deferring everything is a normal
outcome, not a stall:
nothing is then staged, git commit refuses an empty
index, git push origin HEAD:<branch> prints Everything up-to-date, and you
report no-op, HEAD unchanged at <sha> in place of a new SHA. Say it
explicitly — silence there is indistinguishable from a member that died. Never
manufacture a commit to make CI fire.

Put the standing CI facts in that prompt, not in per-event messages —
otherwise you send "your red is staleness, do not rebase" once per member per
merge. review-and-fix.md states them; the prompt only has to say they apply.

The fix-applier pushes and exits — it does not hold the CI wait. You own the
persistent Monitor; a turn-based member re-reading gh pr checks each idle cycle
rebuilds a 100k-token context for nothing the Monitor lacks. The prompt block
above carries the report-last rule; you do not have to restate it. When the
diff-validating check job is green and no
heavy job is in failure
(the heavy diff-validating suites — not the
rebase-check currency gate; a skipped heavy job is behind-count staleness and
fine — solely off that count, which is a condition to establish rather than
infer, see the five-condition note in the fix-applier block above) — or
ci-state.mjs reads verdict: "no-ci", see below — and once the PR's review
has returned and its fix-applier, if one was dispatched, has sent its report,
which the CI-completes edge above states in full
— dispatch a
finisher — a fresh small agent, not the fix-applier resumed. Record it with
ledger.mjs dispatch <pr#> finisher-pr-<pr#> before the call, then dispatch
it with the agent that command printed and omit model on the call
— the
tier lives in that definition's frontmatter, agents/fleet-finisher.agent.md,
as the @smol:low route, which resolves through the operator's modelRoles.
Its four duties are a checklist — audit the
worktree, confirm every deferral has a tracker home and re-run the acceptance
mutation, apply one release label, report — and the
merge gate downstream still catches whatever it gets wrong, the same argument
that puts the merge bot on the same tier below. Never dispatch
one while you still owe the fix-applier a ruling
: the pinned SHA is only as good
as the guarantee nothing else is inbound. The test is your OUTBOX, not the
member's last message
— anything you have decided that it has not received,
including a ruling you have since withdrawn or reversed. That is the test
of THIS condition only
: an empty outbox says nothing is inbound from you, and
nothing about whether the member considers itself finished, which is what the
report condition above is for. A withdrawal you
recorded only in the ledger is undelivered: the member still holds the original
and will act on it. Relay everything — reversals too — then dispatch.

That dispatch refuses a finisher — exit 2, nothing written — on a PR whose
latest reviewed= counts a survived or unverified finding, unless the
dispositions verdict answering that review's head is ok
: among the
dispositions-*=fix-pr-<M>[-x]:<head> tokens whose head matches it, the one
from the highest-suffixed fix-applier. A PR whose latest review counts
0/<n>/0 is not gated. The refusal names its cause. dispositions unchecked — no verdict answers that head: run dispositions-check.mjs for
the fix-applier that answered the review, as the Fix-applier report edge
states, then dispatch again. dispositions mismatch — no finisher: the tick
prints DISPATCH fix-pr PR#<M> for it, and the next-suffix fix-applier answers
it (Then dispatch a fix-applier). dispositions escalate — no finisher, no
retry and no further fix-applier: either a critical or important finding was
deferred remedy-outside-diff, or a second mismatch was drawn on one review, and
only a human can rule on either. Post the check's output, which names each
escalated finding or violating entry, with gh pr comment <M>, and flag the PR
for a human; re-running the check reprints it. Dispatch no fix-applier and no
finisher for that review's head, and expect no fix-due row for it; a new
review of the PR starts the check again. On any PR with a
returned review, 0/<n>/0 included, it refuses too while a fix-applier on the
PR is still live, verdict or not
—
fix-pr-<M>[-x] still live: settle it, run the check for it, then dispatch
again. That is what keeps a re-review at the same head from being answered by
the earlier round's verdict while the fix-applier answering the new round is
still working; once its check has run, its verdict, from the higher suffix, is
the current one.

An unanswered question from the member is an outbox item, and it blocks
dispatch with the same weight as a ruling you have already made.
It does not
feel like one — you have decided nothing yet, so the outbox reads empty — but a
member waiting on you is a member that will push once you answer, and the SHA
you are about to pin is exactly what that push moves. Measured on #488's run:
fix-pr-488 asked whether to add two pins, and the finisher was held until the
member acknowledged the answer. That worked because the controller chose to
hold, not because a rule required it. Answer it, or tell the member you are not
ruling and it should proceed on its own default — either empties the outbox.
What does not empty it is noticing the question and dispatching anyway.

A final report is not proof the member stopped. Observed twice: a ruling
relayed after dispatch produced a new commit mid-audit, and a fix-applier that
reported "nothing outstanding" resumed on a ledger-only withdrawal and was
cycling mutants through the worktree when the finisher audited it. Both finishers
correctly halted. Note what that costs to detect: two reads of the same worktree
a minute apart showed different mutants, so a member's report and any single
git status are each valid only at their instant. Its duties, in this order:

  1. Audit the worktree — run worktree-audit.sh (it takes no argument; it
    audits every worktree in one pass, so find this worktree's row in its
    output). Match the row by the resolved form of the worktree path you were
    dispatched with — realpath <abs-path>, or cd <abs-path> && pwd -P —
    never by that path as written: the row's worktree field is the physical
    path git worktree list reports, so a spelling reached through a symlink
    (macOS's /tmp and /var/folders are symlinks too) matches no row on a
    healthy worktree. Two checks, each with the instrument that can answer it.
    Dirty: the row's dirty field, or git status --porcelain -unormal
    inside the worktree itself — the explicit mode, never bare --porcelain, or
    status.showUntrackedFiles = no reads a dirty worktree as clean (#730). The
    row is the better read: in a worktree whose .git linkage is missing,
    git status answers for the enclosing repo at rc 0, where the audit reports
    the worktree unreadable. Diverged: the worktree's HEAD against your
    dispatch pin — the cause block's head-equality check below. Neither
    instrument above answers it: git status --porcelain reports no commits,
    and the row's ahead is counted against origin/main, which a healthy
    worktree on an open, unmerged PR is ahead of too, so it cannot answer this.
    Three row shapes halt too, each named in the report with the script's
    stderr line quoted where it prints one: readable:false with null counts
    (UNREADABLE: <wt> (<cause>)) is worktree unreadable, readable:false
    with zero counts (MISSING on disk: <wt>) is worktree missing, and no
    row for this worktree at all is worktree absent. git status is no
    substitute for the audit here — it answers the dirty check only, and a
    finisher that cannot see the tree it labels cannot vouch for it. Dirty,
    diverged, unreadable, missing or absent halts the finisher here, before
    the label: it reports what it found and labels nothing. A finisher that
    verifies the dirt is harmless and labels anyway has substituted the rule's
    purpose for the rule, and you find out at merge time. Give it the cause block below,
    verbatim
    — a bare SHA mismatch names no cause, and the halt report needs
    one. Give it <testCmd> too — the same string you passed the workflow
    and the fix-applier — because duty 2's mutation gate runs it and nothing
    else hands the finisher one; substituted with nothing it leaves that gate
    no command at all.

  2. Confirm every deferral — and every claimed APPLY — has a home that outlives
    the merge.
    The test is a durable home, not a tracker number: a tracker
    issue and a committed in-tree comment both qualify, and for a finding whose
    remedy was measured worse than the defect the in-tree note is the better one.
    What fails is a promise living only in a PR body, or on the PR's own source
    issue, which Closes #N buries on merge. Caught once at seven findings. A
    comment on an existing follow-up issue is filed: that is
    review-and-fix.md step 5, not a violation. Not filed → file it or halt,
    never label over it — and file it the way step 5 does, through
    ledger.mjs check "<subject>", never a bare search. The finisher files last,
    off its own read of what the reviewer left behind, so a deferral it reads as
    unfiled may already be on the tracker under someone else's wording. Then
    record it the way step 5 does: ledger.mjs filed <N> "<subject>"
    immediately after gh issue create, with the same subject string you passed
    to check. If filed fails, retry once; if it fails again, leave the issue
    alone — it exists and is correct — and report it unrecorded: #N <subject>.

    A fix-applier's "applied" is a claim like any other, and nothing else
    checks it — the review ran against a snapshot cut before those edits existed.
    Confirm git diff origin/main...HEAD actually contains what it reported
    applying. Two finishers in one run extended duty 2 this way unprompted, and
    one found five applies where the controller's hand-written list named three:
    a controller's list of what to verify is itself a claim.

    That range is right for the positive check and wrong for a negative one.
    It spans the implementer's commits too, so a constraint of the form file X
    was not touched
    has to be scoped to the apply commit. Measured on PR #776:
    a controller wrote "neither document was touched" against
    origin/main...HEAD on a two-commit branch whose documents the IMPLEMENTER
    had correctly edited — the PR's whole point — and a finisher reading that
    literally halts a correct PR. It caught the error instead, and said so; do
    not rely on that.

    Whatever you verify by RUNNING, run in a tree nobody else owns.
    Independently re-running the ticket's acceptance mutation — loosen the
    regex, delete the guard clause, flip the per-entry reset, run <testCmd>,
    watch it redden, discard — is the half of this duty reading a diff cannot
    do, and it stays. But it writes, and the PR's worktree belongs to
    another member. Measured on #380: a finisher was inside that worktree at the
    instant the fix-applier announced one more clause to land, and neither
    ordering is detectable afterwards from the diff — a restore lands on the
    uncommitted clause and destroys it silently, or the mutant is still in the
    tree when the fix-applier commits and a deliberately broken guard ships as
    the fix, in a commit nothing re-reviews. So take your own:
    git worktree add --detach <scratch>/pr<N>/finish-<your member name> <your dispatch pin>,
    mutate and run <testCmd> in there, then
    git worktree remove --force <scratch>/pr<N>/finish-<your member name>.
    At your dispatch pin, never at the fetched branch tip — duty 1 just
    proved the owned worktree sits at that pin, and PR headRefOid may carry
    commits no reviewer read, the substitution the halt block's head-equality
    check rejects for the same reason. The member name is in the path because
    the scratch root is shared with every sibling, so <scratch>/pr<N> alone
    collides whenever a PR gets a second finisher, and worktree add on an
    occupied path fails closed into a halt with a purely mechanical cause.

    Remove it on every path, the failing ones included — a mutant that comes
    back green, a suite that will not start, your own halt at any duty. --force
    is required and is right only here: the tree is deliberately dirty when you
    are done, so a plain git worktree remove refuses it (contains modified or untracked files, rc 128, directory left on disk — measured, git 2.50.1),
    while everywhere else in this runbook that refusal is a finding precisely
    because the dirt may be someone's only copy. Here nothing in it is anyone's.
    Nothing sweeps a leak for you either: reap.sh's branchless sweep is bounded
    to the fleet's worktree home, so a tree under <scratch> is a kept entry
    it reports and never collects. The tree you added is a checkout, so since #55
    it carries the tracked ./agent-test like any other — run either that or
    <testCmd> in it, per A reused worktree may lack the runner above.

    None of this licenses a write to the owned worktree. Duty 1 is a read;
    you never restore that tree, because you never wrote to it. A controller
    instruction to restore mutated paths there is an instruction to perform the
    silent-destruction ordering above — report it back, do not run it. One
    finisher refused exactly that instruction live, on the grounds it could not
    prove the diff was its own; that was judgement, and this is the rule.

  3. Add ready-to-merge — two steps, each checked on its own, in this order:

    • (a) Exactly one release label. Read the PR's labels back
      (gh pr view <pr> --json labels) and find exactly one release label,
      patch/minor/major. Zero or more than one halts the finisher before
      the label, naming which it found. This is the backstop for a label lost
      wherever it was lost, a hand-created PR included: a gh pr create that
      outran its caller's tool timeout leaves the PR open with the flag
      unapplied and no exit status anywhere to notice, and nothing downstream
      re-derives the release label. Count only those three — the PR carries
      other labels, ready-to-merge itself among them once you add it, so a PR
      wearing one release label beside them passes unchanged. A repo that
      defines none of the three does not gate on one — gh label list settles
      that, and it is no licence to skip the read where they exist.
    • (b) Add the label: gh pr edit <pr> --add-label ready-to-merge. (a)
      passing labels nothing — it is the precondition, and this command is the
      duty. Two finishers in one run (#2331) stopped at (a) and reported
      labelled on PRs that sat out of the merge queue.
  4. Report you the label (its post-add read-back: gh pr view <pr> --json labels
    run after 3(b), output as printed), the deferral issue numbers — a
    filed: #N <subject> line for every issue duty 2 created, and
    unrecorded: #N <subject> for one whose filed failed twice — and anything
    it halted on — cause and evidence, below, never a bare "head moved". The
    cause is one word of the ledger's halted:<cause> vocabulary:
    live-editor, rebase or past-pin from the cause block below,
    unreadable, missing or absent from duty 1's row shapes, and other
    for every other halt — the block's anything else, and a halt at duty 2,
    duty 3, the instrument re-check or the CI gate. A rebase halt's report
    carries the output of git cherry <pin> HEAD origin/main, run in the
    worktree.

Give the finisher the instrument re-check verbatim too. Duty 1 and duty 2
each run a script the digest covers — worktree-audit.sh and ledger.mjs,
both under plugin/scripts/ — and the label at duty 3 rests on what they
returned. The rule is stated once, at the top of this file, which no finisher
is dispatched to read, so it reaches one through this block or not at all:

Re-check the instrument set before you act on what duty 1 or duty 2 read,
and again before you add the label.

~/.fleet/bin/fleet-run instruments.sh --repo "$(dirname "$(env -u GIT_DIR -u GIT_WORK_TREE git rev-parse --git-common-dir)")".
Exit 0 is the only code that lets you go on. Exit 1 (the set changed) and
exit 2 (the check could not answer) both halt you: report what it printed,
add no label, and do NOT re-read the script that gave you the reading.
--repo is required and the spelling is the point — the run's baseline lives
in the audited checkout's gitignored .fleet/, which the scratch worktree
you take at duty 2 does not carry, so a bare invocation from there exits 2 on
a missing baseline instead of comparing anything. The env -u GIT_DIR -u GIT_WORK_TREE wrapper guards against an ambient GIT_DIR in your own shell
pointing this check at the wrong tree — the script's own internal unset
cannot reach back and fix a path you already resolved wrong. The baseline
itself names the tree to audit: a cross-workspace run pinned it once at
phase 0, and your --repo only locates that baseline, never the audited
tree — so an exit 2 naming a moved or unreadable audited tree is a halt
like every other, not a spelling for you to correct.

Give the finisher the test-run verdict rule verbatim too. Duty 2 runs
<testCmd> for the acceptance mutation, and the finisher's agent definition
says nothing about what a run that was cut short looks like. A node --test
stopped by an outside timeout still prints a summary, covering only the tests
it reached, so reading that summary alone reads a partial suite as a whole one:

A test run is a verdict only if it ran to completion — and you read that
off the test command's own exit status.

Redirect the run's output to a file in your scratch directory and read the
command's own $? straight after it, then read the file. In an eval cell
there is no $?: spawn <testCmd> with its output going to that file and
read the exit code and the signal off the spawn result — exitCode and
signalCode on Bun's spawnSync, returncode on Python's subprocess.run,
which is negative for a signal. Never read the status of
<testCmd> | tail or <testCmd> | grep: a pipeline's status is its
last stage's, so the filter's 0 hides the run's own exit, and the array that
holds the earlier stages' statuses is spelled differently per shell. A run is
a verdict only when that exit status is 0 and its summary shows tests
above 0 and cancelled 0. A summary line without that
is not a verdict, whatever pass count it prints.
A run completed when the command itself returned on its own — an exit
code, no signal, and no timeout, deadline or kill of yours having fired.
Judge that by how the run ended, never by its cancelled count: a failing
before hook or a per-test timeout cancels tests inside a run that
completed, and a SIGTERM'd node --test can exit 1 with cancelled above 0
in a run that did not.
Any run that did not complete is a killed run — 124 (timeout), 137
(SIGKILL), 143 (SIGTERM), a signal in the spawn result, an eval cell's
deadline — and read for green it is FAILED, the same bucket as
tests 0, however many tests had passed before the kill. Halt with cause
other and the exit code plus the summary line as evidence; label nothing.
The same holds for the mutation gate: a killed run is not a mutant that
reddened, and the files it cancelled are not failures — only a run that
completed can kill a mutant. A completed run that exits 1 is a plain failure,
not a killed run, whatever its cancelled count, and exit 0 with tests 0
stays FAILED.
Do not wrap <testCmd> in an external timeout. Run a long suite in one
eval cell with timeout: 0, as the review runner does, and wait for it.

Give the finisher duty 3 verbatim as well, as its two steps. The finisher's
agent definition carries no duty text, so this brief is the whole of duty 3 it
sees. Two finishers in one run (#2331) read the release-label check as the
whole duty and reported labelled on a PR that never got the label:

Duty 3 is two steps, and only the second one labels.
(a) gh pr view <M> --json labels — confirm exactly one of
patch/minor/major. Zero or more than one halts you before the label:
name which you found. A repo whose gh label list defines none of the three
does not gate on one.
(b) gh pr edit <M> --add-label ready-to-merge. Step (a) passing labels
nothing; this command is the duty.
Then run gh pr view <M> --json labels once more and put its output in your
report: that read-back is what tells the controller the label is on.

Once the label is on, take it off before you approve any push. The label is a
verdict on the tree the finisher read, and a GitHub label does not follow the
branch — on #180 it survived a push and came to sit on a commit nobody had
audited. Write label-off=<latest finisher attempt> onto the PR's row first
(ledger.mjs row, the row's whole text plus the token — Run ledger), then
remove ready-to-merge, then approve. The token is how the tick tells your
removal from a finisher that never labelled — a tick run between a removal and
its token re-dispatches a finisher into your take-off window. The label goes
before the approval because removing the artifact the merge bot gates on is
the reliable stop, where messaging the bot races it, and a label has been
observed holding until after an abort message arrived. Then
dispatch a fresh finisher against the new head; the first audit does not
transfer, since it verified a different tree. The merge bot refuses that head on
its own (run-merge-bot.md, The labelled head), so this is not the only
guard — but its refusal costs a pass and leaves the label lying, which is yours
to clear either way, the same way: label-off= first, then the removal.

A halt at step 1 reads identical from a bare SHA mismatch whatever caused it,
and the causes below are the ones seen so far, not a closed list. Give the
finisher this verbatim, so it derives the cause itself instead of asking anyone:

Worktree differs from your pin, or from what you last read. Check
head-equality first: worktree HEAD == the SHA you were dispatched against on
a clean tree settles it.
Against your dispatch pin, never against
PR headRefOid: a member that kept working and pushed after the pin matches
the branch tip on a clean tree, so a headRefOid comparison reports "settled"
over commits no reviewer read — the exact divergence this halt exists to
catch. headRefOid is a second, separate read (pushed vs unpushed), never the
equality the halt turns on. A rebase entry sitting at or behind the pin is
that head's provenance — the implementer's own pre-push replay — not
divergence from it, and needs no adjudication at all. Three finishers in one
run adjudicated a reflog this one check had already answered. Only when the
head differs from your pin, work out what happened. Each cause below is cheap
and self-checkable, and anything else is a cause too, not a gap:

  • Live editor. git status --porcelain -unormal is dirty. Sample
    git diff --stat twice, a minute apart — diffstat growing means someone
    is still writing. Halt, name live-editor, report both samples.
  • Rebase. git status --porcelain -unormal is clean, head still differs
    from your pin. git reflog in the worktree: a reset/rebase entry near
    the move, not a plain commit, means the branch replayed onto a new
    base — its own commits on a new parent, content-identical only on a
    conflict-free replay.
    Halt, name rebase, report the reflog line and the output of
    git cherry <pin> HEAD origin/main, run in the worktree.
  • Work past the pin. git status --porcelain -unormal is clean, head
    still differs from your pin, and git reflog in the worktree reads a plain
    commit at the move — no reset/rebase entry there, any of those sitting
    at or behind the pin as provenance. A member kept working and committed
    after you were dispatched. Measured 2026-08-28 on #983, where the finisher
    matched neither cause above and halted on its own judgement (#997).
    Halt, name past-pin, and report the commit, and whether it
    is pushed, unpushed, or unknown
    : git log -1 --format='%h %s' names it,
    and git ls-remote origin <branch> answers the rest — a non-zero exit or
    any other failed read is unknown, never folded into "unpushed": the
    same rule release-ticket.sh's own ls-remote names ("a failure here is
    an unknown answer, never a 'no'") and reaping.md's remote check shares
    ("its failure is an unknown answer, never a 'not pushed'"). Only a
    successful read settles pushed vs not: equal to that commit means pushed,
    and a fresh finisher can audit it; a successful read that comes back
    without it means the commit exists only in that worktree, where no
    reviewer can reach it. Ask the remote, not the PR object, whose head lags
    a ref move — the same field run-merge-bot.md refuses to poll, for that
    reason. The controller's next move differs between pushed, unpushed, and
    unknown, so a report naming only two of the three is not a report of this
    cause.
  • Anything else. Matching none of the above is not a licence to report
    the mismatch as unexplained — that report is the one this block exists to
    make unnecessary, and it is where labelling over a moved head starts
    looking reasonable. Halt, name other, and name what you did find: both
    git status --porcelain -unormal samples, the git reflog line at the
    move, and both SHAs. A cause nobody has named yet is still a cause you
    observed, and your report is what gets it named.

Every cause halts, always — named or not, you never verify the dirt is
harmless and label over it, a rebase is not a fast-forward you get to accept,
a commit past the pin is not one either, and a cause you could not name is
the least settled of the lot. Naming the cause makes the halt cheap to
resolve, never a reason to skip it.

Resolving a finisher halt. A halt is the finisher working correctly — it
refused to label — so it is never failed, which stays for a finisher that
crashed or gave up. Record it ledger.mjs settle finisher-pr-<M>=halted:<cause>,
<cause> the word the report names; the ledger refuses any other. Every halt
escalates two ways: the cockpit flags the PR halted:<cause> at severity 4 off
that token, and you post the finisher's halt report — cause and evidence — with
gh pr comment <M>, so it outlives the run. No label: the missing
ready-to-merge already keeps the PR out of the merge queue. Then the cause
decides the rest — two resolve automatically, every other one escalates:

Cause Resolution
live-editor Automatic. Ask the live member by name for its report, wait for it, then dispatch finisher-pr-<M>-b at the current head. If the head moved in the meantime, resolve it as past-pin instead.
past-pin Automatic, through the tick: re-review. The head carries commits no reviewer read. The tick prints DISPATCH review PR#<M> for a PR whose latest finisher is halted:past-pin and whose latest reviewed=<head> is not its current head, until a review of it is running or has returned; record that review's tokens as any review's, and its result reaches a fresh finisher through the same gate.
rebase Escalate — comment and flag, nothing more. Read the report's git cherry <pin> HEAD origin/main: only - lines is a clean rebase, and a + line is a conflict resolution that changed content or a commit past the pin. No automatic rule until a live halt shows the case recurring.
unreadable, missing, absent, other Escalate — comment and flag, no automatic retry.

past-pin needs the tick because the dead-review path cannot reach it: once a
review has returned, its reviewed= token holds the PR out of review-due=,
and settling that review =failed re-queues nothing — that path covers only a
review that died before returning. A head that moved past reviewed= with no
such halt — a fix-applier's push — stays not due: duty 2 verifies what it
applied.

Resolving a finisher that died. A finisher settled failed or killed left
its PR without ready-to-merge, and nothing else notices: the PR is out of the
merge queue, and the tick dispatches no finisher for it. The cockpit flags a
REVIEW-column open PR without the label, whose latest finisher attempt settled
so, finisher:failed or finisher:killed at severity 4 — one token per
outcome, neither the implementer's bare killed flag nor unlabelled. You
resolve it by hand: investigate why the finisher died, or dispatch the
next-suffix finisher after the finisher gate, ledger.mjs dispatch <M> finisher-pr-<M>-<x>. The flag clears when a later attempt settles — labelled
(then unlabelled takes over while the label is still off), halted:<cause>,
or a live -b — or when the PR carries ready-to-merge, merges or closes.
You never add the label yourself, and the tick does not retry.

Gate on the check job, not on ci-state --quiet exit 0: a behind PR never
reaches full green, so an exit-0 gate strands it unlabelled. The finisher reads
per-job state (ci-state.mjs without --quiet, or its jobs), since --quiet
drops jobs, missing and dropped. Normal path, not only kill-recovery.

ci-state.mjs reads verdict: "no-ci" — no check job exists in this repo
to gate on, and that is not a third way to skip the wait. The
--declare-no-ci declaration is caller-side, and reading it back out of the
payload verifies nothing
: the flag is echoed into reasons and flips the exit
code, so re-running with it and finding it there confirms only that you passed
it. Passing it yourself is not a second opinion. Check the two facts it stands
for instead:

  1. ci-state.mjs without the flag still reads verdict: "no-ci". A repo
    whose CI is merely misconfigured — workflow files present under other names,
    or .github/workflows/ not a directory — exits 2 there rather than reading no-ci,
    so this is the check that separates real absence from misconfiguration, the
    case that must never ship silently.
  2. The reviewer's own final verdict reports a green testCmd run on the SHA it
    pushed. review-and-fix.md step 3 requires it to, and that run is the only
    gate this repo actually has.

Both → add the label, and say it rests on the reviewer's suite run, not on CI.
Either missing → halt here, same as a dirty worktree: report no CI configured, no verified suite run on record and add no label.

Every implementer body carries the claim discipline, because tickets that
restate facts ship new wrong claims
— inherited from the ticket, and minted
in prose nobody asked for. It was measured on tickets that would have been
judged routine too: an unasked-for comment asserting a silent-failure mode
that does not exist, a false comparative plus positional references replacing
a name-based one, and two present-tense counts already false when they were
written.
Nothing before dispatch can see the diff, so the discipline goes to every
implementer. A pushed commit body cannot be edited, so its claims are settled
at write time. See references/correction-tickets.md.

The settling command for "which files read X" must never be a literal-path
grep.
The path is assembled in more than one spelling here, one of them behind
a local read helper, so a grep for the path string answers with the files that
merely name it — comments included — and misses the files that build it.
Replace the file with MUTATED in a throwaway copy, run the suite, and read off
which suites red: that answers which ones depend on its contents, which is
the question a coverage claim actually needs, and it returns a different set
from any spelling of the grep.

A settling command over literal text uses grep -F, and its zero counts only
after the same command finds a line known to match.
Prose fragments and
code snippets routinely carry \, ., *, [ or $, and without -F those
are read as a pattern rather than as the text itself, so the command answers
for a different string from the one the claim names — and a wrong zero is the
benign-looking answer. Measured on #1087: grep -c 'settled by:\s+x' answers
0 against a file holding that exact line, and grep -cF answers 1. A zero or
an absence counts only after the same command, with the same flags, finds a
line known to match: a command that cannot find a match settles nothing, and
under Verified: its zero reads as a checked absence.

Fallback: hand-dispatched reviewer member

Only where the review unit is unavailable or has failed — never a
preference. "Unavailable" is the review unit not being there to dispatch at
all; "failed" is the failure event above, surviving its one retry. A review that
is present and failing is not absent, and reading this line as absence-only
leaves the likeliest failure with no sanctioned path at all. One named member
per PR — review-pr-<pr#>, or review-pr-<pr#>-b where a runner already held
that name — never its implementer, recorded as
review=fallback:review-pr-<pr#>[-b] on the PR's row. It runs off your turn
like the review it replaces. Give it the PR number and tell it to read
$(~/.fleet/bin/fleet-run --root)/commands/review-and-fix.md — the resolved file
path, not a slash invocation; command availability inside a member is not
guaranteed the way skill availability is. Tell it to check every added
assertion against the tree, including comments in files the diff does not
touch, and to read each corrected sentence literally, clause by clause. It then
does the fix-applier's job too:
apply, defer, file, push, report, exit. On its report, record
reviewed=<head>:0/<refuted>/<deferred> against the head it pushed — it applied
its own survivors, so the zero is what tells the tick no fix-applier is owed —
and it reaches a finisher through the same gate as any other PR.

Authorize the fan-out explicitly. State that the full specialist set IS the
requested work — otherwise the reviewer inherits the standing "do not call the
AgentTool unless requested" and silently downgrades to a thinner solo review.
Nothing computes the count here, so apply the heuristic yourself: two or three
specialists for annotation-only or single-file, the full set for production code.
See references/member-lifecycle.md.

Delivering specialist reports is your duty on this path, not the reviewer's.
Reports surface to you and grandchildren are unaddressable, so a reviewer must
never be told to ping for one. But it can read the report itself: a specialist
it spawned writes its transcript to the output file named in its spawn result, and
tail -1 <file> | jq -r '.message.content[]?|select(.type=="text").text'
extracts the final report — bounded, unlike reading the whole file. State that in
the prompt: retrieve first, ask you by name only if the file yields nothing, and
rule a dimension unrun only when neither works — not silently, and not after
waiting forever, since a killed specialist never reports and never gets relayed.
See references/member-lifecycle.md.

So two obligations enter the event loop for as long as a hand-dispatched reviewer
is live:

  • A specialist report lands (a task-notification from a grandchild you never
    dispatched) → relay it to the reviewer that owns the PR — source named, text
    included.
    Relay anyway even though the reviewer can retrieve it itself — a
    duplicate costs nothing, a missed report costs a verdict. Telling it to ping the
    specialist does not work either — a depth-2 member cannot dispatch
    further, so the reviewer has no path to its own un-nameable children
    beyond reading their output file.
  • A reviewer's verdict claims a dimension went undelivered → reconcile it
    against your relay receipts before accepting it. Receipts say relayed → re-send
    naming the specialist and hold that verdict until it lands; a relay can arrive
    after a verdict is already composed. Only you hold the msg id, so only you can
    catch this — the reviewer cannot tell "no report exists" from "one was sent that
    I have not received". One reviewer ruled a relayed dimension "not covered by a
    specialist" and shipped a headline claim that report refuted.

Neither belongs in a workflow-path prompt: there agent() returns into the
script, no relay ever occurs, and a rule to wait for one strands every finding.

Same receipts gate the finisher dispatch — dispatch only once every
specialist report you know landed for this PR is relayed, none unrelayed. The
reviewer applies findings and pushes as each relay lands, so an
unrelayed report is exactly as inbound as a fix-applier ruling you haven't sent
(above) — dispatch past it and you pin a SHA the reviewer is about to move past.
Necessary, not sufficient — a reviewer re-reading its own inbox late still moves
the head, which is what the halt block's cause list above is for.
Vacuous on the workflow path: agent() already returned every report before
you had a tree to dispatch a finisher against, so nothing is ever outstanding
there.

Merge bot

Per pass, named merge-bot-<n>, never two at once. ledger.mjs dispatch merge-bot names it before the dispatch call — n is 1 + the merge-bot-
entries already in the ledger's ## Dispatched list, so it restarts at 1 every
run, a replacement for a dead bot takes the next n, and nobody counts. Dispatch
it on the tick's DISPATCH merge-bot: the first ready-to-merge label is
enough, and it never waits for the finisher's report. Tell it to read
$(~/.fleet/bin/fleet-run --root)/commands/run-merge-bot.md and run one
pass — drain the queue, re-evaluating after each merge, hold a 15-minute grace
for late labels, then report once and exit — and say that you dispatched it,
which is what makes it hold that grace instead of arming its own watcher. Dispatch
it with the agent that ledger.mjs dispatch merge-bot printed and omit
model on the call
— the tier lives in that definition's frontmatter,
agents/fleet-merge-bot.agent.md, as the @smol:low route, which resolves
through the operator's modelRoles.

Rebase, wait for green, check the
label, merge is checklist work, and a bad merge still needs the label and the
per-job CI state to have been read correctly, which is exactly what a wrong
merge later surfaces and a human resolves; no-undo-audit.sh is the
deterministic, script-driven backstop run before every push specifically to
catch a wrongly-resolved conflict, so the tier buys nothing that backstop
doesn't already cover.

Put the shell traps in the bot's brief, not in a follow-up message. A bot
already looping cannot be corrected — the loop consumes the turns a correction
would land in. Every entry in Shell traps, verbatim — that section is where
they are stated once, and this is the seat that has to paste them: the bot
reads an exit code off gh pr update-branch, merge-gate.mjs and
prove-merge.sh, which is the PIPESTATUS and read-only-status pair, and its
grace poll captures gh pr list output and loops over it, which is the
assigned-variable trap. The brief always travels with the dispatch.

Its merge gate is one script, run twice. Step 3 of run-merge-bot.md is
merge-gate.mjs --pr <n> --pre <sha> [--post <sha>]: read-only, it runs the
instrument re-check, the label and review-decision read and a run-bound
ci-state, and answers their conjunction — exit 0 mergeable, 1 blocked (the
bot reports <reason>-#<pr>), 2 could not evaluate. The bot runs it once
before any CI wait and again immediately before gh pr merge, and merges only
on that second exit 0. Nothing but the gate reads ci-state on the merge path,
so a bot's report names the gate's reason, never a field it read by hand.

You own the watcher, not the bot. A dying member takes a watcher down with it
and the queue stops silently. Arm one yourself, persistent: true, seeded before
the loop so handled PRs do not re-fire.

Every merge invalidates every other open PR, silently. The rest show stale
green
until something re-triggers, then flip to rebase-check: FAILURE with the
slow jobs skipped. Behind-count is the only honest signal.
See references/ci-and-staleness.md.

One rebase per PR, at merge time. The bot establishes currency once, when the
PR is the merge candidate; requiring it earlier costs a full CI cycle per sibling
merge. Any behind-count you hand a bot is expired on arrival; say so. See
references/ci-and-staleness.md.

run-merge-bot.md carries the mechanics — intra-pass re-checks, run-binding, the
ancestry proof, post-rebase red triage. Do not restate them here.

Reap after each merge pass

The merge bot deletes the remote branch after each merge (delete-merged-branch.sh,
in run-merge-bot.md step 4), leaving the local branch [gone] with its
worktree — and its node_modules — still on disk. Reap after each merge pass,
not once at the end: a stale worktree still answers git worktree list, so the
in-flight probe (inflight.sh, run by the Shortlist and by every Pull) reads an
already-merged ticket as taken and the queue quietly shrinks. The trigger is the
bot's one exit report, and it reaps every time — a pass that merged nothing
included (phase 3, Merge-bot pass reports done). See references/reaping.md.

~/.fleet/bin/fleet-run reap.sh --apply recomputes every precondition
inside the same invocation as the delete — for-each-ref for [gone], git cherry origin/main to authorize -D, worktree removal without --force — and
reports reaped and kept-with-reason counts. Update the reaped tickets' ledger rows
in the same step. See references/reaping.md.

It sweeps detached worktrees too — the shape a [gone] walk structurally
cannot see.
Bounded to .worktrees/, so a checkout of yours outside it is
reported, never removed. Read worktreesRemoved alongside reaped: a pass that
reaps branches and removes no worktrees is a finding, not a quiet success.
See references/reaping.md.

Do not invoke commit-commands:clean_gone. It runs git branch -D and
git worktree remove --force with no merged check at all — nothing there stops it
deleting a branch, or discarding a member's uncommitted work, that exists nowhere
else. The skill is the maintainer's to fix; do the reap yourself.
See references/reaping.md.

Never reap a branch a live member is on. Cross-check .fleet/ledger.md first:
a row without a terminal state means someone may still be in that worktree, and
the dirty check does not see a member that committed but has not pushed.
See references/reaping.md.

Release the claims that never became PRs — at end of run, and on drain

Phase 1 is serial and claims ahead of dispatch, so a ticket can be legitimately
claimed and then never sent: a collision surfaces after the claim, or the
maintainer says drain. The claim still holds the in-progress
label, a worktree and a branch, and reap.sh will not take them — the branch
is not [gone] and has no unique commits, so it is correctly not reapable. Reaping
fires after each merge pass and nothing fires at end of run, so the claim survives
it and the in-flight probe reads a free ticket as taken next run. Same silent queue
shrink as a stale merged worktree, from the opposite end.

~/.fleet/bin/fleet-run release-ticket.sh <N> impl-<N> implementer --apply —
the pair the claim took, so the two name the same branch and worktree — is the
inverse of the claim, and recomputes its preconditions inside the same
invocation as the delete: 0 commits ahead of origin/main, no unique commits
(git cherry), no branch on origin, and — whenever the worktree directory is
there to read — a clean worktree. Where that directory is established absent the
dirty check does not run at all, and git worktree remove is no backstop for it:
it gates its own clean check on the same stat, so the script's absence
measurement is sole arbiter there. Two of the four are recomputed a second time
at the delete itself: the dirty check by git, which is what worktree remove
without --force is; and the ahead count, re-run against origin/main
immediately before the branch delete, because a commit can land across the
gh issue view between the checks and the delete. The git cherry and
pushed-branch checks are not re-run there — a commit landing in that window
is ahead of origin/main by construction, so the recount covers it. All clear →
drops the label, removes the worktree without --force, deletes the branch with
-D, authorized by those two commit checks plus the recount and by nothing else
(-d measures against local HEAD, and a claim has no upstream, so it refuses a
pristine claim whenever local main is behind origin/main — #760). Any one of
them failing → it touches nothing and names the blocker. That refusal is the
finding, never an obstacle
: a claim carrying commits or a pushed branch is not
auto-released, ever — run worktree-audit.sh (it audits every worktree; find
this claim's row in the output) and decide by hand.

Run it over every claimed ticket with no PR when the run ends or the maintainer
drains, and on the spot for a claim abandoned mid-run (a bail before
implementing, a collision found after the claim). Update the released tickets'
ledger rows in the same step. See references/reaping.md.

Worktree and claim model

Ruled on #1315, measured 2026-09-09 in a throwaway clone (/tmp/fleet-probe)
that never touched the real checkout.

Claim, release and reap are unchanged on omp. claim-ticket.sh,
release-ticket.sh, reap.sh and inflight.sh are git-native and operate on
the single shared ref store every worktree and the main checkout read and
write directly (Phase 1, Reap after each merge pass, Release the claims
that never became PRs
above); nothing in omp's task model touches that
store, so none of the four scripts carries a dialect branch.

A fleet member is never dispatched with omp's isolated: true. Measured:
an isolated spawn builds its workspace under ~/.omp/wt/<hash>/m — an APFS
clone (on this box), never a registered git worktree of the repo — and,
because apply defaults to true, its changes are patch-applied into the
calling session's own cwd the instant the spawn completes: a file an
isolated probe created there appeared as ?? probe-isolated.txt in the probe
session's own cwd — which for an actual fleet run is the controller's main
checkout — not in the worktree the prompt named. That is the exact
silent-spill hazard this section exists to guard against, on by default, so
isolated stays unused for every member and task.isolation.enabled stays
off.

A member's tree is the claimed worktree, addressed by absolute path. An
edit header without the worktree prefix resolves into the main checkout,
where member-write-guard refuses it (You read your instruments out of a
tree every member can write to
above). How a
member gets its cwd: the task tool's item schema — name/agent/task/
outputSchema/schemaMode always, effort/isolated only when their own
settings enable them — carries no working-directory field; measured
directly: a member dispatched through it with none of those fields, told
only to report pwd, returned the calling session's own cwd, not the
claimed worktree, the same output an un-cwd-set bash call gives from
that session, so the recipe is the dispatch-prompt absolute path, plus
bash's own cwd parameter set to it on every call and absolute paths for
write/edit.

The one open gap this ticket measured: task does not accept a per-dispatch
working directory.
omp://tools/task.md documents non-isolated spawns as
running runSubprocess(...) "directly with parent cwd" — no cwd field
appears anywhere in the item schema — and the probe above confirms it in
practice: the recipe is the absolute-path discipline above, not a placeholder
for a cwd field that does not exist. The gap is now enforced rather than only
written down: the plugin's member-write-guard omp extension (ADR 0020)
refuses a fleet member's write/edit/ast_edit into the main checkout's
non-ignored tree and an implementer's bash from a main-checkout cwd, and the
missing field itself is requested upstream as can1357/oh-my-pi#13754.

The same gap reaches past the filesystem: task-dispatched members share one
eval kernel.
Measured 2026-09-21 on #1447, in a live run rather than a
probe clone: two sibling implementers dispatched in the same batch, and the
controller that dispatched both, reported the same Python kernel pid and the
same runner file under $TMPDIR/omp-python-runner/, and each read the others'
top-level bindings out of its own eval cell — a bare WT bound by one member
was read back by a sibling and by the controller, the collision #1447 was filed
on as an unproven self-report. omp://tools/eval.md states the mechanism:
retained kernels are keyed by python:${sessionId}, normalized cwd and
interpreter — js:${sessionId} for the JS VM — and "Parent and ordinary task
subagents may share an inherited eval executor id; children created by eval's
own agent() explicitly do not." Neither key component separates two members
of one run: the session id is inherited from the controller, and the cwd is the
main checkout for all of them, because eval, unlike bash, takes no cwd
parameter to set. So the absolute-path discipline above is load-bearing inside
eval too, and it needs one rule write/edit do not — member-prefixed
binding names, since a shared global has no path to be absolute about. The
corollary is reset: it "is likewise destructive to concurrent work sharing
that backend session", so a member resetting its own kernel resets every
concurrent sibling's. No per-dispatch knob turns any of this off: task's item
shape carries no kernel, executor or cwd field, and python.kernelMode set to
"per-call" — the one documented lever that would hand every call a fresh
kernel — is a session setting a child inherits, not something one dispatch can
set for one member. Isolating a member's kernel is an omp-side change, so
discipline is the whole of the remedy here — unlike the missing cwd field
above, which member-write-guard enforces for write/edit/ast_edit and
eval bypasses. Phase 2 carries all three rules to every member.

release-ticket.sh is not a no-op on omp, and inflight.sh's probes are
unaffected by a running member.
Measured against a hand-built claim (a
branch plus a git worktree add) with a member sleeping inside it: a bash
call with cwd set to the worktree, and a non-isolated task spawn told to
work there, both operate inside the existing worktree and leave git worktree list unchanged — they coexist with the claim rather than duplicating it. An
isolated spawn is architecturally blind to the worktree — its relative writes
land in its own ~/.omp/wt/… workspace regardless of the prompt — but it
neither removes nor hides the claim, and ~/.omp/wt/ was empty after every
isolated run: omp's own teardown touches only omp's own workspace, never the
claim. release-ticket.sh's three-artifact teardown (label, worktree, branch)
is therefore still necessary. With the member live inside
the worktree, git worktree list and git branch --list from the main
checkout reported the claim unchanged before, during and after — inflight.sh
hides nothing from omp.

The controller's own cwd stays the main checkout, unchanged. Nothing in
this section's dispatch recipe — the cwd recipe above, the isolated-workspace
measurement, or the teardown scoping — moves
where the controller itself runs from.

Queue depth

  • shortlist — .fleet/shortlist.json's entries with no impl- row and no
    live excluded row: admissible, not yet pulled. The tick's unclaimed=.
  • supply — scanned, the open ready-for-agent tickets shortlist.mjs's
    candidate scan returned, before any filter. Only a Pull's full read judges
    decided?, so a shortlist of undecided tickets is supply that every Pull
    relabels away — and relabelling is what stops it being counted next scan.
  • review backlog — PRs owed a review. fleet-tick.mjs counts every
    open PR without ready-to-merge that closes an issue and carries no review=
    token on the ledger — one settled =failed is owed a review again, and so is
    one whose latest finisher halted past-pin past its latest reviewed= head
    (Resolving a finisher halt) — and it
    prints them as review-due=. Exact, not a wider read: the ledger records
    which PRs are under review or reviewed, so nothing about it is guesswork. The
    closing-issue test is what keeps it from widening: a chore PR you author
    yourself closes nothing and is left unlabelled for the maintainer, so no
    member of the fleet will ever review it, and counting it floors the backlog at
    a depth nothing in the run can drain — the implementer gate then holds for the
    rest of the run against a queue of nothing (#590). GitHub's own linked-issue
    set decides that, not a keyword regex over the body; see
    docs/agents/issue-tracker.md. That exemption is for the chore PR THIS run
    authors, never one a PRIOR run left open
    — step 0 folds those into this
    run's review queue, so count one when you reason about backlog BY HAND. The
    fleet-tick.mjs number still excludes it
    : nothing in its filter
    distinguishes an inherited chore PR from this run's own, since a PR closing no
    issue fails the closing-issue test either way — folding one in cannot reach
    the count. So the by-hand judgement and the script's output differ here, and
    such a PR's review is dispatched by hand.

Defaults: 2 implementers, 6 reviewers — no hard cap on either. Reviewer
slots in use ≈ I·(T_review + 0.94·T_fix)/T_impl ≈ 1.95·I (spec 2026-09-24 § 3
§3, medians since 2026-08-07: T_impl 19.3 min, T_fix 21.0 min, T_review ~18
min, 0.94 fix-appliers per review), so at the default about four of the six
are busy, and an idle slot costs nothing. Re-derive it from the map's guard
once 20 or more PRs have merged under it, never from one run's rows.

The review-backlog gate is narrowed, and the merge-queue depth never gates.
More PRs into a review-bound pipeline buy nothing, but the pipeline is
review-bound only when a PR is still owed its review after the tick's own
dispatches AND no reviewer slot is left for it — HOLD (review side saturated). A deep backlog with free slots is a DISPATCH review on the same
tick, never a reason to idle an implementer. A deep ready-to-merge queue is
not that signal at all: each PR rebases exactly once, when it becomes the
candidate, so producing more PRs adds no rebases-per-PR — it changes only
when a given PR is ready.

Reconcile, do not wait for an event. "A slot is free and the shortlist is
non-empty" is a level condition — re-derive the deficit on every tick,
whatever woke you: a member finishing, a member bailing before implementing, a
merge landing. An edge-triggered loop that only Pulls on completion stalls
silently the moment the shortlist empties, because 0 implementers emit no
completion event. With the shortlist empty the table below governs — refresh,
and never Pull a ticket the shortlist does not hold.

Do not re-derive this by hand — fleet-tick.mjs computes it. One invocation
prints per-role actual/target and an explicit ACTION with the backlog gate
above and every row of the table below already applied; every wake in Phase 3
ends in it. The deficit is then computed, not remembered, which is
the whole point: a table you must remember to consult is one you will not
consult under a merge-side event storm, and that is how implementers reached
0/target with one approved ticket waiting and ~57 ready-for-agent in supply while nobody
noticed (#3). What follows stays here as the explanation of what the script decides —
never as a second, hand-run copy of it.

shortlist (unclaimed) implementer row prints
≥ 1 PULL #N …, one head per free slot — a Pull each (phase 1)
below the cap, missing or empty the tick refreshes first — REFRESHED shortlist: n entries; k lifted — and Pulls from what it found
0 after that refresh SUGGEST /triage, hold idle

Four holds outrank every row: HOLD (draining) once ledger.mjs drain has
recorded the drain, HOLD (tier mismatch impl-<N>) until a replacement is
dispatched at the right tier, HOLD (tier unchecked impl-<N>) until
tier-check.mjs has written its verdict on the newest implementer —
tier-ok=, tier-mismatch= or tier-unverifiable= — and
HOLD (review side saturated) when a PR is still owed its review and no
reviewer slot is left for it — refreshing to enable a Pull you are holding
buys nothing.

A main checkout that is not clean against its Phase 0 baseline outranks all
of them, and every dispatching row besides: HOLD (main checkout dirty),
HOLD (main checkout unknown) or HOLD (main checkout no baseline) on the
implementer, reviewer and merge-bot rows alike, beside the MAIN-CHECKOUT-*
line that says why (#2210).

/triage is user-invoked only — suggest, never run. The suggestion is a report,
not a blocking prompt. Counts come from cheap gh issue list --search, no bodies.
A starved implementer queue never stalls the review or merge side.

Invariants

  • Implementers and review units at their caps (default 2 and 6, no hard cap on
    either), ≤ 1 merge bot. Caps bound members and review units, not live
    agents — members fan out and their children consume slots you never dispatched.
  • A grandchild surfaces as its own task-notification. An unrecognized task-id is
    not a member reporting done.
  • A reviewer never reviews a PR it implemented.
  • The merge bot only touches PRs whose implementer reported done. Rebasing a
    worktree someone is working in destroys uncommitted work.
  • ready-to-merge is added by a reviewer only — never an implementer, never you.
    The run's own artifact PR is exempt, and it is exempt because this invariant
    does not reach it:
    what the line forbids is a fleet member signing off its own
    ticket work, which presupposes an issue, an implementer, a review-pr run and a
    finisher, and the data-only close-out PR you author has none of them. Label and
    merge that one yourself instead of re-deriving a blocker from this line and
    stranding the rows behind it. The exemption belongs to the run that authors the
    PR and does not outlive it: one still open when the run ends is a PR a prior run
    left open, which phase 0 folds into the next run's review queue as ticket work —
    and a chore PR is not exempt from being wrong, only from waiting on a reviewer
    no phase of this run will ever dispatch against it.
  • Every member acts through the maintainer's gh credentials, so no write is
    attributable.
    Any invariant about who did something is unenforceable after
    the fact: when a label moves unexpectedly, ask members directly, rule out
    automation with grep -rn '<label>' .github/workflows/, and do not re-add it.
  • Phase 1 is serial. Everything else may run concurrently.

Guards

Verify every reported SHA. ~/.fleet/bin/fleet-run verify-sha.sh <branch> <sha> before enqueueing — a member can commit in a nested worktree,
leaving the SHA on a stray branch while its report reads normally. Exit codes as
inflight.sh: # 0 reachable, 1 not reachable, 2 unanswerable. Not reachable
(exit 1) → flag, do not enqueue, do not return the ticket to the shortlist until the
maintainer rules. Exit 2 is not a verdict — the question could not be
answered, and every cause of it reaches you as one shape: no JSON on stdout at
all, the cause on stderr alone. Read that stderr, fix what it names and re-run;
escalate if it repeats. Never read exit 2 as not reachable, and never record it
as a stray commit — the flag and the maintainer's ruling above are what exit 1
earns, and spending them on a probe that never answered costs a member its
ticket over a failure a re-run would have cleared.

Never --delete-branch — the merge bot deletes the head branch itself.
gh pr merge <n> --merge alone: the flag's local cleanup errors on a main
held by another worktree, or strands the feature worktree on main. After the
merge, delete-merged-branch.sh <pr> deletes the branch from origin and reads
the deletion back; reap.sh takes the [gone] local branch and its worktree.
No repo setting is relied on to do it (#2196). A branch another open PR still
uses as its head or base is kept instead — branch-kept-#<that PR> in the bot's
report, a deliberate keep and not a failure (#2295).

Never force a rebase to start. No git clean, git checkout .,
git reset --hard, git stash. Uncommitted changes may exist nowhere else.
Non-empty git status --porcelain -unormal → stop and report — the explicit
mode, never bare --porcelain, or status.showUntrackedFiles = no reads an
empty answer here and licenses the rebase over work this check exists to
protect (#730). Stashing empties it either way, so that check and
no-undo-audit.sh both go quiet on work nothing else holds.

Cross-check what members report about their environment. Wrong often enough
to matter, and a confident wrong report from a reviewer flips a verdict.

Distrust negative claims hardest. "Nothing else references this", "the sweep
is clean" — most likely false, least likely checked: a grep that found nothing
looks like a grep never run. Make members state their search scope. Negative claim
vs specific finding with paths → paths win.

Members share one filesystem, one docker stack and one process table, and the
failures arrive as wrong findings, not errors:

  • A pattern kill reaches siblings — say so in the dispatch prompt. pkill -f <pattern> matches on the whole command line, machine-wide. Members hold
    separate worktrees, but the process table is not partitioned by them and a
    test file's name is identical in every one, so a pattern naming a test file
    matches whichever member happens to be running it. Measured: an implementer
    chasing its own stalled run issued pkill -f "claim-ticket.test.mjs" and
    killed a node --test claim-ticket.test.mjs inflight.test.mjs that was not
    its own.

    What the victim's output looks like depends on the signal — and one of the
    cases is a clean green.
    Each measured on node v26.8.1/darwin against a
    purpose-spawned fixture killed by its own PID. pkill -f defaults to
    SIGTERM
    , and a SIGTERMed runner prints Interrupted while running: followed
    by a complete summary block with cancelled non-zero, exiting rc=1 — so
    cancelled is exactly the thing to search for in the default case. pkill -9 -f leaves no summary block at all and the shell reports rc=137: SIGKILL
    means the harness never lived long enough to report. A kill landing on one of
    node's per-file child test processes leaves a complete block with fail
    non-zero and cancelled 0 at rc=1, indistinguishable from an ordinary test
    failure. And a kill landing on a process a test itself spawns leaves a
    complete pass N / fail 0 / cancelled 0 at the expected N over a corrupted
    run: spawnSync reports a SIGKILLed child as status null, and a test
    asserting only that the process it spawned exited non-zero —
    assert.notEqual(r.status, 0), a form used across this suite, in files that
    spawn the shell scripts they cover — passes vacuously on null.

    So a complete summary block proves the runner survived, not that the run was
    uncontaminated.
    Scrutinize reds, short counts and greens from inside the
    incident window; a killed baseline compared against a clean mutant is a
    wrong measurement that reads exactly like a real result.

    The command line is no partition either — not reliably. Ports are derived
    per issue precisely so collisions are impossible; argv isolates a run only
    sometimes. Measured with ps -Ao pid,ppid,args -ww (without -ww macOS ps
    truncates, and the truncation reads as an absence): an invocation carrying an
    absolute path puts a member-unique string in argv, while one carrying
    repo-relative filenames is byte-identical across members. Which of the two you
    get is not yours to choose — members are briefed on ./agent-test <file-or-dir> over the suite under plugin/scripts/, and that runner execs
    node with the file list its caller's shell already expanded, never a literal
    glob. Clearing your own leftover process needs its PID, not a pattern.

    Members report a stalled run to the controller instead of pattern-killing it,
    and the controller re-checks any measurement taken in the window.

    Do not guess the victim — the blast radius is the machine, not the Pull.
    In that incident the controller reasoned from dispatch scope to "almost
    certainly impl-<N>" and told the maintainer so; the named member then proved
    it was not the victim (it had never invoked those files standalone, and its
    mutation baselines post-dated the window). Idle members of previous runs,
    other omp sessions on the same machine, and the maintainer's own shell are
    all pkill -f targets and none of them appear in your ledger. Warn every live
    member, record the incident as unattributed, and re-check measurements by
    timestamp rather than by who you think was running.

  • A warning acted on silently is indistinguishable from one that never
    arrived.
    The same incident: the controller's warning DID reach the member,
    which re-ran, found nothing changed, and said nothing — correctly reading
    "send once, never re-send" as covering it. The controller then had to spend a
    round trip asking whether its own outbox was lossy. So say in the dispatch
    prompt that confirmation state includes warnings received and acted on, even
    when the outcome is no change
    — one line, in the report the member already
    owes. That is the cheap half of the never read silence as assent rule, paid
    by the member rather than by the controller's guesswork.

  • Specialist tree isolation is review-and-fix.md's job — do not restate it.
    It owns the object-store rule, the two-tree split, snapshot provisioning, and
    which suites a snapshot cannot validly run. Your only duty is to not
    contradict it.

  • Filesystem isolation is not stack isolation. A private copy of the tree and
    a test command solve different problems; the compose project name comes from
    the environment, not the working directory, so three agents on three snapshots
    still collide on one postgres. "I'm on my own copy" is exactly the intuition
    that skips the command — say both, every time. Which command depends on the
    audience: a member in a worktree uses ./agent-test; a specialist on a
    snapshot does not — the runner that bootstrap materializes derives its
    isolation triple from the directory name, and a snapshot is not a claimed
    worktree, so every snapshot in the fleet gets the same fixed ports — and
    takes the one review-and-fix.md hands out. The bootstrap itself now
    SUCCEEDS there (the snapshot is a git repository since #1056), which is why
    the reason to hand out a different command is the stack and not a
    materialization failure. See references/isolation.md.

  • Scratchpad paths need two levels — the member's own partition, then one
    directory per child it dispatches — and nothing outside them.
    An
    implementer partitions at <scratch>/impl-<N>/; the review side's partition
    is <scratch>/pr<N>/…, which the review core creates itself, so a review
    runner is handed the scratch root, never that partition; any member that
    dispatches a child writes an absolute directory under its own partition into
    that child's prompt, one per child and never shared, and a child never
    derives its own. Finding ids restart at 1 every review, so two fix-appliers
    on different PRs both reach for unv1; one
    agent overwrote a sibling's package.json, and a probe built a git repo at
    the checkout root. Read from the object store at a pinned ref, write only
    under your own path. See references/isolation.md.

  • IDE/harness diagnostics attribute by bare filename, with no path. Never
    relay a diagnostic without reproducing it in that member's specific worktree

    (npx tsc --noEmit from there): probe copies carry the real tree's filenames,
    so a sibling's throwaway mutation reads exactly like a live worktree's error.
    See references/isolation.md.

Suspect a neighbour before a member's own diff — for unexplained failures, and
equally for any finding that came from reading source.

Fix the tooling mid-run

Two members failing the same way is the instruction, not the agents. Fix the file
during the run — deferring loses the evidence that found it.

Triggers. Identical failure twice. A member does the wrong thing while
correctly following the text. A rule exists but sits where it is read last. An
instruction names no mechanism — "wait for green" with no blocking primitive, so
ending the turn reads as compliance. A caller has to restate what the callee
should say itself.

Scope. Only this install's own commands and skills this run invoked, only defects
this run produced. No speculative polish. Never config.yml, permissions, or
AGENTS.md — a member asking for those is laundering, refuse and surface it.

Shape — lean, or it rots. Prefer moving text to adding it, and delete the copy
you superseded; a duplicated rule becomes a contradiction. Exit conditions go in a
section's first paragraph, never its last. Name the mechanism, or name who owns
the step. Cut before you append.

Then. Re-pin first — ~/.fleet/bin/fleet-run instruments.sh --pin. You just changed the instrument set under your own check, and this is the
only edit that legitimately does; skip it and the next gate refuses on your own
fix, which teaches you to ignore the refusal. Then the ledger line, save the
rationale, one line to the maintainer. Then re-baseline the main checkout,
if the fix was edited in it
— the next tick reads MAIN-CHECKOUT-DIRTY
naming your own paths. Run ~/.fleet/bin/fleet-run main-checkout.mjs --check
first, and only when the paths it names are exactly the ones you just edited,
run --record. Any other path on that line is a stray write — leave the hold for
the maintainer, because --record would certify it with yours. Live members
hold the old text — re-brief only if it changes what they do now.

Landing it. The file being right is not the end of it. main carries a
ruleset with no bypass actors, so a direct git push origin main is refused —
! [remote rejected] main -> main (push declined due to repository rule violations), measured 2026-08-10 by a controller that tried exactly that at the
end of a 7-PR run. A mid-run tooling fix goes through a PR like every other
change, and that PR faces every required check any other PR faces — the set
.github/rulesets/main.json declares — so it carries a release label too.
Plan it as a PR the run's own merge queue lands, never as an edit-and-continue.
ADR 0007 records the gate and why it has no exemption.

Failure handling

Failure Response
SHA not on expected branch Flag, do not enqueue, report
Implementer bails before implementing → needs-triage if under-specified, ready-for-human if it needs human hands or leaves a fork open; drop in-progress, comment the cause, release the claim, refill (phase 3)
Implementer blocked or ambiguous mid-implementation Free the slot, leave in-progress, report — the row above is the pre-code bail, not this one
review-pr workflow throws or returns no tree Retry once, then hand-dispatch the fallback reviewer. Never enqueue the PR as reviewed — the workflow is not a member, so no other row here covers it
Reviewer or fix-applier cannot reach green Report, leave the PR unlabeled, free the slot
Merge bot hits the hold rule Report held-behind-#<lower>, PR stays queued
Merge bot finds the worktree ahead of the PR head on the local-rebase fallback worktree-diverged-#<pr>, PR stays queued. Read the stray commit; push-or-discard is yours, and the maintainer's if the evidence cannot settle it
Merge bot finds the head moved after ready-to-merge was applied head-moved-after-label-#<pr>, PR stays queued, label untouched. Dispatch a fresh finisher against the new head — the first audit verified a different tree
Merge bot cannot resolve a rebase safely Stop that PR, report, continue. On a conflict the bot has already appended conflict-hold:#<pr> to the PR's row; the tick prints DISPATCH fix-pr PR#<pr> and holds the merge until that fix-applier settles
Merge bot's gate refuses on its instrument re-check (instrument-set-changed or instruments-unanswerable, exit 2) Report <reason>-#<pr> with what it printed, PR stays queued, label untouched. Do not re-run the gate
Member silent or truncated Send to ping or resume — same unit of work; see the state machine below
Member idle with work outstanding Read the PR first, then ping. Idle ≠ done
Member killed (spend limit, API error, crash) Confirm it is dead first — a frozen transcript does not establish that; see below — then new member, new name, prompt carries inherited state

A red PR never silently becomes ready-to-merge.

A frozen transcript does not establish that a member is dead — the
discriminator is its scratch dir.
A member wedged on a blocked tool call
emits the whole signature of a dead one: record counts unchanged from one poll
of its transcript to the next. So read the mtimes of the files anywhere under
the scratch directory its own dispatch prompt assigned it, subdirectories
included — <scratch>/impl-<N>/ for an implementer, that member's own
run-scoped subdirectory under <scratch>/pr<N>/ on the review side, never
the bare pr<N>/ tree those siblings share — since a member waiting on a
child it dispatched writes nothing itself while that child writes below it.
Read them at two observations a poll apart — touch a marker file outside
that directory at the first, and
find <dir> -type f -newer <marker> at the second lists every file that
moved: mtimes that moved between the two mean BLOCKED, a live member
still holding its claim and its worktree, so the killed row above does not
apply to it; mtimes unchanged at both are the dead signature.
Only the first
of those two verdicts is conclusive — a member wedged on a call that writes
nothing freezes its scratch dir too — so settle the dead one on the liveness
read below, never on the two listings alone. Measured on #503: a refuter
wedged forever on until grep -q for a marker node --test never writes, its
transcript's record counts unchanged across two polls while its own scratch
files carried fresh mtimes throughout — alive the entire time it read as dead.
The filenames are not a contract: they are whatever that member happens to be
writing, and they differ by member and by role. That second listing is cheap
at the price, because the cost of the wrong call is asymmetric — a live member
written off gets killed and re-dispatched, which discards the work in flight
and, where it holds a claim or a worktree, collides the fleet with itself,
while one poll spent on a member that had in fact stopped costs only the poll.
The refuter's own observation rule in the fix-applier prompt above stops a
member from creating that state; this one stops you from misreading whoever
reaches it anyway, and neither replaces the other.

Settle outcome and liveness are different facts, not the same table with two
spellings. Recovery is a fresh member, fresh name (impl-<N>-b,
fix-pr-<M>-b, review-pr-<M>-b) whose prompt states what it inherits.

hub cancel leaves a peer hard-aborted and unmessageable, but a failed job's peer can stay idle and answer normally. No distinct truncated state exists.

Reviewers that went idle on CI recover as a finisher, not a re-review once
commits are pushed. See references/member-lifecycle.md.

Audit every worktree before dispatching replacements with
~/.fleet/bin/fleet-run worktree-audit.sh — it reports each worktree's
ahead-count and uncommitted files, the committed-vs-uncommitted distinction that
decides whether a replacement redoes or destroys work.

Run ledger

One git-ignored .fleet/ledger.md per run, updated at every state change via
~/.fleet/bin/fleet-run ledger.mjs subcommands (row, filed, ruled,
check, read, dispatch, settle, drain, rotate). Your context is the least
durable thing in the run: it compacts, and a controller that loses the dispatch
map or the filed list redoes finished work. Two duplicate tickets shipped in one
run from exactly that.

The file lives one run. Phase 0 rotates it before anything reads it, so
no row, exclusion, filing, ruling or drain marker outlives the run that
wrote it. Never rotate mid-run: it is phase 0's step alone.

One line per ticket, rewritten in place (ledger.mjs row <ticket> <text>):

#332 impl-332=PR#344 → PR#344 → MERGED 73b356de
#324 impl-324=PR#346 → PR#346 · fix-pr-346 · ports=16324 · ruled:6-applies · held-behind:#313
#351 impl-351 · tier=task-high
#358 excluded · behind-pr:#346
#360 excluded · behind-issue:#351

A member's token is <member> while it is live and <member>=<outcome> once
it settles
— the grammar is ledger-grammar.mjs's, and the tick derives every
live count from it, so nobody states one. Write neither by hand:

  • ledger.mjs dispatch <ticket|pr> <member> — before the dispatch call,
    marks the member live on its row and appends it to ## Dispatched. dispatch merge-bot names the next bot itself. It prints {member, agent, ticket, line, created, total}; agent is the definition the task call names — an
    implementer's comes off its row's tier= (phase 2's tier check states the
    mapping), a finisher's and a merge bot's are their own definitions, a
    review fix-applier's is null, and a conflict-hold fix-applier's is
    fleet-implementer-slow-high: its own section says how it is dispatched. It
    refuses a name this run already used (a replacement takes -b, -c …), a
    second live member on one ticket or PR, any implementer once the run is
    draining, an implementer whose row's tier= names no single definition
    or one with no file under agents/, and a fix-applier on a ledger the tick
    itself refuses.
  • ledger.mjs settle <member> <outcome> — rewrites the token to
    <member>=<outcome> in both places. Outcomes: impl-N = PR#M | bailed | released | killed | tier-mismatch; fix-pr-M = applied:<head> | no-op | failed | killed; finisher-pr-M = labelled | failed | killed | halted:<cause>, <cause> one of live-editor | rebase | past-pin | unreadable | missing | absent | other (Resolving a finisher halt);
    merge-bot-n = done | killed. A settled member stays settled. settle impl-N=PR#M also folds a #M row a PR-bound dispatch made before the settle
    into #N's row, so one row names each PR from then on — but only when #N's
    row named no PR before this write (a second PR# on it would misattribute
    the fold, or bury #N's own fresher review= state under an older one) and
    #M's row is genuinely the fallback (a live or settled PR-bound token on it,
    or nothing at all) rather than an unrelated row that merely shares the key.
  • ledger.mjs drain "<reason>" — the one drain marker per run; supply
    stops, the review and merge sides keep going. It lives in this run's
    ledger, so the next run's rotation retires it with everything else.
  • ledger.mjs rotate — phase 0's first ledger write, every run: moves
    the file as-is to ledger.<UTC YYYY-MM-DDTHHMMSSZ>.md beside it under the
    same <file>.lock, and the next write starts the empty skeleton. Refused
    while the heartbeat mark is still beating or when the archive name already
    exists; no ledger is exit 0. Phase 0 states the guard's limit.

row refuses a malformed member token anywhere in its text — one whose outcome
is outside the vocabulary above, e.g. merge-bot-1=dispatched — at exit 2,
naming the token and writing nothing, because every reader would take it as a
permanent settle.

→ PR#M stays as the human-readable arrow; the tick reads only the =
tokens. tier=<cell> marks an Exploration Pull's cell and route=<REASON> a router fallback (phase 2), and excluded · behind-pr:#M | behind-issue:#M is an Exclusion (phase 1) — a ticket row like
any other, never a section of its own.

A PR's review is not a member, so it carries its own pair of row tokens,
written with row (Reviewers): review=wf:<runId> |
review=member:review-pr-<n> | review=fallback:review-pr-<n>[-b] at launch,
settled dead as …=failed, then reviewed=<head>:<survived>/<refuted>/<unverified>
when the result lands. A row with review= and no reviewed= is a review in
flight, and the tick counts it against the reviewer cap. ci=<run-id>:<attempt>:<conclusion>
and held-behind:#<lower> are row tokens the same way (Phase 3), and so is
conflict-hold:#<pr> — the one row token the merge bot writes itself, naming
the row's own PR (run-merge-bot.md step 1). It is not an Exclusion: that
gates a ticket's claim, this a reviewed PR's merge. label-off=<finisher-pr-M[-x]>
is yours, written before you take ready-to-merge off PR M on purpose (taking
it off before you approve a push, or clearing one left on a moved head),
naming that PR's latest finisher attempt: the tick reads a missing label as a
finisher's miss only after the last attempt a label-off= names, so without
it your own removal reads as one. row refuses a label-off= naming no
finisher-pr member that ## Dispatched or some row already carries.

Plus two append-only lists:

  • filed (ledger.mjs filed <issue> <subject>, checked with ledger.mjs check <subject> before every gh issue create) — so a finding already recorded
    this run is not filed twice. That is not a guarantee against duplicates at
    large; the tracker query below is what covers those. The FILER writes this
    one, not you: review-and-fix.md step 5 and finisher duty 2 each run filed
    immediately after their own gh issue create, with the subject they passed
    to check, and report filed: #N <subject>. A filer whose filed failed
    twice reports unrecorded: #N <subject> instead, and you run filed for it.
  • ruled (ledger.mjs ruled <pr> <decision>) — PR + decision + one-line reason,
    so a replacement controller does not re-litigate a settled call.

Writer policy: the controller owns run state (row/settle/dispatch/drain/ruled);
filers append filings with filed; all writes are serialized by <file>.lock.

Every write rewrites the whole file from what it read, so without the lock two
concurrent writers are last-writer-wins — measured, 8 concurrent filed kept
2–5 of 8 rows (#531). A writer that cannot take the lock within 10 s exits 2
naming the holder's pid, having written nothing; a lock whose holder is dead is
taken over, and a leftover <file>.lock.reap is never reclaimed. One other
shape wedges the lock permanently the same way, needing the same fix: an empty
<file>.lock — its writer was killed between creating it and writing its pid
into it — is unparseable and therefore, by design, always read as live rather
than handed to the death check; its timeout names <file>.lock itself, since
there is no pid to name. A zombie holder — a pid whose process exited
but whose parent never wait()-ed on it — also answers a liveness check as
alive, because the OS keeps a reserved pid until it is reaped, but that is
transient, not a wedge: once the pid is reaped (the parent's own exit is
normally enough to trigger this), the next writer's death check reads it as
dead and takes the lock over the same as any other dead holder — no hand
removal needed. The other two shapes do not clear on their own — remove
<file>.lock or <file>.lock.reap by hand once nothing is writing. Reads
(check, read) take no lock.

The gap the lock does not close: check → gh issue create → filed is
three commands, and the lock covers each one alone, so two filers can both read
clean in the seconds before either records. Accepted — the measured
same-run accidental duplicate, #298 → #302, was filed minutes apart — and no
claim/reserve step exists to close it.

check exits 0 clean, 1 already in this run's filed list, 2 usage
error (no JSON on stdout), 3 the ledger is clean but open or closed tracker issues match — read
those and decide. Exit 3 is scored: some row gh returned has to score above
zero against the subject, because the ANDed query also matches issues the
overlap rates 0.00, and every measured stop over those was survived only by a
reader who overrode it and searched the tracker by hand — obeying it would have
dropped a real deferral (#388). It also prints the closest filed rows with an overlap score.
Those never move the exit code — the same finding gets worded differently by
whoever finds it second — but once the tracker read succeeds, a filed row at or
above NEAR_SOFT_HIT in ledger.mjs does move the verdict to soft-hit, as
does a tracker set with no scoring row in it; a read that failed stays
unverified, which outranks both. Exit 0 is not automatically "safe to file": when gh
cannot be reached the answer is ledger-only, and it says TRACKER NOT CHECKED —
an issue filed by an earlier run is invisible to it; and soft-hit means rows
worth reading were found while none of them established a duplicate, so read
them before filing. The stdout JSON names all of that in one field:
verdict is already-filed, tracker-hit, clean, soft-hit or
unverified. Only already-filed and tracker-hit carry a non-zero code, so
verdict is the only thing that tells a searched-and-clean tracker from one
that was never read and from one whose rows you have to read yourself.

The ledger half reports its own readability as its own field, ledger.ok, the
peer of tracker.ok: false when the file did not exist, and false too when the
path led to something that did not parse as a ledger — which is what tells a
ledger that was read and held nothing filed from one there was nothing to read
(#231). The absent case is the normal one for a run's first check — .fleet/
is git-ignored and created lazily by the first write — so it stays clean at
exit 0, and ledger.ok is the field that says why rather than the verdict
moving. The stderr warning is deliberately narrower than the field: it fires on
a path with no file, so a --file that landed on the wrong existing file is a
silent ok:false with nothing on stderr. Gate on the field, not on the warning.

Write the ledger line before dispatching, not after. A member that dies
between spawn and write is invisible — and members die in batches.

Report

One running table, updated as events land:

#N  ticket   impl-<N>       PR #M  fix-pr-<M>      label:yes  merged
#N  ticket   impl-<N>       PR #M  fix-pr-<M>      label:no   -        red CI
#N  ticket   impl-<N>       -      -               -          -        blocked: SHA off-branch

Plus a queue-depth line: shortlist, supply, whether triage was suggested.

Red flags

  • "Fork the controller so the member has context" → fork inherits everything.
  • "This member finished, send it the next ticket" → resumes its transcript.
  • "The name is cosmetic" → the name is the ledger token and the hub address.
  • "Name the specialists too" → a depth-2 member cannot dispatch further.
  • "ready-for-agent came back empty, widen to ready-for-human" → empty means no work.
  • "The reap at the end will pick up the claim I never dispatched" → it declines:
    not [gone], no unique commits. Release it, or it reads as taken next run.
  • "Touches eight files, too big for the fleet" → size is not the axis. Decided is.
  • "Body is three lines, so it's simple" → short bodies hide open design choices.
  • "The brief is thorough, so it's decided" → thorough ≠ decided. Read it for the
    choice it leaves open.
  • "I'd have to pick an approach myself" → that IS undecided.
  • "The reviewer can dispatch, it'll fan out" → not unless authorized.
  • "Tell the reviewer to ping its specialists" (fallback path) → a depth-2 member
    cannot dispatch further, so the ping goes nowhere. Tell it to
    read the specialist's output file instead; your relay is the backup, not the
    only path.
  • "I relayed it, so the reviewer has it" (fallback path) → sent is not read, and a relay can land
    after the verdict is composed. Two reviewers ruled relayed dimensions
    undelivered, one of them shipping a headline claim the report it disclaimed
    refuted. Reconcile verdicts against your receipts.
  • "It reported the SHA, so it's on the branch" → verify.
  • "Let the merge bot arm its own monitor" → it dies, the queue stops.
  • "The member died, message it the state" → dead agents do not read mail.
  • "Its CI is green, ship it" → check behind-count; green decays invisibly.
  • "The specialist says nothing else references it" → the claim most often wrong.
    Ask what scope it searched.
  • "Five implementers = five times throughput" → reviews are 3-5x longer. It means
    a backlog.
  • "npm install to set up the worktree" → the wrong install mutates the lockfile
    for the whole repo.
  • "I'm on my own copy, so I'm isolated" → not from the docker stack.
  • "commit-commands:clean_gone printed nothing, the tree is clean" → its
    [gone] detection works fine; it just runs -D/--force with no merged
    check, so "printed nothing" only means nothing was gone yet, not that it's safe.
  • "--force the worktree removal, the PR merged anyway" → merged says nothing
    about uncommitted files, and the reviewer may still be in there.
  • "Reap once at the end of the run" → stale worktrees make phase 0 read merged
    tickets as taken, so the starvation compounds with every Pull.