@feigi/fleet-ctl 3.82.19 → 3.82.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -134,3 +134,50 @@ than being detected, and nothing else can write the reason afterwards:
134
134
  ```bash
135
135
  ~/.fleet/bin/fleet-run fleet-heartbeat.mjs --stop "budget exhausted"
136
136
  ```
137
+
138
+ Skipping it is not fatal — the next reader still reports the stop, it just
139
+ reports that the beat stopped *without a recorded reason*, which is all it can
140
+ honestly say about a death that named nothing.
141
+
142
+ ## Nothing to do: why the beat, and what its lines mean
143
+
144
+ - **Nothing to do right now** → **do not end your turn.** Arm the heartbeat
145
+ (SKILL.md's **Nothing to do: arm the beat**). A turn that ends on a drained
146
+ queue is the stall itself: nothing external will wake you, and the
147
+ level-check you are one command away from never runs. Listed on its own
148
+ because it is the only wake with no trigger — every other row of SKILL.md's
149
+ wake list arrives; this one is the absence of one.
150
+
151
+ - `… Ns of Ms remain → re-issue this command now, do not end your turn` — no
152
+ command blocks for a whole interval (omp backgrounds one at 60s, which is
153
+ why the CI gate holds its wait in one `eval` cell polling `gh run view`).
154
+ Issue it again. That is still one blocking call per turn, never an idle turn.
155
+
156
+ **The beat does not stop on idleness.** An empty shortlist and an empty supply are
157
+ not a reason to end the run; they are the state a newly triaged ticket arrives
158
+ into. The run ends when you drain it, when the budget goes, or when this
159
+ context does — not because there was briefly nothing to do.
160
+ Zeroes on the cockpit are not yet proof the pipeline is idle.
161
+
162
+ ## Rule: record, tick, act, beat
163
+
164
+ **Rule: record, tick, act, beat.** Every wake — a member report, a review
165
+ notification, a label, a CI run reaching a terminal state, a `CONFLICT` line, a
166
+ heartbeat, the maintainer's drain — is one `fleet-event.mjs` call, which makes
167
+ the wake's writes and ends in one `fleet-tick.mjs` invocation, and you do what
168
+ it prints. There are no hand-run
169
+ refill or dispatch edges left. Record first, because the tick reads the run off
170
+ `.fleet/ledger.md` and nothing else of yours: a wake you have not written down is
171
+ one it cannot see, and it names the same dispatch again. Every record goes
172
+ through `ledger.mjs` (references/ledger.md), which `fleet-event.mjs` drives — never a
173
+ hand edit. What each kind writes is in the header comment of `fleet-event.mjs`.
174
+
175
+ ## The merge bot does not wait for the finisher's report
176
+
177
+ The `ready-to-merge` Monitor catches hand-added labels as well as a finisher's
178
+ own. The bot does not wait for that finisher's report: its rebase is
179
+ server-side, `gh pr update-branch --rebase`, so nothing on the finisher's
180
+ worktree has to happen first. **You own the watcher, not the bot.** Arm one
181
+ yourself, `persistent: true`, seeded before the loop so handled PRs do not
182
+ re-fire. **One rebase per PR, at merge time.** Any behind-count you hand a bot
183
+ is expired on arrival; say so.
@@ -1,6 +1,141 @@
1
- # The CI watcher: why its block is shaped the way it is
1
+ # The CI watcher: its block, and why it is shaped the way it is
2
2
 
3
- Evidence for the rules SKILL.md states beside its watcher block, under **Own the CI waits**, keyed by the rule each justifies.
3
+ SKILL.md's **Own the CI waits** arms the persistent Monitor from the block
4
+ below, verbatim. The rules beside the block follow it; the evidence for each
5
+ rule is under the heading naming it.
6
+
7
+ ## The block
8
+
9
+ **A probe that cannot read is a transition event, not silence.** So check the REST budget *before* polling — `gh api rate_limit` is itself
10
+ unmetered (`gh api rate_limit --jq '.resources.core.remaining'`, re-confirmed
11
+ 2026-09-08) — and give an unreadable `ci-state` payload the same treatment one
12
+ level down. The shape is **one outer tick loop with an inner pass over the open
13
+ PRs**, because the two causes live at different levels — the budget is one
14
+ account, the payload is one PR:
15
+
16
+ ```sh
17
+ budget_out= hidden_out= list_out= cap_out= blind= # seed the latches: set -u reads each below before tick 1 ever assigns one
18
+ pr_cap=200 # the open-PR read's --limit; gh stops there with exit 0 and no warning
19
+ while :; do # one tick
20
+ rl=$(gh api rate_limit --jq '.resources.core.remaining' 2>/dev/null || echo ERR)
21
+ case "$rl" in ''|*[!0-9]*) rl=ERR;; esac # a non-numeric read is an outage, not a budget
22
+ if [ "$rl" = ERR ] || [ "$rl" -lt 200 ]; then
23
+ if [ -z "$budget_out" ]; then # ONE global latch: a quota outage hits every PR
24
+ echo "WATCHER DEGRADED: core REST budget=$rl — CI polling paused, silence is NOT green"
25
+ budget_out=1
26
+ fi
27
+ else # every RECOVERED waits for the pass gates below
28
+ prs=$(gh pr list --state open --limit "$pr_cap" --json number --jq '.[].number' 2>/dev/null) || prs=ERR
29
+ if [ "$prs" = ERR ]; then
30
+ if [ -z "$list_out" ]; then # global latch: this cause is account-level too
31
+ echo "WATCHER DEGRADED: cannot list open PRs — CI polling paused, silence is NOT green"
32
+ list_out=1
33
+ fi
34
+ else # a list or cap RECOVERED waits for the pass gates too
35
+ count=$(printf '%s\n' "$prs" | grep -c .) || : # grep -c exits 1 on zero matches; set -e must not take it
36
+ if [ "$count" -ge "$pr_cap" ] && [ -z "$cap_out" ]; then # global latch: a full page may have been cut there
37
+ echo "WATCHER DEGRADED: open-PR list hit its --limit $pr_cap — PRs past it are NOT watched, silence on them is NOT green"
38
+ cap_out=1
39
+ fi
40
+ # gh pr list is GraphQL, so $rl still holds. The pass is budgeted at 4 core
41
+ # REST units per listed PR on top of the 200 floor.
42
+ # A shortfall here, or at the Actions-bucket gate below, pauses this tick's pass.
43
+ if [ "$rl" -lt $((200 + 4 * count)) ]; then
44
+ if [ -z "$budget_out" ]; then
45
+ echo "WATCHER DEGRADED: core REST budget=$rl < $((200 + 4 * count)) needed for $count PRs — CI polling paused, silence is NOT green"
46
+ budget_out=1
47
+ fi
48
+ else
49
+ # The workflow and run lookups bill a second core bucket that
50
+ # `rate_limit` does not report. Its count is only in the response
51
+ # headers of a lookup that bills it: one 1-unit read per tick, 4 units
52
+ # per listed PR on the same 200 floor. Recovery for every global latch
53
+ # waits until this gate clears too, so no line says polling resumed
54
+ # while this one still pauses it.
55
+ hrl=$(gh api -i 'repos/{owner}/{repo}/actions/workflows?per_page=1' 2>/dev/null | awk 'tolower($1)=="x-ratelimit-remaining:" {sub(/\r$/, "", $2); print $2; exit}')
56
+ case "$hrl" in ''|*[!0-9]*) hrl=ERR;; esac # a missing header is an outage, not a budget
57
+ if [ "$hrl" = ERR ] || [ "$hrl" -lt $((200 + 4 * count)) ]; then
58
+ if [ -z "$hidden_out" ]; then
59
+ need=; [ "$hrl" = ERR ] || need=" < $((200 + 4 * count)) needed for $count PRs" # an unreadable header has no amount to compare
60
+ echo "WATCHER DEGRADED: unreported Actions core REST bucket=$hrl$need — CI polling paused, silence is NOT green"
61
+ hidden_out=1
62
+ fi
63
+ else
64
+ if [ -n "$list_out" ]; then
65
+ echo "WATCHER RECOVERED: open-PR list readable again — CI polling resumed"
66
+ list_out=
67
+ fi
68
+ if [ -n "$cap_out" ] && [ "$count" -lt "$pr_cap" ]; then
69
+ echo "WATCHER RECOVERED: open-PR list under its --limit $pr_cap — every open PR watched again"
70
+ cap_out=
71
+ fi
72
+ if [ -n "$budget_out" ]; then
73
+ echo "WATCHER RECOVERED: core budget=$rl — CI polling resumed"
74
+ budget_out=
75
+ fi
76
+ if [ -n "$hidden_out" ]; then
77
+ echo "WATCHER RECOVERED: unreported Actions core REST bucket=$hrl — CI polling resumed"
78
+ hidden_out=
79
+ fi
80
+ for pr in $(printf '%s\n' "$prs"); do # inline $(...): `for pr in $prs` is ONE iteration under zsh
81
+ st=$(~/.fleet/bin/fleet-run ci-state.mjs --pr "$pr" 2>/dev/null) || : # not-green exits non-zero; the payload is the verdict
82
+ if ! printf '%s' "$st" | jq -e '.verdict and .verdict != "rate-limited"' >/dev/null 2>&1; then
83
+ case " $blind " in *" $pr "*) ;; *) # latch keyed BY PR: this cause is per-PR
84
+ echo "WATCHER DEGRADED: no usable ci-state reading for #$pr — silence is NOT green"
85
+ blind="$blind $pr" ;;
86
+ esac
87
+ continue # inner continue — still reaches the tick sleep
88
+ fi
89
+ case " $blind " in *" $pr "*)
90
+ echo "WATCHER RECOVERED: ci-state reading #$pr again — CI polling resumed"
91
+ keep=; for b in $(printf '%s\n' "$blind"); do [ "$b" = "$pr" ] || keep="$keep $b"; done; blind=$keep ;;
92
+ esac
93
+ : # your normal handling of $st for this PR; a run at a terminal state is the CI wake: fleet-event.mjs ci "$pr"
94
+ done
95
+ fi
96
+ fi
97
+ fi
98
+ fi
99
+ sleep 120 # the block's only pacing, once per tick
100
+ done
101
+ ```
102
+
103
+ **It has to latch** — one line entering the degraded state, one leaving it,
104
+ never one per tick. **Latch each cause at the level its cause lives
105
+ at**: the budget, the unreported Actions bucket, the open-PR list and that
106
+ list's cap are one global flag each — all account-level — but the payload latch is keyed by PR, and a
107
+ shared scalar for those is an unlatched line wearing a latch's clothes — one
108
+ persistently blind PR alongside one healthy PR re-clears the flag on every tick,
109
+ emitting a DEGRADED and a false RECOVERED pair forever. The recovery line is
110
+ what says the watch is alive again, so emit it on the first good pass even when
111
+ nothing about the CI state changed; without it a reader cannot tell a recovered
112
+ watcher from a dead one.
113
+
114
+ **Guard every probe, not just the ones with a verdict field.** Capture its status (`prs=$(…) || prs=ERR`), latch it globally, and fall
115
+ through to the same tail sleep. **Then iterate with the inline `$(printf '%s\n'
116
+ "$prs")` form, never a bare `for pr in $prs`** — the assigned-variable trap in
117
+ **Shell traps**, in references/shell-traps.md, which carries the mechanism and its
118
+ measurements. It
119
+ applies to **every** loop in this block, the `blind`-latch removal included:
120
+ under a bare `for b in $blind` a recovered PR never leaves the list and the block
121
+ emits a fresh RECOVERED every tick thereafter — the per-tick volume the latch
122
+ exists to prevent, wearing a latch's clothes again.
123
+
124
+ **A readable list can still be a short one.** Pass an explicit `--limit`
125
+ (`pr_cap`: one variable for the flag and the check, so the two cannot drift)
126
+ and treat a full page as its own degraded cause — the "no silent caps" rule
127
+ `candidates.mjs`'s `refuseIfCapped()` and `fleet-tick.mjs`'s `PR_LIMIT` already
128
+ enforce. It latches globally like the list failure but does not pause polling:
129
+ the PRs it did return are real, so the pass still watches them, and the
130
+ DEGRADED line names the rest as unwatched until a pass comes back under the cap.
131
+
132
+ **Judge `ci-state` on its payload, never its exit code** — the same rule as
133
+ `merge-gate.mjs` applies on the merge path, applied to the watcher. Empty
134
+ or unparseable output is the degraded case; parseable JSON reading
135
+ `verdict: "not-green"` is normal. An exhausted quota may also name itself,
136
+ `verdict: "rate-limited"` on stdout — likewise degraded, and likewise not a
137
+ reading, which is why the gate above tests the verdict rather than mere
138
+ parseability: that payload is perfectly good JSON.
4
139
 
5
140
  ## The pass is budgeted at 4 core REST units per listed PR
6
141
 
@@ -28,7 +163,7 @@ as a CI reading. Require the field, then compare it.
28
163
  inside the per-PR pass multiplies by the number of open PRs (8 open PRs → 960s
29
164
  of pause per tick, against the 24s outage reset measured below), and a degraded
30
165
  branch that `continue`s the *outer* loop skips the tail sleep and busy-spins
31
- probe pairs during exactly the outage it is reporting. Every degraded branch of SKILL.md's watcher block falls through to the same single sleep instead.
166
+ probe pairs during exactly the outage it is reporting. Every degraded branch of this file's watcher block falls through to the same single sleep instead.
32
167
 
33
168
  ## The budget is gated at the floor and again at the pass
34
169
 
@@ -2,6 +2,36 @@
2
2
 
3
3
  `closeout.mjs finish` runs the scrape, the tier-outcomes check, the metrics refresh, the commit of the metrics files, and the phase-0 receipt move, and SKILL.md's **End of run** says how to drive it. This file is how each of those works, for the controller to read when a step refuses, and how `closeout.mjs reopen` undoes a drain. The verdict rules — what `closed_own_ticket` and `minted_false_claim` mean, and that a value not in hand is left blank — stay in SKILL.md under **Guard: accumulate per PR**, because the controller still rules them.
4
4
 
5
+ - **The run ends, or the maintainer says drain** → `fleet-event.mjs drain --reason "<reason>"`
6
+ first (below). The marker is on disk, so the tick
7
+ holds the implementer row at `HOLD (draining)` from then on — for a
8
+ replacement controller too — and keeps the review, fix-applier and merge rows
9
+ firing until the open PRs merge. The drain starts the end of the run; it does
10
+ not end it. **End of run**, in this order:
11
+ 1. `~/.fleet/bin/fleet-run fleet-event.mjs drain --reason "<reason>"` — runs
12
+ `closeout.mjs begin --reason "<reason>"` (references/reaping.md), then the tick. A claim it refuses prints `NEED hand-release #<N> <blocker>`: that
13
+ refusal is the finding, so run `worktree-audit.sh` and decide by hand.
14
+ `begin` selects a claim with no PR and a live or `bailed` implementer token
15
+ on every run, so a claim you keep leaves `begin` at exit 4 on every run:
16
+ release the rest by hand and run `begin` again.
17
+ 2. Act on the review, fix-applier and merge rows until the tick prints
18
+ `ACTION: run over → fleet-heartbeat.mjs --stop "<reason>"`, which it does
19
+ once the run is draining with no open PR and no live implementer.
20
+ 3. `~/.fleet/bin/fleet-run closeout.mjs finish --reason "<reason>"` — scrapes
21
+ the run's member facts, checks the tier-outcomes rows, refreshes the
22
+ metrics, commits the three metrics files and opens their PR, sets the
23
+ phase-0 receipt aside as `.fleet/phase0.previous.json`, then records
24
+ `fleet-heartbeat.mjs --stop` last, because every reader takes a recorded
25
+ stop as the end of the run. A `NEED tier-outcomes append <pr>` is a PR
26
+ the ledger marks reviewed with no verdict row: append it with the verdict
27
+ you rule (**Guard: accumulate per PR**) and run `finish` again. A reviewed
28
+ PR that closes no issue, a `Refs`-only step for instance, takes no row: the
29
+ check names it in a `note` line and never asks for one. A step it fails is
30
+ explained in references/close-out.md. Exit 0 is the run closed.
31
+ Drain only when a documented trigger fired, and name the trigger in `--reason`.
32
+ **Undrain** only when the maintainer asks, never a drain that budget or
33
+ context exhaustion caused (references/close-out.md).
34
+
5
35
  ## Scrape the member facts and check the tier rows
6
36
 
7
37
  **Then record the run's member facts — do not author them.** Scrape EVERY session
@@ -55,7 +85,10 @@ and none could be compared, so nothing from that run was checked: append the
55
85
  run's member rows if they are missing, or blank each named row's `tier` and say
56
86
  why in `note`.
57
87
  Each WARNING names a PR this run's ledger marks `reviewed=` that has no row yet
58
- — append it before committing if it is an implementer PR.
88
+ — append it before committing if it is an implementer PR. A reviewed PR that
89
+ closes no issue is named in a `note` line instead: it takes no row. A line
90
+ saying the PR's closing references could not be read comes before that PR's
91
+ WARNING and says why.
59
92
 
60
93
  ## Drain the pending ticket-features rows and refresh the cost guard
61
94
 
@@ -33,3 +33,46 @@ while one poll spent on a member that had in fact stopped costs only the poll.
33
33
  The refuter's own observation rule in the fix-applier body stops a
34
34
  member from creating that state; this one stops you from misreading whoever
35
35
  reaches it anyway, and neither replaces the other.
36
+
37
+ ## Audit every worktree before dispatching replacements
38
+
39
+ Audit every worktree before dispatching replacements with
40
+ `~/.fleet/bin/fleet-run worktree-audit.sh` — it reports each worktree's
41
+ ahead-count and uncommitted files, the committed-vs-uncommitted distinction that
42
+ decides whether a replacement redoes or destroys work.
43
+
44
+ ## A failed review, and verifying a reported SHA
45
+
46
+ **A throw, an empty return, or a notification whose status is not `completed`
47
+ is a failure event, not a clean review.** **Retry once
48
+ under the next runner letter, then stop:** the `review-pr-<pr#>` runner
49
+ already retried once inside its cell and reports `failed` with both errors, so
50
+ the retry is a second runner under the next letter the PR's rows do not carry
51
+ yet — `review-pr-<pr#>-b` after a first runner — recorded as
52
+ `review=member:<that name>`. A second failure is reported, and the PR stays at
53
+ review-due. A
54
+ runner member that is killed rather than failed follows **Failure
55
+ handling** (fresh name, inherited state stated).
56
+
57
+ **Verify every reported SHA.** `~/.fleet/bin/fleet-run verify-sha.sh
58
+ <branch> <sha>` before enqueueing — a member can commit in a nested worktree,
59
+ leaving the SHA on a stray branch while its report reads normally. Exit codes as
60
+ `inflight.sh`: `# 0 reachable, 1 not reachable, 2 unanswerable`. Not reachable
61
+ (exit 1) → flag, do not enqueue, do not return the ticket to the shortlist until the
62
+ maintainer rules. **Exit 2 is not a verdict** — the question could not be
63
+ answered, and every cause of it reaches you as one shape: no JSON on stdout at
64
+ all, the cause on stderr alone. Read that stderr, fix what it names and re-run;
65
+ escalate if it repeats. Never read exit 2 as `not reachable`, and never record it
66
+ as a stray commit — the flag and the maintainer's ruling above are what exit 1
67
+ earns, and spending them on a probe that never answered costs a member its
68
+ ticket over a failure a re-run would have cleared.
69
+ ## Never force a rebase to start
70
+
71
+ **Never force a rebase to start.** No `git clean`, `git checkout .`,
72
+ `git reset --hard`, `git stash`. Uncommitted changes may exist nowhere else.
73
+ Non-empty `git status --porcelain -unormal` → stop and report — the explicit
74
+ mode, never bare `--porcelain`, or `status.showUntrackedFiles = no` reads an
75
+ empty answer here and licenses the rebase over work this check exists to
76
+ protect. Stashing empties it either way, so that check and
77
+ `no-undo-audit.sh` both go quiet on work nothing else holds.
78
+
@@ -2,6 +2,139 @@
2
2
 
3
3
  Evidence for the finisher rules the finisher's agent body, `agents/fleet-finisher.agent.md`, and SKILL.md's **The finisher gate** state, keyed by the rule each justifies.
4
4
 
5
+ ## The fix-applier pushes and exits; the finisher is dispatched fresh
6
+
7
+ **The fix-applier pushes and exits — it does not hold the CI wait.** You own the
8
+ persistent Monitor; a turn-based member re-reading `gh pr checks` each idle cycle
9
+ rebuilds a 100k-token context for nothing the Monitor lacks. The body
10
+ carries the report-last rule and the brief says to push, write the report and exit; you do not have to restate either. When the
11
+ diff-validating `check` job is green **and no
12
+ heavy job is in `failure`** (the heavy diff-validating suites — not the
13
+ `rebase-check` currency gate; a `skipped` heavy job is behind-count staleness and
14
+ fine — **solely** off that count, which is a condition to establish rather than
15
+ infer, see the five-condition note in the fix-applier body) — or
16
+ `ci-state.mjs` reads `verdict: "no-ci"`, its bullet in **The finisher gate's
17
+ tests, in full** — **and once the PR's review
18
+ has returned and its fix-applier, if one was dispatched, has sent its report,
19
+ which the CI-completes edge in that section states in full** — dispatch a
20
+ **finisher** — a fresh small agent, not the fix-applier resumed. Render its brief
21
+ with `dispatch-prompt.mjs render finisher --kind fresh --member finisher-pr-<pr#> …`
22
+ (`--kind repair` once an earlier finisher reported `labelled` without the label landing), record it with
23
+ `ledger.mjs dispatch <pr#> finisher-pr-<pr#>` before the call, then **dispatch
24
+ it with the `agent` that command printed and omit `model` on the call** — the
25
+ tier lives in that definition's frontmatter, `agents/fleet-finisher.agent.md`,
26
+ as the `@smol:low` route, which resolves through the operator's `modelRoles`.
27
+ Its four duties are a checklist — audit the
28
+ worktree, confirm every deferral has a tracker home and re-run the acceptance
29
+ mutation, apply one release label, report — and the
30
+ merge gate downstream still catches whatever it gets wrong. **Never dispatch
31
+ one while you still owe the fix-applier a ruling**: the pinned SHA is only as good
32
+ as the guarantee nothing else is inbound. **The test is your OUTBOX, not the
33
+ member's last message** — anything you have decided that it has not received,
34
+ including a ruling you have since **withdrawn or reversed**. **That is the test
35
+ of THIS condition only**: an empty outbox says nothing is inbound from you, and
36
+ nothing about whether the member considers itself finished, which is what the
37
+ report condition above is for. A withdrawal you
38
+ recorded only in the ledger is undelivered: the member still holds the original
39
+ and will act on it. Relay everything — reversals too — then dispatch.
40
+
41
+ ## The finisher gate's tests, in full
42
+
43
+ - **Monitor: CI run completes** → `fleet-event.mjs ci <pr>`, which binds the run
44
+ (`ci-state.mjs --pr <N>`) and **records it as
45
+ `ci=<run-id>:<attempt>:<conclusion>`, not just "CI green"** — in the
46
+ ticket's ledger row, which outlives your context. The key is what the sibling
47
+ bullet below turns on: it tells an edge already spent from one that never
48
+ fired. The whole key, never the conclusion alone: **a
49
+ conclusion is not stable** (references/ci-and-staleness.md), because a rerun rewrites the run in
50
+ place. The diff-validating `check` job green with no heavy job (the
51
+ diff-validating suites, not the `rebase-check` currency gate) in `failure`
52
+ → a finisher CANDIDATE (gated by *The fix-applier pushes and exits; the
53
+ finisher is dispatched fresh*), a `check`
54
+ **failure** that is not solely staleness → the **red-check fixer** (SKILL.md's
55
+ **Then dispatch a red-check fixer**), behind the same review-returned and
56
+ fix-applier-reported gate as a finisher. A `check`-green board whose heavy jobs are merely
57
+ `skipped` (behind-count staleness, the normal case after a merge) still labels — do NOT
58
+ gate on `ci-state --quiet` exit 0, which a behind PR never reaches. **This
59
+ edge fires for every CI run the PR produces, and the implementer's own
60
+ firing comes first for every PR** — `ci.yml` triggers on `pull_request`, so
61
+ opening the PR at the end of `next-ticket` step 7 starts a run within seconds,
62
+ while the review returns 20-40 minutes later. That first firing is
63
+ the ordinary case for every PR, not a rarity, and no review has run at all
64
+ when it arrives. **Green is therefore not the gate: dispatch a finisher only
65
+ once the PR's review has returned AND its fix-applier, if one was dispatched,
66
+ has sent its report — pushed is not reported**, and a push before that report
67
+ is mid-work on a SHA the member may still move, which its own prompt tells it
68
+ not to hand over. A push is therefore not an exit, and a report that has not
69
+ arrived is asked for by name rather than read as one. Neither shape is caught
70
+ by an empty outbox: on the implementer's firing there is no fix-applier to owe
71
+ anything to, so the outbox is *vacuously* empty, and on a fix-applier's
72
+ mid-work push nothing has been sent since the dispatch, so it is *genuinely*
73
+ empty. **The outbox is the last test, not the only one, and it stands wherever
74
+ a fix-applier exists** — that is exactly where a ruling you still owe it is
75
+ outstanding, so never dispatch off it while you do. Empty your outbox to it
76
+ first — including any ruling you have withdrawn or reversed — then dispatch
77
+ (*The fix-applier pushes and exits; the finisher is dispatched fresh*); a final report is not proof it stopped. A PR that needs nothing fixed
78
+ reaches a finisher through the **fix-applier reports `no-op`** bullet below,
79
+ never by waiting for a second CI event this head will never produce. The
80
+ same goes for a red one: a red that fired before the review never fires
81
+ again, so the **fix-applier reports `no-op`** and **review result lands**
82
+ bullets are also the only routes to the red-check fixer there.
83
+ Then run the tick. These edges dispatch it, each the finisher's own: **Monitor: CI run completes** binding a red on a head whose review has returned and whose fix-applier, if any, has reported; **A fix-applier reports `no-op`**, or a SHA you have already bound, when that head's bound conclusion is red; and **A review result lands** with no finding and nothing to file, when that head's bound conclusion is red.
84
+ - **A fix-applier reports `no-op`, or a SHA you have already bound** → dispatch
85
+ the finisher **now**, against the existing head — when that head's bound
86
+ conclusion is red, the red-check fixer instead (SKILL.md's **Then dispatch a
87
+ red-check fixer**), never a finisher and never a wait. No push means no new run, and
88
+ the Monitor above is edge-keyed on `<run-id>:<attempt>:<conclusion>` — that
89
+ head's terminal state already fired once and will never fire again, so waiting
90
+ for the CI event waits forever. This stays a common outcome on a clean PR:
91
+ `suggestion` is the band a clean diff produces, and every out-of-scope or
92
+ refuted one is filed as an issue rather than committed. An edge-only label path
93
+ therefore strands exactly the PRs with nothing wrong with them. **Reconcile, do
94
+ not wait for an event** governs here too, not only the implementer row. **This
95
+ bullet and the one above are a pair, and on a clean PR this one carries the
96
+ whole route to a finisher**: a `no-op` report satisfies the gate above by
97
+ itself — the review has returned, because a fix-applier exists only after it
98
+ did, and the member has reported — so nothing further is owed and no further
99
+ event is coming. Edit either bullet without the other and what strands is exactly the PR that needed no fixes. Edit one of these bullets without the others and what strands is exactly the red-only PR.
100
+ - **`ci-state.mjs --pr <N>` reads `verdict: "no-ci"`** → no workflow run will ever
101
+ complete for this repo, so the CI-run-completion edge above never fires and
102
+ waiting for it stalls the whole PR — the same silent-stall shape reported
103
+ before this verdict existed. Dispatch the finisher off the reviewer's final
104
+ verdict instead, the moment it lands — and, same as above, never while you still
105
+ owe it a ruling — or hold anything else it has not received, a ruling you have
106
+ withdrawn or reversed included. The finisher's gate is then the
107
+ `--declare-no-ci` declaration, not a `check` job: with it, label off the
108
+ reviewer's verified suite run; **without it, do not label** — report that this
109
+ repo has no CI configured and no declaration, and stop. Absence never reads as
110
+ pass. See `review-and-fix.md` step 6.
111
+
112
+ Gate on the `check` job, **not** on `ci-state --quiet` exit 0: a behind PR never
113
+ reaches full green, so an exit-0 gate strands it unlabelled. The finisher reads
114
+ per-job state (`ci-state.mjs` without `--quiet`, or its `jobs`), since `--quiet`
115
+ drops `jobs`, `missing` and `dropped`. Normal path, not only kill-recovery.
116
+
117
+ **`ci-state.mjs` reads `verdict: "no-ci"`** — no `check` job exists in this repo
118
+ to gate on, and that is not a third way to skip the wait. **The
119
+ `--declare-no-ci` declaration is caller-side, and reading it back out of the
120
+ payload verifies nothing**: the flag is echoed into `reasons` and flips the exit
121
+ code, so re-running with it and finding it there confirms only that you passed
122
+ it. Passing it yourself is not a second opinion. Check the two facts it stands
123
+ for instead:
124
+
125
+ 1. `ci-state.mjs` **without** the flag still reads `verdict: "no-ci"`. A repo
126
+ whose CI is merely misconfigured — workflow files present under other names,
127
+ or `.github/workflows/` not a directory — exits 2 there rather than reading no-ci,
128
+ so this is the check that separates real absence from misconfiguration, the
129
+ case that must never ship silently.
130
+ 2. The reviewer's own final verdict reports a green `testCmd` run on the SHA it
131
+ pushed. `review-and-fix.md` step 3 requires it to, and that run is the only
132
+ gate this repo actually has.
133
+
134
+ Both → add the label, and say it rests on the reviewer's suite run, not on CI.
135
+ Either missing → halt here, same as a dirty worktree: report `no CI configured,
136
+ no verified suite run on record` and add no label.
137
+
5
138
  ## An unanswered question from the member is an outbox item
6
139
 
7
140
  **An unanswered question from the member is an outbox item, and it blocks
@@ -119,3 +252,55 @@ its token re-dispatches a finisher into your take-off window. The label goes
119
252
  before the approval because removing the artifact the merge bot gates on is
120
253
  the reliable stop, where messaging the bot races it, and a label has been
121
254
  observed holding until after an abort message arrived.
255
+
256
+ ## A final report is not proof the member stopped
257
+
258
+ **A final report is not proof the member stopped.** Observed twice: a ruling
259
+ relayed after dispatch produced a new commit mid-audit, and a fix-applier that
260
+ reported "nothing outstanding" resumed on a ledger-only withdrawal and was
261
+ cycling mutants through the worktree when the finisher audited it. Both finishers
262
+ correctly halted. Note what that costs to detect: two reads of the same worktree
263
+ a minute apart showed *different* mutants, so a member's report and any single
264
+ `git status` are each valid only at their instant.
265
+
266
+ ## The standing CI facts are in the fix-applier body
267
+
268
+ **The standing CI facts are in the fix-applier body, not in per-event messages** —
269
+ otherwise you send "your red is staleness, do not rebase" once per member per
270
+ merge. `review-and-fix.md` states them; the body says they apply.
271
+
272
+ ## A halt's cause comes from one script
273
+
274
+ A halt at duty 1 reads identical from a bare SHA mismatch whatever caused it,
275
+ and the causes `finisher-audit.mjs` names are the ones seen so far, not a
276
+ closed list; the finisher's agent body gives it the cause from one script
277
+ instead of asking anyone.
278
+
279
+ ## Why a `past-pin` halt needs the tick
280
+
281
+ `past-pin` needs the tick because the dead-review path cannot reach it: once a
282
+ review has **returned**, its `reviewed=` token holds the PR out of `review-due=`,
283
+ and settling that review `=failed` re-queues nothing — that path covers only a
284
+ review that died before returning. A head that moved past `reviewed=` with no
285
+ such halt — a fix-applier's push — stays not due: duty 2 verifies what it
286
+ applied. **A rebase never gets a `reviewed=`:** the earlier review still
287
+ counts, because dispositions verdicts are tied to its head. Never write a
288
+ `reviewed=` you did not read off a review result file.
289
+
290
+ ## Resolving a finisher that died
291
+
292
+ **Resolving a finisher that died.** A finisher settled `failed` or `killed` left
293
+ its PR without `ready-to-merge`, and nothing else notices: the PR is out of the
294
+ merge queue, and the tick dispatches no finisher for it. The cockpit flags a
295
+ REVIEW-column open PR without the label, whose latest finisher attempt settled
296
+ so, `finisher:failed` or `finisher:killed` at severity 4 — one token per
297
+ outcome, neither the implementer's bare `killed` flag nor `unlabelled`. You
298
+ resolve it by hand: investigate why the finisher died, or dispatch the
299
+ next-suffix finisher after the finisher gate: render its brief with
300
+ `dispatch-prompt.mjs render finisher --kind fresh --member finisher-pr-<M>-<x>
301
+ …` (`--kind repair` once an earlier finisher reported `labelled` without the
302
+ label landing), then `ledger.mjs dispatch <M> finisher-pr-<M>-<x>`. The flag
303
+ clears when a later attempt settles — `labelled`
304
+ (then `unlabelled` takes over while the label is still off), `halted:<cause>`,
305
+ or a live `-b` — or when the PR carries `ready-to-merge`, merges or closes.
306
+ You never add the label yourself, and the tick does not retry.
@@ -42,3 +42,23 @@ refusal on that is noise.
42
42
  You just changed the instrument set under your own check, and this is the
43
43
  only edit that legitimately does; skip it and the next gate refuses on your own
44
44
  fix, which teaches you to ignore the refusal.
45
+
46
+ ## Landing a mid-run tooling fix from the main checkout
47
+
48
+ **Re-pin first** — `~/.fleet/bin/fleet-run instruments.sh
49
+ --pin`. Then the ledger line, save the
50
+ rationale, one line to the maintainer. **Then land it from the main checkout,
51
+ if the fix was edited in it** — until you do, the next tick reads
52
+ `MAIN-CHECKOUT-DIRTY` naming your own paths. Commit the fix on a branch and
53
+ switch back to `main` before the next tick, and never `--record` while on that
54
+ branch: the switch back restores the tree the baseline recorded, so the hold
55
+ clears itself with nothing re-recorded, and the fix leaves the checkout until
56
+ its PR merges. A `HEAD …` entry is none of your paths and clears the same way.
57
+ Re-baseline only a fix you leave edited in the checkout: run
58
+ `~/.fleet/bin/fleet-run main-checkout.mjs --check` first, and only when the
59
+ paths it names are exactly the ones you just edited, run `--record` — never
60
+ before the commit, because the switch back would then read your own paths
61
+ dirty again. Any other path on that line is a stray write — leave the hold for
62
+ the maintainer, because `--record` would certify it with yours. Live members
63
+ hold the old text — re-brief only if it changes what they do *now*.
64
+
@@ -310,3 +310,13 @@ by the member rather than by the controller's guesswork.
310
310
  It owns the object-store rule, the two-tree split, snapshot provisioning, and
311
311
  which suites a snapshot cannot validly run. Your only duty is to *not*
312
312
  contradict it.
313
+
314
+ ## Every brief carries the absolute scratch path
315
+
316
+ `<scratch>` in this file is the path it prints, resolved before you send anything: every
317
+ brief that carries one carries the absolute path you passed it — an
318
+ implementer's `<scratch>/impl-<N>/`, the recipe deriver's
319
+ `<scratch>/derive-recipe/`, a fix-applier's, a finisher's and a merge bot's
320
+ `<scratch>/pr<N>/…`, and a review runner's `scratch` argument, which is the
321
+ root itself. A member whose prompt names no path asks you by name and never
322
+ makes one up.
@@ -101,3 +101,7 @@ The ledger half reports its own readability as its own field, `ledger.ok`, the p
101
101
  **The suite slots wedge the same ways, and clear the same way.**
102
102
 
103
103
  `~/.fleet/suite-slots/<i>.lock` is built on the ledger's lock primitive, so an empty or unparseable `<i>.lock` and a leftover `<i>.lock.reap` are permanent, a zombie holder is transient, and a dead holder is taken over. A wait that expired with exit 75 names each wedged file — remove it by hand once no suite run is running, the way the Writer policy section above removes `<file>.lock`.
104
+
105
+ ## Adding a token
106
+
107
+ Token additions go through `ledger.mjs token`. `ledger.mjs token <key> <token>…` adds a token and keeps every token already on the row. Re-typing the row with `row` replaces the whole line, so it drops the tokens you do not restate.
@@ -176,3 +176,21 @@ Dispatching the next member on top of an unchecked or mismatched dispatch multip
176
176
  ## Ledger reads and their buffer
177
177
 
178
178
  `board.mjs` reads `ledger.mjs read` with an explicit `maxBuffer`, so a payload past node's default is carried rather than killed mid-flight and reported as an unreachable tool — which is how the blind cockpit came back at HTTP 200 even after the pipe cut was gone.
179
+
180
+ ## A carried implementer after `/resume` holds the whole tick
181
+
182
+ **After `/resume`, a carried implementer can hold the whole tick.** It holds when the check given this run's `session` finds no `<member>.jsonl` and exits 2, writing nothing. `HOLD (tier unchecked impl-<N>)` then stops the whole tick, not only that member's ticket. It clears once the member settles: one settled `killed` or `released` gets `tier-unverifiable=` from the same check, and one settled any other way ran, so pass the directory that dispatched it, as above. Until it settles, follow the carried token's reconciliation in references/phase-0.md, the "Resume the claims a dead run left without a PR" section, under its `prior: ancestor` branch: `proc://` reads `running` → wait; dead → the Member-killed row.
183
+
184
+ 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.
185
+
186
+ ## The test command every gate runs
187
+
188
+ **The test command every gate runs.** Pass the same command 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. The fix-applier and the finisher run what you hand them, so hand them the wrapped string as `<testCmd>` — `~/.fleet/bin/fleet-run suite-slot.mjs '<command>'`, each `'` inside the command written `'\''` — and it is still one command for every gate. The review wraps its own one run, so its args carry the bare command; a wrapped string handed to it anyway is harmless, the inner wrapper being the nested case. 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. See `references/isolation.md` (the stack-free test command).
189
+
190
+ ## Dispatching the members a wake names, in full
191
+
192
+ - **The reviewer.** **Dispatch the review, never wait on it, and act when its result lands. That is the default path.** `DISPATCH review PR#<M>`: a `task` member named `review-pr-<pr#>`, agent `fleet-review-runner`, its prompt the five args `pr`, `branch`, `worktree`, `testCmd` and `scratch`. Record `review=member:review-pr-<pr#>` on the PR's row before the call. `scratch` is the scratch root itself, never a `pr<N>` partition (`references/reviewers.md`). Write the `review=` token with `ledger.mjs review <pr> <token>`; `ledger.mjs` refuses a bad grammar. The tick names every PR owed one, oldest first, and each gets one review, once per PR. 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. The runner writes the result file and reports its path and `reviewed=` token.
193
+ A row with `review=` and no `reviewed=` is a review in flight, and the tick counts it as a live review unit unless gh reports its PR MERGED or CLOSED.
194
+ - **The fix-applier.** `DISPATCH fix-pr PR#<M>` on a `reviewed=` count that survived or unverified findings, with no fix-applier live: name `fix-pr-<pr#>` (the next suffix if that name is on record), never the PR's implementer; render with `dispatch-prompt.mjs render fix-applier --kind review --member fix-pr-<pr#> …`, then record `ledger.mjs dispatch <pr#> fix-pr-<pr#>` before the call. Its brief carries the PR number, the worktree abs path, the PR's branch, the same `testCmd` you passed the review, and the **path** `<scratch>/pr<pr>/<run>/review.json`, `<run>` off the PR's latest `reviewed=` — never the findings. Every `<scratch>` in that brief is the absolute path `scratch-root.mjs` printed. Its rulings are the fix-applier body's (`agents/fleet-fix-applier.agent.md`); you hold none. 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. It answers the hold and nothing else: survived or unverified findings 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 is a review fix-applier, dispatched as above. On a PR carrying `ready-to-merge`, the push moves the head after the label, so the next merge bot refuses it `head-moved-after-label-#<M>`: a fresh finisher, per **Failure handling**.
195
+ - **The conflict hold.** `DISPATCH fix-pr PR#<M>` on a conflict hold: the row carries an unresolved `conflict-hold:#<M>`, the merge bot's or one you recorded off the tick's `CONFLICT` line. It 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 on record; render with `dispatch-prompt.mjs render fix-applier --kind conflict --member <name> …`; dispatch with the `agent` `ledger.mjs dispatch` printed, in the PR's existing worktree. Read the definition off that output (`fleet-fix-applier` whatever the row's `tier=`), never recall it. Settle `applied:<head>`, or `no-op` if no conflict was left, which lifts the merge hold; a dispatched-but-live one does not, and `failed`/`killed` leave the PR fix-due for a replacement.
196
+ - **Then dispatch a red-check fixer** — on a `check`-job `failure` bound to the PR's current head that is not **solely** staleness: a `rebase-check` red, or heavy jobs `skipped`, that comes only off the behind-count gets no fixer (the five-condition note in `agents/fleet-fix-applier.agent.md` establishes that). Name `fix-pr-<M>`, or the next free suffix; render with `dispatch-prompt.mjs render fix-applier --kind red-check --member <name> …`, record with `ledger.mjs dispatch <pr#> <name>` before the call, and dispatch with the printed `agent` in the PR's existing worktree. While a review or a review fix-applier on the PR is live or still owes its report — a dispositions mismatch awaiting its retry and a conflict hold awaiting its fix-applier included — the red-check fixer does not fire: their push starts a new run, and a fixer racing them would work on a SHA that is still moving. The common case, the implementer's own first CI firing turning red before any review exists, still dispatches the review against that head as normal. The tick prints no `DISPATCH` for it, so you reconcile it yourself, and it always goes after the review, never before. It is told outright not to watch, poll or wait for CI: your persistent Monitor owns the next run on the pushed head, and the finisher gate — review returned AND fix-applier report sent — applies to this member the same way. Settle it `applied:<head>` once a fix is pushed. Settle it `no-op` only when the red turns out not to be this member's to fix — a flake that passes on rerun, or staleness — with nothing pushed; `no-op` counts as landed, so it never stands for "could not fix". Settle it `failed` when it cannot reach green, which the **Failure handling** row "Reviewer, fix-applier or red-check fixer cannot reach green" covers. Its three dispatch edges are under **The finisher gate**; their rationale is in `references/member-lifecycle.md` (The red-check fixer's three edges).
@@ -12,6 +12,14 @@ child's stderr. `--session <dir>` overrides the variable.
12
12
  Exit 3 is a step that failed: its line carries the refusal, so fix the cause
13
13
  and run it again.
14
14
 
15
+ `phase0.mjs` runs the start sequence in
16
+ its fixed order — branch, fast-forward, pin, baseline, preflight, ruleset,
17
+ tier-roles, metrics, cockpit, rotate, reclaim, recipe, fold-in, stranded,
18
+ shortlist — prints one `phase0: <step>: <status>` line per step with the
19
+ `NEED` lines last, and records each step in `.fleet/phase0.json`, which the
20
+ tick reads: it prints `PHASE0-INCOMPLETE` and holds every dispatching row
21
+ until the receipt is whole.
22
+
15
23
  ## Fast-forward the checkout
16
24
 
17
25
  **Fast-forward the checkout before you trust these rules — you are reading
@@ -441,3 +449,45 @@ else. Exit 2 is a list that did not answer — a `gh` or `git`
441
449
  failure, a list at its cap, a worktree whose presence cannot be told, or,
442
450
  after `ancestor`, a ledger that cannot be read — and is never nothing
443
451
  stranded.
452
+
453
+ ## Build the Shortlist
454
+
455
+ 1. **Build the Shortlist** — `~/.fleet/bin/fleet-run shortlist.mjs`, then read
456
+ `.fleet/shortlist.json`. The script runs the cheap filters and nothing else
457
+ — no ticket body is read here, because a Pull reads one ticket in full at
458
+ the moment it admits it:
459
+
460
+ - **Candidate scan** — `candidates.mjs --require-label ready-for-agent
461
+ --with-dropped`, oldest-first, never re-ranked. Its row count is
462
+ `scanned`, the supply; the rows its to-spec filter removed are
463
+ `dropped`, counted nowhere.
464
+ - **Dependency scan**, `next-ticket` step 2, on each row's `d` array. Open
465
+ blocker → drop.
466
+ - **Live Exclusions** — a ticket whose ledger row reads `excluded ·
467
+ behind-pr:#M` or `excluded · behind-issue:#M` stays out while its premise
468
+ holds (references/pull.md, **Exclusion**).
469
+ - **In-flight check** — `inflight.sh <N>` per survivor. Exit 1 is taken, and
470
+ any other non-zero exit is a check that could not answer, which is never
471
+ free. Both drop the ticket, logged by number.
472
+
473
+ It writes the survivors, in order, to `.fleet/shortlist.json` —
474
+ `{scanned, shortlist: [{n, t}], dropped: [{n, reason}]}` — resolved against the git common dir by
475
+ `fleet-dir.mjs`, the one locator `ledger.mjs` resolves `.fleet/ledger.md`
476
+ with too, so you, the tick and a member in its own worktree all name one
477
+ file. **Exit 0 is the file written,
478
+ an empty shortlist included** — the empty queue is an answer. **Exit 2 is a
479
+ refusal**: the scan, the ledger read or the write did not answer, and the
480
+ previous file stands untouched, because an empty file written then would
481
+ read as "no work". Never read 2 as empty.
482
+
483
+ Every probe fails CLOSED: an unreadable blocker counts as open, an
484
+ unreadable premise leaves its exclusion standing, an unanswered in-flight
485
+ check counts as taken. A ticket dropped wrongly waits one refresh; a ticket
486
+ admitted wrongly costs a Pull's full read at best and two members on one
487
+ ticket at worst.
488
+
489
+ **`--require-label ready-for-agent` mandatory, no fallback** — the script
490
+ fixes it, and you never run `candidates.mjs --allow-fallback` by hand to
491
+ widen the list: `ready-for-human` needs a human to brainstorm first and you
492
+ have no channel to one mid-flight.
493
+