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.
Resources
1Install
npx skillscat add feigi/fleet-plugin/run-team Install via the SkillsCat registry.
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 asuccess: 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
trackplugin/…, 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 insidemerge-gate.mjs, step 3 ofrun-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. Itspgrep -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
EOFThe 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.
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, sogit merge --ff-only origin/mainand re-read what changed undercommands/,scripts/andskills/. 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 trackplugin/…— 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 --recordwrites.fleet/main-checkout.sha: the main checkout'sgit status --porcelain -uallentries 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 everyfleet-tick.mjscompares the main checkout against it and
holds dispatch on any change (#2210): the backstop for the stray member
writesmember-write-guardcannot see — anevalcell, abashthatcds orgit -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.tsvanddocs/metrics/member-outcomes.tsv, which you write mid-run, are left out
by name — anything else underdocs/metrics/still trips it. The same
command is the clear, and it overwrites; it never compares. OnMAIN-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 overMAIN-CHECKOUT-UNKNOWN: that
walks the run from "could not look" to "certified clean" in one step, so--recorditself refuses over a baseline it cannot read and on any git
read that fails.main-checkout.mjs --checkprints 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 --checkcompares 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, nogh, a listing or read that failed
outright, or a read that succeeded but withheldbypass_actorsbecause
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:--openopens 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/sessionstree
(#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.jsonlfile 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 rotatemoves the previous run's.fleet/ledger.mdbyte-for-byte to.fleet/ledger.<UTC YYYY-MM-DDTHHMMSSZ>.mdand leaves none behind, so this run's first write
starts the empty skeleton. Nothing is carried forward — no row, exclusion,## Filedor## Ruledentry — so an uncheckedimpl-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-endand 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.jsonbeside the common git dir); no script infers either
command. Check it first:~/.fleet/bin/fleet-run derive-testcmd.sh . installand~/.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 ataskmember namedderive-recipe, agentfleet-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 withrecipe-prove.mjsin a
throwaway worktree oforigin/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, withderivedAt(theorigin/maincommit it ran against) and the proof itself.RECIPE PROVEN→ re-run bothderive-testcmd.shreads; 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: dispatchderive-recipeagain
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 withrelease-ticket.shfirst (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 PROVENfrom 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 reportSTALL — Recipe not proven: <cause>to the maintainer with the
deriver's reason verbatim. A repository the fleet cannot run needs human
hands, theready-for-humancause: 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 pinningfleet-tick's backlog forever (#590): unreviewable AND uncounted is the state
that strands it.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 isscanned, the supply. - Dependency scan,
next-ticketstep 2, on each row'sdarray. Open
blocker → drop. - Live Exclusions — a ticket whose ledger row reads
excluded · behind-pr:#Morexcluded · behind-issue:#Mstays 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 asledger.mjsresolves.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-agentmandatory, no fallback — the script
fixes it, and you never runcandidates.mjs --allow-fallbackby hand to
widen the list:ready-for-humanneeds 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 theready-for-humanand untriaged work you have no channel for.- Candidate scan —
Refresh is the tick's from here on.
fleet-tick.mjsrunsshortlist.mjsitself when unclaimed entries fall below the implementer
cap, when the file is missing or empty, and when anexcludedpremise has
lifted, and printsREFRESHED 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):
Take the shortlist head — the next
.fleet/shortlist.jsonentry with noimpl-row and noexcludedrow whose premise still holds, which is the
ticket the tick'sPULLrow names.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 dropMERGEDandCLOSED, so a
merged or abandoned PR thegh pr listwindow 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 anunknown[]naming the probes that could not look:
readunknown, nevertakenalone, because exit 2 says"taken": false,
which summarizeshitsand is not a claim the ticket is free. A hit outranks
an unknown — a payload can carry a non-emptyhits[]and a non-emptyunknown[]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.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 withjq -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 Briefcomment where one exists, otherwise the issue
body, which is where most tickets actually carry it (a controller probe found only 43/100 openready-for-agentissues have a separate Agent Brief comment; the rest,
this ticket included, carry the brief in the body) — plus itsOut of scopesequencing 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 againstorigin/main— never the working tree, never
the ticket's own line numbers, which drift. Onegit show origin/main:<file>
piped togrepdoes it for any ticket citing a construct, which is most of
them. That read is fatal, not empty, when the path is untracked inorigin/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 togit 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 fromgit show origin/main:<file>
⇒ the fix landed, andgit log -S '<old string>' --oneline origin/main -- <file> | head -1names
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/mainis where the
walk starts, so every commit it can print is reachable fromorigin/mainby
construction, andgit merge-base --is-ancestor <sha> origin/mainis true
for all of them — true again when the sha isorigin/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, sogit showresolves it happily while nothing inorigin/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 withNot a valid object nameis a sha this clone does not have at all, a probe that
could not look rather than an orphan (measured: #640's comment cites35c0845on a since-deleted branch, and 128 is what it exits here).Both halves of that command are load-bearing. Drop the
origin/main
argument andgit log -SsearchesHEAD, 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's0dc39ef^,| head -1names the rename4fd2f73, 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 at647ae44^, which already tracks this
path under this name,git log -S 'ls-tree origin/main'prints nothing at
exit 0.
Add--reverseand 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,--reversenames4fd2f73("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 -1names0dc39ef, the
commit that actually did it.
(--followfixes the rename half but is mutually destructive with--reverse: the two together return empty at exit 0, and--followtakes
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
e7b11e6ten 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 thecontentWords()extraction it asks for had all landed (d016414,1abd6c8)
whileoverlap(), 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, whosed:scancandidates.test.mjs
already pinned —6d94451and43d6ac5both added.dassertions, and the
ticket's own closing comment cites both. It missed #206, whose wording
defect0dc39efhad already fixed — by an equivalent rewording, not by
the literal string the ticket proposed, which appears nowhere inorigin/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 inorigin/main, the fatal-not-empty case above.staleness.mjsruns 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 inshortlist.mjsbecause 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.--goneis a defect the fix must remove, the wording the ticket quotes as
wrong;--presentis 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 commitandsubject2 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/mainand
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-testbeing written
into a claimed worktree byclaim-ticket.sh's heredoc, which makes every
copy under.worktrees/a snapshot of whenever that worktree was claimed.fixedis never read off the string's presence alone either: it carries the
commitgit log -Snames for that string at that path, reachable fromorigin/mainby construction since that is where the walk starts. A--gonestring 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 acould 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 itready-for-humanas a fork naming the open decision — the label is how a
Pull surfaces it to the maintainer. Opposite ofsizing-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 — seedocs/adr/0001-filing-label-bar-is-defect-confirmed.md.No sizing agent here.
sizing-a-ticketpicks the process path, and that is
phase 2's call, after the ticket is claimed.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'sgh pr diff <PR> --name-onlyand each live worktree'sgit diff --name-only origin/main...<branch>(the branchesgit worktree listnames) — a member
mid-work has a branch before it has a PR.pr-overlap.mjsis PR-vs-PR
only — it runsgh pr diffon 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 itbehind-pr:#M, next entry. The tracker cannot
express this, and it is what actually stalls a Pull's PR at merge.Sequence check — the brief's
Out of scopenames an open ticket this one
sequences after → exclude itbehind-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.Claim —
~/.fleet/bin/fleet-run claim-ticket.sh <N> impl-<N> implementer --apply(below). Exit 2 → treat it as taken, next entry.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.jsonprintsPOLICY=… CELL=… DRAW=… STRATUM=… REASON=…(phase 2's exploration rule
says what each argument is); thenledger.mjs row <N> "impl-<N>[ · tier=<CELL>][ · route=<REASON>]",tier=<CELL>exactly whenDRAWis 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> (noin-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-agentrestored 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 abehind-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 fromorigin/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 fromorigin/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 HEADagainstgit ls-remote origin refs/heads/<branch> | cut -f1— the ref itself, which
is what the PR's head actually is, and nevergit 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 onoriginat 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 intorun-merge-bot.md's step-1 poll. - Read
gh pr view <N> --json headRefOid -q .headRefOidhere too, and
record it — it is NOT the gate. AheadRefOidthat disagrees with the ref
is the PR-object desyncrun-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 inrun-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, whilegit 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 torefs/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'sagent() 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 readingfailed/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> meansfleet-implementer-<cell> — the same mapping ledger.mjs dispatch prints itsagent 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 withsession. 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'smodelRoles, 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), everytask.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'scells 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 isfleet-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. Threesonnet 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 readsledger.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, andboard.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 todocs/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. ASizing: 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"
doneencodeProjectDir (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-editdocs/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 twodocs/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 55routine/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.tsvThese 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 atslow-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 anyfleet-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 asDISPATCH 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-triagegenuine fork — options named, none ruled ready-for-humanneeds human hands — external access, manual testing, judgment during the work ready-for-humangh issue edit <N> --remove-label ready-for-agent --remove-label in-progress --add-label <label>, comment the cause, release the worktree and branch withrelease-ticket.sh(below —reap.shdeclines a claim that never became a
PR), then run the tick: the head it names next is a different ticket.needs-triageroutes back to/triage, which can return it asready-for-agent;ready-for-humanfor hands or a fork;needs-triagefor a
brief that names no what.Dropping
in-progressis the load-bearing half — phase 1 applied it andcandidates.mjsexcludes 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 noin-progressto 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 recordreviewed=off its
digest, then run the tick: a PR with survivors prints asDISPATCH 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-mergeappears → nothing to record; run the tick, and
onDISPATCH merge-botdispatch the nextmerge-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 asheld-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 — settlemerge-bot-<n>=done, reap merged branches and worktrees withreap.sh --apply(below) — always, even when the pass merged nothing — and
then run the tick. A label that landed at the last moment readsDISPATCH merge-botagain: dispatch the next bot straight away. Arebase-fallback-#<pr>
on a conflict needs no record from you — the bot already appendedconflict-hold:#<pr>to that PR's row, and the tick turns it intoDISPATCH 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 eachimpl-<N>=released. Nothing else in the loop fires for those
claims. The marker is on disk, so the tick holds the implementer row atHOLD (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 asci=<run-id>:<attempt>:<conclusion>, not just "CI green" — in the
ticket's ledger row, which outlives your context.rowreplaces the whole
line (above), so repeatports=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-validatingcheckjob green with no heavy job (the
diff-validating suites, not therebase-checkcurrency gate) infailure
→ a finisher CANDIDATE (gated below), acheck
failure → a fixer. Acheck-green board whose heavy jobs are merelyskipped(behind-count staleness, the normal case after a merge) still labels — do NOT
gate onci-state --quietexit 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.ymltriggers onpull_request, so
opening the PR at the end ofnext-ticketstep 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 reportsno-opbullet 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:suggestionis 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: ano-opreport 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>readsverdict: "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-cideclaration, not acheckjob: 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. Seereview-and-fix.mdstep 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 itsPULLrow already reads the result.REFRESH FAILEDis yours:shortlist.mjscould 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'sconflict-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 nextmerge-bot-<n>(Merge bot).DISPATCH finisher PR#<M> …— a finisher settledlabelled, yet the open
list shows PR M withoutready-to-merge, and nolabel-off=(Run ledger)
accounts for the removal (#2331). Apply the finisher gate (Reviewers: thecheckjob 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 1past-pinhalt 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 lastlabel-off=
settledlabelledand the label is still not on: the tick dispatches no
third. Not actionable; the maintainer's, through the cockpit'sunlabelled
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-reviewsin 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 runningtier-check.mjs --batchon the member it names
(itstier-ok=, ortier-unverifiable=for a member settledkilledorreleasedwith 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: thetier-check.mjscommand
with a batch entry per member it names (fill in<file>and<session>),
or the replacement's name —impl-<N>-bon 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) readingHOLD (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 …withHOLD (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 —--recordoverwrites, so that certifies
a tree nobody read. Fix what the line names and tick again.MAIN-CHECKOUT-NO-BASELINE …withHOLD (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 theHOLD (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-unchangedand 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-issuegh 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 dropsjobs, 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 asconclusion: "", 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
doneOne 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" rulecandidates.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 asmerge-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 readingverdict: "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 readsverdict: "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 — areview-pr-<pr#> member still running — is one; eachfix-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 withreview= 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: thereview-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 thengit 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 indimensionsRun; 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; everydimensionsUnrun entry is {dimension, reason} naming which failure it was.
The review already re-dispatched each crashed specialist once, so whatdimensionsUnrun 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 insurvived/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 ofclean/dirty/unrepo/missing. The prompt asks every specialist to rungit 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 missingargs.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 oneledger.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, andtestEnvironment/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 theagent 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 byrun-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, andfailed/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 ithead-moved-after-label-#<M>: a fresh finisher, per Failure handling.
A DISPATCH fix-pr PR#<M> on a dispositions mismatch — the PR's row carriesdispositions-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 linesdispositions-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 asdispositions-escalate=fix-pr-<M>-b:<head> in place of a mismatch: the tick
prints no DISPATCH fix-pr for it, dispatch refuses the finisher namingdispositions 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 value1 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 reponode --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.
editandreadresolve a bare relative path against the SESSION ROOT —
the controller's own main checkout — not against that worktree and not
against yourbashcwd, which they share nocwdparameter with. Give them
the absolute worktree path on every call, mutation-probe copies included.
The main checkout is the parent ofgit rev-parse --path-format=absolute --git-common-dir. Amember-write-guardrefusal means the path hit the
main checkout — forbash, a missing or main-checkoutcwd— so re-issue
it absolute, under your worktree.Read
$(~/.fleet/bin/fleet-run --root)/commands/review-and-fix.mdand 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 throughreview-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, andtestEnvironment
andcwdAuditbefore 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 takesgrep -F, and
its zero counts only after the same command finds a line known to match.Apply
survivedfindings. A finding inunverifiedwhose refuters ran and
crashed always defers — and which of the two it is, you read offrefutersDispatched, never off severity and never off an empty vote list:
above zero with nothing surviving means every refuter dispatched against it
died, and atcriticalthat 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. Asuggestionis also inunverified,
for a different reason —refutersDispatchedof zero, the 0-refuter budget
the review gives that band by policy — and the rule below, not this one,
covers it.A
suggestionis 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 -donce per round for<scratch>/pr<N>/fix-XXXXXXXX/, never barepr<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 useenv -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 namedSETBto1 BADJ=1, never setsBADJat all, and still exits 0.env $cfg[@]is not
the portable spelling either: measured, bash word-splits it intoSETB=1
andBADJ=1[@], so the injection variable is set to a corrupted value,
while zsh behaves exactly as with the bare$cfg—BADJnever 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, thoughgit show/git archiveat a pinned ref read fine anywhere. Chain the
directory change into the command,cd "$D" && git …, nevercd "$D"; git …, so a failedcdcannot leave agitcommand running in
the checkout — and bracket a fixture's own git withgit rev-parse --show-toplevel: beforegit initit must NOT resolve to
the repository, and a fresh scratch dir'sfatal: not a git repository
(exit 128) is the pass, not a failure; before anygit commitit must
resolve to your scratch path — compare resolved forms (realpath), since--show-toplevelcan report/private/tmp/…for a/tmpscratch 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_fixis 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 hadagent()
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>), viahub 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 isreview-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-checkred, or heavy jobsskipped, solely off a non-zero behind-count, is staleness and not a
failure. Never rebase to clear it.solelyis the load-bearing word:ci.yml'srebase-checkexits 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 withgit fetch origin && git rev-list --merges --count "origin/<base>..HEAD"—
keep the fetch, or a stale localorigin/<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-1gh pr update-branch --rebasedrops merge commits too
(run-merge-bot.mdstep 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 0is 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 namedSETBto1 BADJ=1,
never setsBADJat all, and still exits 0.env $cfg[@]is not the portable
spelling either: measured, bash word-splits it intoSETB=1andBADJ=1[@], so the injection variable is set to a corrupted value, while
zsh behaves exactly as with the bare$cfg—BADJnever 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 stashorgit stash pophere 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 samerefs/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 baregit 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 withdispositions-check.mjsbefore it dispatches any finisher. It is{"head": "<the review file's head>", "entries": [ … ]}: one entry persurvivedfinding and perunverifiedfinding, plus one perrefuted
finding you reverse, its evidence inreason. Each entry carriesbucket
(survived|unverified|refuted),index(its position in that bucket of the
review file),scope(in|out),claimKind(behavior|shape) anddisposition(apply|defer), and where they applyreason,issue(the
number you filed it to or commented on),verdictPath(an in-scope
suggestion's refuter verdict file) andremedyFiles(the files its remedy
names). A finding with noline, or on a line the PR's diff touched —git difffrom its merge-base withorigin/mainto the review's head — is
in scope whatever itsscopesays. An in-scopesurvivedfinding you defer
carriesreasonfalse-rationale,mutual-exclusion,remedy-worseorremedy-outside-diff; the last needsremedyFilesto name a file the PR's
diff does not touch, and for acriticalorimportantfinding it sends the
PR to a human instead of passing. Any
other reason, none, or asurvivedorunverifiedfinding 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 — afiled: #N <subject>line for every issue
step 5 created, andunrecorded: #N <subject>for one whoseledger.mjs filed
failed twice — and exit. Deferring everything is a normal
outcome, not a stall: nothing is then staged,git commitrefuses an empty
index,git push origin HEAD:<branch>printsEverything up-to-date, and you
reportno-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 therebase-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) — orci-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 withledger.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 thedispositions-*=fix-pr-<M>[-x]:<head> tokens whose head matches it, the one
from the highest-suffixed fix-applier. A PR whose latest review counts0/<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 singlegit status are each valid only at their instant. Its duties, in this order:
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>, orcd <abs-path> && pwd -P—
never by that path as written: the row'sworktreefield is the physical
pathgit worktree listreports, so a spelling reached through a symlink
(macOS's/tmpand/var/foldersare symlinks too) matches no row on a
healthy worktree. Two checks, each with the instrument that can answer it.
Dirty: the row'sdirtyfield, orgit status --porcelain -unormal
inside the worktree itself — the explicit mode, never bare--porcelain, orstatus.showUntrackedFiles = noreads a dirty worktree as clean (#730). The
row is the better read: in a worktree whose.gitlinkage is missing,git statusanswers for the enclosing repo at rc 0, where the audit reports
the worktree unreadable. Diverged: the worktree'sHEADagainst your
dispatch pin — the cause block's head-equality check below. Neither
instrument above answers it:git status --porcelainreports no commits,
and the row'saheadis counted againstorigin/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:falsewithnullcounts
(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 statusis 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.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, whichCloses #Nburies on merge. Caught once at seven findings. A
comment on an existing follow-up issue is filed: that isreview-and-fix.mdstep 5, not a violation. Not filed → file it or halt,
never label over it — and file it the way step 5 does, throughledger.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 aftergh issue create, with the same subject string you passed
tocheck. Iffiledfails, retry once; if it fails again, leave the issue
alone — it exists and is correct — and report itunrecorded: #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.
Confirmgit diff origin/main...HEADactually 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" againstorigin/main...HEADon 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, thengit 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, andPR headRefOidmay 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, andworktree addon 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 plaingit worktree removerefuses 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 akeptentry
it reports and never collects. The tree you added is a checkout, so since #55
it carries the tracked./agent-testlike 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.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: agh pr createthat
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-mergeitself 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 listsettles
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 reportedlabelledon PRs that sat out of the merge queue.
- (a) Exactly one release label. Read the PR's labels back
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 — afiled: #N <subject>line for every issue duty 2 created, andunrecorded: #N <subject>for one whosefiledfailed twice — and anything
it halted on — cause and evidence, below, never a bare "head moved". The
cause is one word of the ledger'shalted:<cause>vocabulary:live-editor,rebaseorpast-pinfrom the cause block below,unreadable,missingorabsentfrom duty 1's row shapes, andother
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. Arebasehalt's report
carries the output ofgit 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.--repois 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. Theenv -u GIT_DIR -u GIT_WORK_TREEwrapper guards against an ambientGIT_DIRin 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--repoonly 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 anevalcell
there is no$?: spawn<testCmd>with its output going to that file and
read the exit code and the signal off the spawn result —exitCodeandsignalCodeon Bun'sspawnSync,returncodeon Python'ssubprocess.run,
which is negative for a signal. Never read the status of<testCmd> | tailor<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 showstests
above 0 andcancelled0. 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 notimeout, deadline or kill of yours having fired.
Judge that by how the run ended, never by itscancelledcount: a failingbeforehook or a per-testtimeoutcancels tests inside a run that
completed, and a SIGTERM'dnode --testcan exit 1 withcancelledabove 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, anevalcell's
deadline — and read for green it is FAILED, the same bucket astests 0, however many tests had passed before the kill. Halt with causeotherand 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 itscancelledcount, and exit 0 withtests 0
stays FAILED.
Do not wrap<testCmd>in an externaltimeout. Run a long suite in oneevalcell withtimeout: 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 ofpatch/minor/major. Zero or more than one halts you before the label:
name which you found. A repo whosegh label listdefines 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 rungh pr view <M> --json labelsonce 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 againston
a clean tree settles it. Against your dispatch pin, never againstPR 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.headRefOidis 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, andanything elseis a cause too, not a gap:
- Live editor.
git status --porcelain -unormalis dirty. Samplegit diff --stattwice, a minute apart — diffstat growing means someone
is still writing. Halt, namelive-editor, report both samples.- Rebase.
git status --porcelain -unormalis clean, head still differs
from your pin.git reflogin the worktree: areset/rebase entry near
the move, not a plaincommit, means the branch replayed onto a new
base — its own commits on a new parent, content-identical only on a
conflict-free replay.
Halt, namerebase, report the reflog line and the output ofgit cherry <pin> HEAD origin/main, run in the worktree.- Work past the pin.
git status --porcelain -unormalis clean, head
still differs from your pin, andgit reflogin the worktree reads a plaincommitat the move — noreset/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, namepast-pin, and report the commit, and whether it
is pushed, unpushed, or unknown:git log -1 --format='%h %s'names it,
andgit ls-remote origin <branch>answers the rest — a non-zero exit or
any other failed read is unknown, never folded into "unpushed": the
same rulerelease-ticket.sh's ownls-remotenames ("a failure here is
an unknown answer, never a 'no'") andreaping.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 fieldrun-merge-bot.mdrefuses 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, nameother, and name what you did find: bothgit status --porcelain -unormalsamples, thegit reflogline 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 — withgh pr comment <M>, so it outlives the run. No label: the missingready-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:
ci-state.mjswithout the flag still readsverdict: "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.- The reviewer's own final verdict reports a green
testCmdrun on the SHA it
pushed.review-and-fix.mdstep 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 asreview=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, recordreviewed=<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, andtail -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 omitmodel 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 andprove-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 ismerge-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-boundci-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 andgit 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 thegh 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, soisolated stays unused for every member and task.isolation.enabled stays
off.
A member's tree is the claimed worktree, addressed by absolute path. Anedit 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, plusbash's own cwd parameter set to it on every call and absolute paths forwrite/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 oneeval 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 insideeval 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 andeval 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 noimpl-row and no
liveexcludedrow: admissible, not yet pulled. The tick'sunclaimed=. - supply —
scanned, the openready-for-agentticketsshortlist.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.mjscounts every
open PR withoutready-to-mergethat closes an issue and carries noreview=
token on the ledger — one settled=failedis owed a review again, and so is
one whose latest finisher haltedpast-pinpast its latestreviewed=head
(Resolving a finisher halt) — and it
prints them asreview-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; seedocs/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. Thefleet-tick.mjsnumber 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>) untiltier-check.mjs has written its verdict on the newest implementer —tier-ok=, tier-mismatch= or tier-unverifiable= — andHOLD (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-mergeis 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, areview-prrun 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
ghcredentials, 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 withgrep -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 asinflight.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 andno-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 issuedpkill -f "claim-ticket.test.mjs"and
killed anode --test claim-ticket.test.mjs inflight.test.mjsthat 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 -fdefaults to
SIGTERM, and a SIGTERMed runner printsInterrupted while running:followed
by a complete summary block withcancellednon-zero, exitingrc=1— socancelledis exactly the thing to search for in the default case.pkill -9 -fleaves no summary block at all and the shell reportsrc=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 withfail
non-zero andcancelled 0atrc=1, indistinguishable from an ordinary test
failure. And a kill landing on a process a test itself spawns leaves a
completepass N / fail 0 / cancelled 0at the expected N over a corrupted
run:spawnSyncreports a SIGKILLed child asstatus 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 onnull.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 withps -Ao pid,ppid,args -ww(without-wwmacOSps
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 underplugin/scripts/, and that runnerexecs
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
certainlyimpl-<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
allpkill -ftargets 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 onereview-and-fix.mdhands 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 forunv1; one
agent overwrote a sibling'spackage.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 --noEmitfrom 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, andfind <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:#351A 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-botnames the next bot itself. It prints{member, agent, ticket, line, created, total};agentis the definition thetaskcall names — an
implementer's comes off its row'stier=(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 isnull, and a conflict-hold fix-applier's isfleet-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'stier=names no single definition
or one with no file underagents/, 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 oflive-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#Malso folds a#Mrow 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 secondPR#on it would misattribute
the fold, or bury#N's own fresherreview=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 toledger.<UTC YYYY-MM-DDTHHMMSSZ>.mdbeside 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 isconflict-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 nofinisher-pr member that ## Dispatched or some row already carries.
Plus two append-only lists:
- filed (
ledger.mjs filed <issue> <subject>, checked withledger.mjs check <subject>before everygh 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.mdstep 5 and finisher duty 2 each runfiled
immediately after their owngh issue create, with the subject they passed
tocheck, and reportfiled: #N <subject>. A filer whosefiledfailed
twice reportsunrecorded: #N <subject>instead, and you runfiledfor 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 readclean 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 staysunverified, 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 orunverified. Only already-filed and tracker-hit carry a non-zero code, soverdict 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-branchPlus 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 installto 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_goneprinted nothing, the tree is clean" → its[gone]detection works fine; it just runs-D/--forcewith no merged
check, so "printed nothing" only means nothing was gone yet, not that it's safe. - "
--forcethe 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.