@feigi/fleet-ctl 3.89.3 → 3.91.0

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.
@@ -48,7 +48,7 @@ it.** For each one, first decide scope: is it inside the scope of the PR's own
48
48
  ticket, or a different piece of work? **Out of scope → defer and file, never
49
49
  apply** — but the claim bar runs first: an out-of-scope suggestion whose
50
50
  claim is about shape (`claimKind: shape`) gets no refuter and goes to the
51
- closed suggestion-band record, and every other one gets the same refuter an
51
+ suggestion-band record, and every other one gets the same refuter an
52
52
  in-scope one does, dispatched the same way, before it is filed.
53
53
  **In scope → dispatch ONE refuter** against the finding before
54
54
  touching the tree, biased to refuse, and hand it the prompt below with one
@@ -152,7 +152,7 @@ refuter you never hear from leaves the finding exactly as unchecked as it
152
152
  arrived, so it defers like a refuted one.
153
153
 
154
154
  **Out of scope, the refuter decides only where the suggestion is filed — it
155
- defers either way, never applied.** Refuted → the closed suggestion-band
155
+ defers either way, never applied.** Refuted → the suggestion-band
156
156
  record, never an open issue. Survived, or its refuter crashed or abstained →
157
157
  its own open issue labelled `needs-triage`, citing the verdict, or the run
158
158
  that crashed, in the body. There is no cap on these refuters: your
@@ -323,7 +323,7 @@ PR to a human instead of passing. Any
323
323
  other reason, none, or a `survived` or `unverified` finding with no entry is
324
324
  a mismatch, and the PR gets no finisher. `disposition` has no `dismiss`: an
325
325
  in-scope `survived` finding verified correct, needing no change, is deferred
326
- with `reason` `false-rationale` and filed on the closed suggestion-band
326
+ with `reason` `false-rationale` and filed on the suggestion-band
327
327
  record.
328
328
 
329
329
  Then write your report to the file your brief names,
@@ -10,14 +10,14 @@ argument-hint: [pr-number]
10
10
  `fleet-review-<key>` (e.g. `fleet-review-correctness`).
11
11
 
12
12
  See **Specialists** — those constraints are not optional.
13
- 2. Plan the actions. Split explicitly into **apply now** / **defer**. A finding is deferred, never dropped. **Every `suggestion` — the review's `severity`, the plugin's *Suggestions* bucket — arrives unchecked: the review budgets that band 0 refuters, so nothing has looked at it yet.** Checking it is now your job, not nobody's. So split it by SCOPE first, and check it in either scope. Out of the PR's ticket scope → defer and file, but **the claim bar runs first:** a suggestion whose claim is about shape — step 5's worth-a-claim bar, recorded `claimKind: shape` — gets no refuter and goes to the closed `PR #<pr> review: the suggestion band, checked` record, and every other out-of-scope suggestion gets the same refuter an in-scope one does, dispatched the same way, before it is filed. In scope → dispatch one refuter against it, biased to refuse, and hand it this instruction verbatim with one substitution: resolve `<scratch>/pr<N>/<finding>/` in it to an absolute path under your own run root — `mktemp -d` once per round for `<scratch>/pr<N>/fix-XXXXXXXX/`, never bare `pr<N>`, since finding ids restart at 1 each round too — and write that absolute path into the refuter's prompt in its place; a refuter never derives its own path, and tell it your step-3 `testCmd`, which its rules call `<testCmd>`: *Try to REFUTE this finding. Default to refuted=true if uncertain. Verify by RUNNING something — compile it, run the test, apply the mutation. Do not reason your way to agreement. Observe that run synchronously — run the command, wait for it, read its exit code. Never poll a log file for a completion marker: prefer ONE blocking run to a poll loop, and treat its return as permission to look, never as the answer. Reading a log the run has already finished writing is fine; waiting on one is not. If you match a test reporter's own output, accepting both `ℹ` and `#` is necessary but NOT sufficient — strip SGR escapes first as well. node's prefix moves with the node version and with whether stdout is a TTY, and color wraps the whole line so it begins with ESC and no prefix anchor matches at all, which returns empty at exit 0 — indistinguishable from a hung run and from a run of zero tests. For an uncolored baseline use `env -u FORCE_COLOR`; `FORCE_COLOR=` empty still enables color, so it is not a control. State your search scope AND what your pattern would have missed. A grep over one ref does not support a claim about history; a pattern built from the token a diff removed does not support a claim that the category is empty. When your check scores a mutant killed or a test red, run only in your own copy of the tree, and these rules decide it and nothing else: Valid red: a run that completed — the command returned on its own, with no signal and no timeout, deadline or kill having fired — in which the test the claim names is reported failing. An exit ≠ 0 with no failing test is not red, and a different test failing is not red for this claim. A red in the full suite only triggers a narrowed run; it is never evidence on its own. Narrowed run: the Test entrypoint with arguments appended, `<testCmd> <args>`. That appended arguments reach the Test entrypoint is all that is promised about them, so a narrowed run counts only if it reports `tests` > 0 and its output names the claimed test. If no narrowing can be proven, run the full `<testCmd>` and read the named test's own result from its output. Kill: the mutant produced a valid red in a narrowed run, and the same narrowed command, in the same tree with the mutant reverted, ran a valid green with the named test passing — completed, exit 0, `tests` > 0, `cancelled` 0. A completed exit ≠ 0 with the named test not failing is not a kill, and no kill stands without that green run on the unmutated tree. The result is inconclusive, never a kill, when the red does not reproduce in the narrowed run, when the unmutated tree is red too, or when no usable run exists. Evidence line: every kill and every valid red you claim carries one line in the claim's own free-text field: `red: <cmd> → exit N, fail K incl <test>; baseline: <cmd> → exit 0, <test> pass`. A failing-test finding filed off the review's shared run carries the `red:` half alone. This refuter has no abstain: a run that could not decide — neither a valid red nor a valid green — is no kill and no refutation, so name the runs in `reason`, each with its evidence line, and leave `refuted` true, so the suggestion defers on the record instead of applying on a check that decided nothing. A failure injection with no positive control has produced NO result, never a negative one. Before you read an injected fault — an env var, an argv word, a mutant — as having had no effect, prove the injection reached the child: one run whose output differs with it present versus absent, or the child echoing the injected value back. Uncontrolled, the cell is unrun — say so in your verdict instead of reporting a no-effect result. Build such an invocation as an array expanded braced and quoted — `cfg=(SETB=1 BADJ=1); env "${cfg[@]}" sh ./probe.sh` — or inline the assignments literally — `env SETB=1 BADJ=1 sh ./probe.sh`; NEVER from an unquoted scalar — `cfg="SETB=1 BADJ=1"; env $cfg sh ./probe.sh` — which under zsh passes ONE argument, sets a variable literally named `SETB` to `1 BADJ=1`, never sets `BADJ` at all, and still exits 0. `env $cfg[@]` is not the portable spelling either: measured, bash word-splits it into `SETB=1` and `BADJ=1[@]`, so the injection variable is set to a corrupted value, while zsh behaves exactly as with the bare `$cfg` — `BADJ` never set, exit 0. Everything you write — mutants, fixtures, scratch repos — goes under `<scratch>/pr<N>/<finding>/` and nowhere else; the checkout and any worktree are never write targets, though `git show`/`git archive` at a pinned ref read fine anywhere. Chain the directory change into the command, `cd "$D" && git …`, never `cd "$D"; git …`, so a failed `cd` cannot leave a `git` command running in the checkout — and bracket a fixture's own git with `git rev-parse --show-toplevel`: before `git init` it must NOT resolve to the host repository, and `fatal: not a git repository` (exit 128) is the usual pass, not a failure — an outer repository whose ignore rules cover your scratch dir is a pass too; before any `git commit` it must resolve to your scratch path, which catches a forgotten `git init` under any enclosing repository — compare resolved forms (`realpath`), since `--show-toplevel` can report a symlinked parent such as `/tmp` or a symlinked `$HOME` under its resolved name.* That last clause is the whole mechanism — a refuter left to reason its way to agreement rubber-stamps, and "apply only what survives" silently degrades into "apply everything in scope". Apply only what survives; a refuted one defers with the refutation in its body. **Out of scope the refuter decides only where the suggestion is filed — it defers either way, never applied.** Refuted → the closed `PR #<pr> review: the suggestion band, checked` record, never an open issue. Survived, or its refuter crashed or abstained → its own open issue labelled `needs-triage`, citing the verdict, or the run that crashed, in the body. There is no cap on these refuters; your final verdict (step 4) says how many you dispatched against out-of-scope suggestions. "In scope" means the ticket's scope, not merely the same files — and applying one still never licenses editing a file the ticket has no business in. **A finding in `unverified` whose refuters ran and crashed or abstained always defers, whatever its severity** — and which it is, you read off the finding's **`refutersDispatched`** and **`refutersInconclusive`**, never off severity and never off an empty vote list: `refutersDispatched` above zero with nothing decided means every refuter dispatched against it died or abstained, and `refutersInconclusive` counts the ones that abstained. Only a `survived` finding has been checked; severity says how much a finding would matter if true, never whether anything looked. **Deferring a crash is your move, and re-running it is not** — the review already re-dispatched every crashed refuter pair once, in-run, so a crash that reaches you crashed twice, and the result's `resume` field names the one move left, which is not yours: nothing replays a review that ran in a runner's kernel — it is reported, not acted on. So say *in the deferral* that its refuters crashed rather than that it went unchecked, and re-run nothing for it. **An abstention is not a crash** — a refuter that abstained ran and could not decide: defer and file the finding, citing the inconclusive runs each abstaining refuter named in its `reason`, and never describe its refuters as crashed. **A `suggestion` is also in `unverified`, for a different reason — `refutersDispatched` of zero, the 0-refuter budget the workflow gives that band by policy, so nothing looked *there* — and the scope split above, not this rule, covers it.**
14
- **Write every ruling to `<scratch>/dispositions-<your name>.json`, `<your name>` the `fix-pr-<pr>[-x]` you were dispatched as (standalone, `fix-pr-<pr>`) — the disposition record `dispositions-check.mjs` judges against the review result file before any finisher is dispatched.** It is `{"head": "<the review file's head>", "run": "<the run root's name in the review path>", "entries": [ … ]}` — `run` the `<run>` of the review path your prompt names, which the check holds against the one the PR's latest `reviewed=` names, so a record written against an earlier review of the same PR, a same-head one included, is a mismatch (standalone, with no ledger naming a run, leave it out): one entry per `survived` finding and per `unverified` finding, plus one per `refuted` finding you reverse. Each entry carries `bucket` (`survived|unverified|refuted`), `index` (its position in that bucket of the review file — stable, since a round's file never changes), `scope` (`in|out`), `claimKind` (`behavior|shape`) and `disposition` (`apply|defer`), and where they apply `reason`, `issue` (the number you filed it to or commented on), `verdictPath` (a suggestion's refuter verdict file, in scope and, above the claim bar, out of scope) and `remedyFiles` (the files its remedy names). A `refuted` entry carries `scope`, `claimKind` and `disposition` like every other entry, and a non-empty `reason` holding the evidence that reverses it; a missing or blank `reason` is a mismatch. The check reads scope off the diff before it reads your entry: a finding with no `line`, or on a line `git diff` touches from the PR's merge-base with `origin/main` to the review's `head`, is in scope whatever `scope` says, and only otherwise does the declared one stand. An in-scope `survived` finding deferred passes only with `reason` `false-rationale`, `mutual-exclusion`, `remedy-worse` or `remedy-outside-diff`. `remedy-outside-diff` — the remedy would edit a file the PR's diff does not touch — also needs `remedyFiles` to name at least one file absent from `git diff` from that merge-base to the review's `head`: none named, or every one in that diff, is a mismatch. A `critical` or `important` finding deferred for it goes to a human instead of passing: the script exits 1 with verdict `escalate`, `ledger.mjs dispatch` refuses the PR's finisher, and nothing retries it; a `suggestion` deferred for it passes. Any other reason, none, or a `survived` or `unverified` finding with no entry is a mismatch: the script exits 1 naming each violating entry's bucket, index and rule, and `ledger.mjs dispatch` refuses the PR's finisher. `disposition` has no `dismiss`: an in-scope `survived` finding verified correct, needing no change, is deferred with `reason` `false-rationale` and filed on the closed suggestion-band record — step 5's `PR #<pr> review: the suggestion band, checked`.
13
+ 2. Plan the actions. Split explicitly into **apply now** / **defer**. A finding is deferred, never dropped. **Every `suggestion` — the review's `severity`, the plugin's *Suggestions* bucket — arrives unchecked: the review budgets that band 0 refuters, so nothing has looked at it yet.** Checking it is now your job, not nobody's. So split it by SCOPE first, and check it in either scope. Out of the PR's ticket scope → defer and file, but **the claim bar runs first:** a suggestion whose claim is about shape — step 5's worth-a-claim bar, recorded `claimKind: shape` — gets no refuter and goes to the `PR #<pr> review: the suggestion band, checked` record, and every other out-of-scope suggestion gets the same refuter an in-scope one does, dispatched the same way, before it is filed. In scope → dispatch one refuter against it, biased to refuse, and hand it this instruction verbatim with one substitution: resolve `<scratch>/pr<N>/<finding>/` in it to an absolute path under your own run root — `mktemp -d` once per round for `<scratch>/pr<N>/fix-XXXXXXXX/`, never bare `pr<N>`, since finding ids restart at 1 each round too — and write that absolute path into the refuter's prompt in its place; a refuter never derives its own path, and tell it your step-3 `testCmd`, which its rules call `<testCmd>`: *Try to REFUTE this finding. Default to refuted=true if uncertain. Verify by RUNNING something — compile it, run the test, apply the mutation. Do not reason your way to agreement. Observe that run synchronously — run the command, wait for it, read its exit code. Never poll a log file for a completion marker: prefer ONE blocking run to a poll loop, and treat its return as permission to look, never as the answer. Reading a log the run has already finished writing is fine; waiting on one is not. If you match a test reporter's own output, accepting both `ℹ` and `#` is necessary but NOT sufficient — strip SGR escapes first as well. node's prefix moves with the node version and with whether stdout is a TTY, and color wraps the whole line so it begins with ESC and no prefix anchor matches at all, which returns empty at exit 0 — indistinguishable from a hung run and from a run of zero tests. For an uncolored baseline use `env -u FORCE_COLOR`; `FORCE_COLOR=` empty still enables color, so it is not a control. State your search scope AND what your pattern would have missed. A grep over one ref does not support a claim about history; a pattern built from the token a diff removed does not support a claim that the category is empty. When your check scores a mutant killed or a test red, run only in your own copy of the tree, and these rules decide it and nothing else: Valid red: a run that completed — the command returned on its own, with no signal and no timeout, deadline or kill having fired — in which the test the claim names is reported failing. An exit ≠ 0 with no failing test is not red, and a different test failing is not red for this claim. A red in the full suite only triggers a narrowed run; it is never evidence on its own. Narrowed run: the Test entrypoint with arguments appended, `<testCmd> <args>`. That appended arguments reach the Test entrypoint is all that is promised about them, so a narrowed run counts only if it reports `tests` > 0 and its output names the claimed test. If no narrowing can be proven, run the full `<testCmd>` and read the named test's own result from its output. Kill: the mutant produced a valid red in a narrowed run, and the same narrowed command, in the same tree with the mutant reverted, ran a valid green with the named test passing — completed, exit 0, `tests` > 0, `cancelled` 0. A completed exit ≠ 0 with the named test not failing is not a kill, and no kill stands without that green run on the unmutated tree. The result is inconclusive, never a kill, when the red does not reproduce in the narrowed run, when the unmutated tree is red too, or when no usable run exists. Evidence line: every kill and every valid red you claim carries one line in the claim's own free-text field: `red: <cmd> → exit N, fail K incl <test>; baseline: <cmd> → exit 0, <test> pass`. A failing-test finding filed off the review's shared run carries the `red:` half alone. This refuter has no abstain: a run that could not decide — neither a valid red nor a valid green — is no kill and no refutation, so name the runs in `reason`, each with its evidence line, and leave `refuted` true, so the suggestion defers on the record instead of applying on a check that decided nothing. A failure injection with no positive control has produced NO result, never a negative one. Before you read an injected fault — an env var, an argv word, a mutant — as having had no effect, prove the injection reached the child: one run whose output differs with it present versus absent, or the child echoing the injected value back. Uncontrolled, the cell is unrun — say so in your verdict instead of reporting a no-effect result. Build such an invocation as an array expanded braced and quoted — `cfg=(SETB=1 BADJ=1); env "${cfg[@]}" sh ./probe.sh` — or inline the assignments literally — `env SETB=1 BADJ=1 sh ./probe.sh`; NEVER from an unquoted scalar — `cfg="SETB=1 BADJ=1"; env $cfg sh ./probe.sh` — which under zsh passes ONE argument, sets a variable literally named `SETB` to `1 BADJ=1`, never sets `BADJ` at all, and still exits 0. `env $cfg[@]` is not the portable spelling either: measured, bash word-splits it into `SETB=1` and `BADJ=1[@]`, so the injection variable is set to a corrupted value, while zsh behaves exactly as with the bare `$cfg` — `BADJ` never set, exit 0. Everything you write — mutants, fixtures, scratch repos — goes under `<scratch>/pr<N>/<finding>/` and nowhere else; the checkout and any worktree are never write targets, though `git show`/`git archive` at a pinned ref read fine anywhere. Chain the directory change into the command, `cd "$D" && git …`, never `cd "$D"; git …`, so a failed `cd` cannot leave a `git` command running in the checkout — and bracket a fixture's own git with `git rev-parse --show-toplevel`: before `git init` it must NOT resolve to the host repository, and `fatal: not a git repository` (exit 128) is the usual pass, not a failure — an outer repository whose ignore rules cover your scratch dir is a pass too; before any `git commit` it must resolve to your scratch path, which catches a forgotten `git init` under any enclosing repository — compare resolved forms (`realpath`), since `--show-toplevel` can report a symlinked parent such as `/tmp` or a symlinked `$HOME` under its resolved name.* That last clause is the whole mechanism — a refuter left to reason its way to agreement rubber-stamps, and "apply only what survives" silently degrades into "apply everything in scope". Apply only what survives; a refuted one defers with the refutation in its body. **Out of scope the refuter decides only where the suggestion is filed — it defers either way, never applied.** Refuted → the `PR #<pr> review: the suggestion band, checked` record, never an open issue. Survived, or its refuter crashed or abstained → its own open issue labelled `needs-triage`, citing the verdict, or the run that crashed, in the body. There is no cap on these refuters; your final verdict (step 4) says how many you dispatched against out-of-scope suggestions. "In scope" means the ticket's scope, not merely the same files — and applying one still never licenses editing a file the ticket has no business in. **A finding in `unverified` whose refuters ran and crashed or abstained always defers, whatever its severity** — and which it is, you read off the finding's **`refutersDispatched`** and **`refutersInconclusive`**, never off severity and never off an empty vote list: `refutersDispatched` above zero with nothing decided means every refuter dispatched against it died or abstained, and `refutersInconclusive` counts the ones that abstained. Only a `survived` finding has been checked; severity says how much a finding would matter if true, never whether anything looked. **Deferring a crash is your move, and re-running it is not** — the review already re-dispatched every crashed refuter pair once, in-run, so a crash that reaches you crashed twice, and the result's `resume` field names the one move left, which is not yours: nothing replays a review that ran in a runner's kernel — it is reported, not acted on. So say *in the deferral* that its refuters crashed rather than that it went unchecked, and re-run nothing for it. **An abstention is not a crash** — a refuter that abstained ran and could not decide: defer and file the finding, citing the inconclusive runs each abstaining refuter named in its `reason`, and never describe its refuters as crashed. **A `suggestion` is also in `unverified`, for a different reason — `refutersDispatched` of zero, the 0-refuter budget the workflow gives that band by policy, so nothing looked *there* — and the scope split above, not this rule, covers it.**
14
+ **Write every ruling to `<scratch>/dispositions-<your name>.json`, `<your name>` the `fix-pr-<pr>[-x]` you were dispatched as (standalone, `fix-pr-<pr>`) — the disposition record `dispositions-check.mjs` judges against the review result file before any finisher is dispatched.** It is `{"head": "<the review file's head>", "run": "<the run root's name in the review path>", "entries": [ … ]}` — `run` the `<run>` of the review path your prompt names, which the check holds against the one the PR's latest `reviewed=` names, so a record written against an earlier review of the same PR, a same-head one included, is a mismatch (standalone, with no ledger naming a run, leave it out): one entry per `survived` finding and per `unverified` finding, plus one per `refuted` finding you reverse. Each entry carries `bucket` (`survived|unverified|refuted`), `index` (its position in that bucket of the review file — stable, since a round's file never changes), `scope` (`in|out`), `claimKind` (`behavior|shape`) and `disposition` (`apply|defer`), and where they apply `reason`, `issue` (the number you filed it to or commented on), `verdictPath` (a suggestion's refuter verdict file, in scope and, above the claim bar, out of scope) and `remedyFiles` (the files its remedy names). A `refuted` entry carries `scope`, `claimKind` and `disposition` like every other entry, and a non-empty `reason` holding the evidence that reverses it; a missing or blank `reason` is a mismatch. The check reads scope off the diff before it reads your entry: a finding with no `line`, or on a line `git diff` touches from the PR's merge-base with `origin/main` to the review's `head`, is in scope whatever `scope` says, and only otherwise does the declared one stand. An in-scope `survived` finding deferred passes only with `reason` `false-rationale`, `mutual-exclusion`, `remedy-worse` or `remedy-outside-diff`. `remedy-outside-diff` — the remedy would edit a file the PR's diff does not touch — also needs `remedyFiles` to name at least one file absent from `git diff` from that merge-base to the review's `head`: none named, or every one in that diff, is a mismatch. A `critical` or `important` finding deferred for it goes to a human instead of passing: the script exits 1 with verdict `escalate`, `ledger.mjs dispatch` refuses the PR's finisher, and nothing retries it; a `suggestion` deferred for it passes. Any other reason, none, or a `survived` or `unverified` finding with no entry is a mismatch: the script exits 1 naming each violating entry's bucket, index and rule, and `ledger.mjs dispatch` refuses the PR's finisher. `disposition` has no `dismiss`: an in-scope `survived` finding verified correct, needing no change, is deferred with `reason` `false-rationale` and filed on the suggestion-band record — step 5's `PR #<pr> review: the suggestion band, checked`.
15
15
  3. **Run `checkCmd` and `testCmd` before you commit**, from the worktree, each copied verbatim — not a runner you picked. `checkCmd` is the Recipe's Check step and `testCmd` is the command your dispatcher handed you; **standalone, with no dispatcher to hand you them, they are the repo's own Check step and Test entrypoint**, read off the repo's Recipe cache (`derive-testcmd.sh <repo> check` prints the first, `derive-testcmd.sh <repo> test` the second). Read them off the repo, never invent one: "not a runner you picked" is what this step forbids, and on the standalone path nothing else defines the referent. With no usable Recipe cache (`derive-testcmd.sh` refuses), run the Recipe derivation step first — dispatch a `task` member, agent `fleet-recipe-deriver`, its prompt naming the main checkout's absolute path as `<repo>` and a scratch directory of its own; it proves all three commands in a throwaway worktree and writes the cache only on proof, and its `RECIPE NOT PROVEN` is a stop, reported with its reason. Never fall back to a runner of your own choosing. `tests 0` is a FAILED run, not a pass: a glob matching nothing still exits 0. **Zero is not the only no-work count.** A run reporting **0 passes with no failures** skipped everything it collected, and a count **materially below the whole suite** means you ran a partial tree — this suite reported `tests 937` when this line was written, so compare what you get against a full run rather than against zero. The number drifts; that is the safe direction, because a stale one produces a loud false red while its absence produces a silent false green. Red, zero-test, or either no-work count → fix it or move the finding to defer; never commit over it. Then commit and push — `~/.fleet/bin/fleet-run push-sync.mjs --branch <branch>`, which pushes the explicit refspec `HEAD:refs/heads/<branch>` and then moves the worktree holding `<branch>` to the pushed SHA, or names why it did not and exits 3 with the push landed: this step also runs from a `--detach` worktree (`run-team/SKILL.md`'s worktree-recreation form), where a bare `git push` exits 128 (`fatal: You are not currently on a branch`), any push whose destination is `HEAD` rather than a full or branch refname (`-u` or not, forced or not) exits 1 (`error: The destination you provided is not a full refname`), and a refspec push alone leaves the claim worktree's `refs/heads/<branch>` at its old tip — and report the test result alongside the SHA. **Then reconcile the PR body with what you actually did.** It merges into the permanent record and nothing downstream re-derives it: your commit messages are yours to fix and usually get fixed, the body is neither. Removing a change, or reversing one, leaves the body asserting work the diff no longer contains. Measured twice in one run — one body kept asserting a calibration its own fix had just deleted as false, another kept claiming a hunk the fix had removed as "this diff owns it" — both caught by a finisher's reading rather than by any rule, and both would otherwise have merged intact. `gh pr edit <pr> --body-file <path>`, and re-read the body against the final diff rather than against your memory of it. **Never `--no-verify` on a commit you push, or on the push itself.** The fleet runs in repos it knows nothing about: it must not assume a pre-commit hook exists, and must never bypass one that does on what reaches the branch — a hook that fails is a finding to report, not an obstacle. Nothing re-reviews what you commit here, so this run is the only gate between your edit and a merge.
16
16
  **Under the fleet, run the two through `recipe-check.mjs`, not one by one:** from the worktree, after `git add` and before the commit, `~/.fleet/bin/fleet-run recipe-check.mjs --member <your name> --testCmd "<testCmd>" --reports <scratch>/reports` — `<your name>` the `fix-pr-<pr>[-x]` you were dispatched as, and `<testCmd>` the whole command you were handed, one quoted argument. It runs the Recipe's Check step, which is `checkCmd`, then `<testCmd>` exactly as handed, against the staged tree, and records both results beside your report. The record is keyed to the tree, so run it again after any content change or rebase — a message-only amend keeps it valid — and the controller refuses an `applied` report whose head's tree has no green record. After the commit, and before you push or report, compare `git rev-parse 'HEAD^{tree}'` with the `tree` field of your record, `<scratch>/reports/<your name>.check.json`. A commit hook that rewrote and re-staged files after the record was written makes the two differ. When they do, list the paths it changed, `git diff --name-only "$(jq -r .tree <scratch>/reports/<your name>.check.json)" HEAD`. If the PR changes every listed path anyway, run `recipe-check.mjs` again with the same arguments: right after the commit the index holds the committed tree, so the re-run runs both commands on that tree and records it, leaving the head's sha as it is. A listed path the PR does not otherwise change is a stale base the hook staged rather than left unstaged, and a re-run would record it green: `git fetch origin && git rebase origin/main`, take the commit back with `git reset --soft HEAD^`, put each such path alone back with `git restore --source=HEAD --staged --worktree -- <that path>`, run `recipe-check.mjs`, then `git commit -C ORIG_HEAD` and compare again. A hook that stages such a path again on the rebased base → push nothing and report that cause. Equal trees need no re-run. A non-zero exit is red; exit 75 is no test result — no slot was free and nothing ran — so run it again. **Standalone, with no report to settle against,** run `checkCmd` and `testCmd` yourself, each copied verbatim.
17
17
  4. **Under the fleet, do not hold this wait.** The controller owns a persistent CI Monitor and dispatches a finisher once your report has arrived and CI is green — so push, then report to the controller a **final verdict** (every specialist you dispatched + its outcome + your apply/defer adjudication + how many refuters you dispatched against out-of-scope suggestions + the pushed SHA) and stop. A specialist that has not reported to you is not a verdict line — `hub send` it by name for its report, then ask the controller, before ruling; see **Specialists**. The controller keys "review complete" on that verdict, **never on you merely going idle** — a slow specialist can still be running when your turn ends, and finishing off idle-alone ships a premature label (observed: a PR labelled while its contract-test specialist was still running and about to surface a vacuous pin). A turn-based reviewer re-reading `gh pr checks` each idle cycle rebuilds its whole context for nothing the Monitor lacks. **Standalone (no controller): watch** the run until the diff-validating jobs are green (bind via `ci-state.mjs` below); fix a **genuine** failure and repeat from 3. **Staleness is not a failure:** a `rebase-check` red or heavy jobs `skipped` **solely from a non-zero behind-count** never clears by waiting and must not be rebased away here — see **`skipped` is not `passed`** and **Do NOT rebase to label** below for what it looks like and why. Stop watching and go to 6. A turn-based reviewer that "waits for green" on a behind PR stalls forever and ends its turn without labelling. **`ci-state.mjs` returns `verdict: "no-ci"`** → there is no run to watch, so don't: go straight to 6 and let that step's `--declare-no-ci` branch decide.
18
18
  5. File each deferred finding: `gh issue create --label needs-triage` with the finding, `file:line`, why deferred, `Deferred from PR #<pr> review`, and **the finding's `dimension` in the issue body** — the review returns it on every finding, and a backlog that does not carry it cannot be attributed to the specialist that generated it. **The body also carries the evidence triage needs: the refuter's verdict — the `verdictPath` reason, or the review's votes — and the candidate remedy.** **Every issue you create is labelled `needs-triage`; only triage sets `ready-for-agent`** — and the label is never omitted; an unlabelled issue is invisible to both `candidates.mjs --require-label` and the cockpit's pool query, reachable only by a human. **A confirmed defect does not settle its remedy**, so confirming one earns it no other label: a confirmed defect with the remedy still open files `needs-triage`, and so does a finding you are unsure is broken at all. **A finding a refuter CHECKED and did not survive is neither** — it is not a confirmed defect, and you are not unsure whether anything is broken: you measured that nothing is. Refutations go as **one issue per review, titled `PR #<pr> review: the suggestion band, checked`, labelled `wontfix`, and CLOSED** — a run that files them open instead has to be corrected afterwards, as one was on 2026-08-27. Left open they read as queued agent work and rot into filing artifacts; a measured triage pass found a quarter of its `needs-triage` backlog was refuted records, bulk-closable by title shape. Closed is still a durable home — `check` reads the tracker at `--state all`, so a future run re-deriving the finding lands on the evidence rather than re-litigating it. Split the band only where an entry is **not** a refutation: a residual the refuter surfaced, or an out-of-scope finding its refuter let survive or never decided, is real deferred work — and real deferred work earns its own open issue only past a second bar. **Worth a claim: a finding whose own claim is that correct code could be shaped better — style, naming, layout, redundancy, a micro-simplification — never gets its own open issue.** **Neither does a gap in a prose guard — a test holding prose to its wording — unless something acts on the guarded text: an agent follows it as an instruction (a skill, a command, an agent definition, a script's header or usage text, `AGENTS.md`), or a program other than its own guard reads it.** Whether anything does is your judgement, as `claimKind` is, and a gap below this bar is recorded `claimKind: shape`. A gap in a guard's own code — a slow pattern, a dead branch, an unchecked spawn result — takes the stakes of the prose it guards, and a helper several guards share takes the highest of theirs. A missed wording already in the tree is no guard gap — that prose is wrong — and neither is an input already in the tree that trips a guard's code; each files on its own merits. Once the prose is corrected, the observed wording may join the guard's literal ban, never a reader built to catch wordings nobody has written. A below-bar finding's only open question is "is this worth doing?", which answers itself under the repriced triage bar: measured 2026-08-15→31, 133 issues were triage-closed `wontfix` in sixteen days, 121 of them review deferrals, mostly this shape. Append it to the same closed record issue under a `Below the claim bar` heading — `file:line`, dimension, the claim, and a re-open trigger per entry. Closed is a durable home for these too: `check` reads `--state all`, so a run re-deriving the finding lands on the record instead of re-filing — and a later review returning the same finding as `survived`, or a maintainer hitting it, is the promotion signal: file it open then, citing the record. **An in-scope rediscovery step 2 applied is that same promotion signal, and it files nothing** — so the record carries no mark unless you write one: run `check` over each finding step 2 applied as well, and where its tracker rows put one on a `Below the claim bar` entry, comment on that record issue, opening `Promoted — applied, not filed`, and name the entry, that its refuter let it through, and the commit carrying the fix. That comment is the promotion mark this path leaves, and it is **not** a cross-reference — measured on this tracker, a comment mints `cross-referenced` on the issue its own text *names*, never on the issue it sits under (a comment citing a second issue landed on that issue's timeline and nowhere else), so the fixed opening is what carries the signal instead. **The bar reads the finding's claim, never its review band** — a finding that alleges wrong behavior, whatever band it sat in, `unverified`-with-crashed-refuters included, is above this bar and files open, `needs-triage`, which is why a band cannot carry this decision. Do **not** reach for phase 0's **decided?** test here. That is a *consumption* gate, and whether a filed issue is decided enough to reach it is triage's call, never the filer's. **The cost of this rule is watched:** open `needs-triage` issues citing `Deferred from PR` above 40 on 3 consecutive run dates send the filing rule back to the maintainer — batch triage, a fleet triage role, or a different bar. **Dedupe through `~/.fleet/bin/fleet-run ledger.mjs check "<subject>"`, never a bare `gh issue list --search`** — the duplicate class that guard exists to catch was filed from *here*: the ledger row for the finding rediscovered five times in one measured run carries a reviewer's own source tag, `(review-pr-108)`. `check` reads this run's filed list *and* the tracker at `--state all`, so a closed duplicate still counts. Exit **1** is already filed in this run; exit **3** is a clean ledger with matching tracker rows that score against the subject — read the ones it prints and comment on the existing follow-up rather than filing a second issue. **Exit 0 is not automatically "safe to file"**: it covers `clean`, `soft-hit` and `unverified` alike, and an unreachable `gh` degrades to a ledger-only answer that says `TRACKER NOT CHECKED`, so read the stdout `verdict` to tell a tracker that was searched and clean from one nobody read — under `unverified`, search it yourself before filing — or, for the applied-promotion comment above, before treating a tracker nobody could read as a reason to skip it. **`soft-hit` is the answer that costs most to misread**: it means related rows were found and none of them was established as a duplicate — a near-miss above the floor, or tracker rows the scorer rates 0.00 — so read the rows it printed and decide, and never treat that exit 0 as permission. Exit **2** is a usage error, meaning nothing was checked at all: fix the invocation and re-run, never read it as a duplicate. Full exit-code and `verdict` semantics live in `$(~/.fleet/bin/fleet-run --root)/skills/run-team/references/ledger.md`, which `SKILL.md`'s wake section points at — read them there rather than restating them here. **The guard dedupes, it never drops:** a duplicate becomes a comment on the issue that already covers it, never a dropped finding. **Record every issue you create, at once:** immediately after each `gh issue create`, run `~/.fleet/bin/fleet-run ledger.mjs filed <N> "<subject>"` — `<N>` the number `gh issue create` printed, and **the same subject string you passed to `check`**, so the next `check` for that finding meets it in the filed list instead of scoring a rewording. Without it the guard's ledger half never holds a row, and its exit **1** can never fire. If `filed` fails, retry it once; if it fails again, leave the issue alone — it exists and is correct — and report it as `unrecorded: #N <subject>`, so the controller runs `filed` for it. Report `filed: #N <subject>` for every issue you created — under the fleet, in your report to the controller. **The one window the guard cannot see:** `check` → `gh issue create` → `filed` is three commands, and the ledger's lock covers each alone, so two filers can both read `clean` in the seconds before either records. That gap is accepted, never a reason to skip `filed`. Post the numbers as one PR comment.
19
19
  **A duplicate that should hold an open deferral is a comment, never a relabel.** Where the guard lands a finding whose home is an open issue on an existing one that is open, cite the refuters' verdict — or the review's votes — in the comment you leave there, and change no label. Never relabel `ready-for-agent` down to `needs-triage`: a `ready-for-agent` issue already holds a `needs-triage` finding, because triage already promoted it, and a later run's crashed refuters are no reason to send it back. A closed one cannot hold it: file a new open issue citing it, as the promotion rule above does for the closed record.
20
- **Name where each deferral went, in the disposition record.** Every entry you defer carries `issue`: the number of the issue you filed it to, commented it onto, or appended it to — the closed `PR #<pr> review: the suggestion band, checked` record included. A `suggestion`'s entry — an `unverified` finding with `refutersDispatched` of zero — also carries `verdictPath`, applied or deferred, in scope and, above the claim bar, out of scope: the absolute path of a file holding its refuter's verdict as JSON, `{"refuted": <boolean>, "reason": "<string>"}`, under your own `<scratch>/pr<N>/fix-XXXXXXXX/<finding>/` run root. An out-of-scope one whose refuter crashed before any verdict reached you still names the path its verdict would have taken there; out of scope, that file missing or holding no decision files the finding open `needs-triage`, while in scope it is a mismatch. `dispositions-check.mjs` reads each named issue back through `gh issue view --json labels,state,title` and each verdict file off disk; a mismatch names the row of its filing table the entry broke, so move or relabel that filing to the home the row names and run the check again. With `gh` unreachable, or an issue it cannot read, that filing goes unchecked: with nothing else broken the verdict is `unchecked` — never `ok`, and it exits 1 — so run the check again once `gh` answers.
20
+ **Name where each deferral went, in the disposition record.** Every entry you defer carries `issue`: the number of the issue you filed it to, commented it onto, or appended it to — the `PR #<pr> review: the suggestion band, checked` record included. A `suggestion`'s entry — an `unverified` finding with `refutersDispatched` of zero — also carries `verdictPath`, applied or deferred, in scope and, above the claim bar, out of scope: the absolute path of a file holding its refuter's verdict as JSON, `{"refuted": <boolean>, "reason": "<string>"}`, under your own `<scratch>/pr<N>/fix-XXXXXXXX/<finding>/` run root. An out-of-scope one whose refuter crashed before any verdict reached you still names the path its verdict would have taken there; out of scope, that file missing or holding no decision files the finding open `needs-triage`, while in scope it is a mismatch. `dispositions-check.mjs` reads each named issue back through `gh issue view --json labels,state,title` and each verdict file off disk; a mismatch names the row of its filing table the entry broke, so move or relabel that filing to the home the row names and run the check again. With `gh` unreachable, or an issue it cannot read, that filing goes unchecked: with nothing else broken the verdict is `unchecked` — never `ok`, and it exits 1 — so run the check again once `gh` answers.
21
21
  6. Diff-check green (run-bound per `ci-state.mjs`; under staleness see **`skipped` is not `passed`** for what the label then rests on) **and** deferrals filed **and** the PR carrying exactly one release label (`patch`/`minor`/`major`, counting only those three; **required on every PR** — `release-label.yml` auto-adds `patch` when none is present and hard-fails on more than one, so this never needs to check whether the repo defines the gate at all, and `gh label list` settles nothing here any more) **and** `dispositions-check.mjs --member fix-pr-<pr> --scratch <scratch>` run over your own record exiting 0 (standalone that is yours to run, from the worktree, after step 2 writes `<scratch>/dispositions-fix-pr-<pr>.json`; `--review <path>` names the review file, which you write yourself from the findings step 1 collected, in the shape **The review result file** describes — `pr`, `head`, `snapshot`, `counts` and the `survived`, `refuted` and `unverified` buckets; standalone you pass `--no-ledger` with it, so it writes no token and the exit status is the verdict — a `.fleet/ledger.md` the repository already holds is a previous run's, and without the flag it exits 2 for a ledger with no row for you; under the fleet `ledger.mjs dispatch` has already refused a finisher without it) → `gh pr comment <pr> --body "ready-to-merge-head: <prHead>"`, `prHead` being the full SHA your green `ci-state.mjs` reading reported, which the merge bot reads as the head the label was applied to, then `gh pr edit <pr> --add-label ready-to-merge`. Do not merge. A non-zero dispositions exit halts you before the label, and you report the violations it printed. Zero or more than one release label halts you before the label, naming which you found — a timed-out `gh pr create` opens the PR with its `--label` unapplied and no exit status to react to, and nothing after this step re-derives the release label. **Under the fleet a finisher does this** — a fresh small agent the controller dispatches (after your final verdict, never on your bare idle). Gate it on the diff-validating **`check`** job, **not** on `ci-state --quiet` exit 0, which a behind PR — the normal case once any sibling merges — never reaches: a `rebase-check` red or heavy jobs `skipped` off the behind-count hold it short of full green, so an exit-0 gate strands every behind PR unlabelled. Label when `check` is present and green **and no heavy job (the diff-validating suites — *not* the `rebase-check` currency gate) is in `failure`** — 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 `$(~/.fleet/bin/fleet-run --root)/skills/run-team/SKILL.md`'s fix-applier prompt block. A `failure` is the diff's own fault and blocks the label, else a genuinely broken PR gets labelled off a green `check` and burns a merge-bot rebase+CI cycle before the failure resurfaces. Read per-job state for this: run `ci-state.mjs` **without** `--quiet`, or read its `jobs` — the `--quiet` payload drops the per-job `jobs`/`missing` and the `dropped` list, keeping `reasons` (which still distinguishes `is failure` from `is skipped`). You reach step 6 yourself only when green is already in hand at push time.
22
22
 
23
23
  **`verdict: "no-ci"` — this repo has no `check` job to gate on at all**, and that is a different state from red or unknown, not a third way to skip the wait. Label off step 3's own green `testCmd` run instead of a `check` job that will never exist — and only once `ci-state.mjs` reads `no-ci` **without** the flag, since a repo whose CI is merely misconfigured exits 2 there rather than reading no-ci. `--declare-no-ci` is your caller-side declaration that step 3's run **is** this repo's gate: it flips the exit code and echoes itself into `reasons`, so finding it in the payload confirms only that you passed it, never that anyone verified anything. **No green `testCmd` run on record → do not label.** Say in your verdict that the repo has no CI configured and no declaration, and stop — inferring a pass from silence here is exactly the misconfigured-CI case the declaration exists to rule out. See `ci-state.mjs --pr <n> --declare-no-ci` below.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feigi/fleet-ctl",
3
- "version": "3.89.3",
3
+ "version": "3.91.0",
4
4
  "description": "Agent fleet: run-team controller, merge bot, PR reviewer, and the ticket pipeline they share",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -67,7 +67,7 @@
67
67
  // of ALLOWED_DEFER. Any other reason, or none, is a mismatch.
68
68
  // `disposition` has no `dismiss`: an in-scope `survived` finding verified
69
69
  // correct, needing no change, is deferred with `reason` `false-rationale`
70
- // and filed on the closed suggestion-band record.
70
+ // and filed on the suggestion-band record.
71
71
  // - `remedy-outside-diff` passes only when `remedyFiles` names at least one
72
72
  // file absent from the file list of `git diff <merge-base>...<head>`
73
73
  // (renames and deletions listed on both sides, so a file the PR deleted
@@ -103,16 +103,16 @@
103
103
  // row finding home
104
104
  // 1 survived, in scope, deferred for an allowed open, needs-triage
105
105
  // reason other than false-rationale
106
- // 2 survived, in scope, deferred false-rationale closed suggestion-band record
106
+ // 2 survived, in scope, deferred false-rationale suggestion-band record
107
107
  // 3 unverified, its refuters dispatched and crashed open, needs-triage
108
108
  // or abstained
109
- // 4 in-scope suggestion, its refuter refuted it closed suggestion-band record
109
+ // 4 in-scope suggestion, its refuter refuted it suggestion-band record
110
110
  // 5 in-scope suggestion, its refuter let it survive applied, or deferred as row 1
111
- // 6a out-of-scope suggestion alleging wrong behavior, closed suggestion-band record
111
+ // 6a out-of-scope suggestion alleging wrong behavior, suggestion-band record
112
112
  // its refuter refuted it
113
113
  // 6b out-of-scope suggestion alleging wrong behavior, open, needs-triage
114
114
  // its refuter let it survive, crashed or abstained
115
- // 7 claimKind shape, below the claim bar closed suggestion-band record
115
+ // 7 claimKind shape, below the claim bar suggestion-band record
116
116
  // 8 survived, out of scope, whatever its reason open, needs-triage
117
117
  //
118
118
  // Row 4 outranks row 7, and row 7 every other row. A row-5 deferral is
@@ -121,12 +121,12 @@
121
121
  // or row-8 deferral is held to no reason and escalates nothing, whatever
122
122
  // its severity. An open home carries exactly one triage label, its row's or
123
123
  // a stronger one: every open row is filed `needs-triage`, and
124
- // `ready-for-agent`, which only triage sets, answers one too. The closed
125
- // suggestion-band record is the closed issue titled `PR #<pr> review:
126
- // the suggestion band, checked`, labelled `wontfix`. `claimKind` is
127
- // trusted: the script never judges whether a claim is about shape. A
128
- // deferral no row holds — a reversed refutation whose claim is about
129
- // behavior — is a mismatch naming no filing-table row.
124
+ // `ready-for-agent`, which only triage sets, answers one too. The
125
+ // suggestion-band record is the issue titled `PR #<pr> review: the
126
+ // suggestion band, checked`, already closed and labelled `wontfix`.
127
+ // `claimKind` is trusted: the script never judges whether a claim is
128
+ // about shape. A deferral no row holds — a reversed refutation whose
129
+ // claim is about behavior — is a mismatch naming no filing-table row.
130
130
  //
131
131
  // Unchecked: `gh` unreachable, or a deferral's issue it cannot read, leaves
132
132
  // that entry's filing unjudged. With no rule broken and nothing escalated the
@@ -188,7 +188,7 @@ const BUCKETS = [...COVERED, "refuted"];
188
188
  const ENUMS = { scope: ["in", "out"], claimKind: ["behavior", "shape"], disposition: ["apply", "defer"] };
189
189
 
190
190
  // The filing table: a deferral's home by its finding's state. `record` is the
191
- // closed suggestion-band record; `open` the weakest triage label an open home may carry.
191
+ // suggestion-band record; `open` the weakest triage label an open home may carry.
192
192
  export const FILING_ROWS = Object.freeze({
193
193
  1: { finding: "an in-scope survived finding deferred for an allowed reason other than false-rationale", open: "needs-triage" },
194
194
  2: { finding: "an in-scope survived finding deferred false-rationale", record: true },
@@ -429,6 +429,27 @@ export function rowWriteRefusal(before, after, oldLine, line, retired = []) {
429
429
  return null;
430
430
  }
431
431
 
432
+ /**
433
+ * The name of every member the ledger holds settled: a settled copy in
434
+ * `## Dispatched` or on a row settles it, except that a PR-bound member's
435
+ * settled copy counts only on its own PR's row. Malformed tokens are the
436
+ * caller's to have refused: one here is read as nothing.
437
+ */
438
+ export function settledOnLedger(rows, dispatched) {
439
+ const settled = new Set();
440
+ for (const e of dispatched) {
441
+ const t = parseToken(e);
442
+ if (t !== null && t.error === null && t.outcome !== null) settled.add(t.name);
443
+ }
444
+ for (const text of rows) {
445
+ const { pr } = rowNums(text);
446
+ for (const t of memberTokens(text)) {
447
+ if (t.error === null && t.outcome !== null && (t.bound !== "pr" || t.number === pr)) settled.add(t.name);
448
+ }
449
+ }
450
+ return settled;
451
+ }
452
+
432
453
  /**
433
454
  * What a ledger still holds live, and what must travel with it. `tokens` is
434
455
  * every unsettled member token and every open review launch, once each, in
@@ -442,25 +463,14 @@ export function rowWriteRefusal(before, after, oldLine, line, retired = []) {
442
463
  * keep a bare token from reading live again. `dispatched` is every
443
464
  * `## Dispatched` entry whose member is unsettled, plus every settled entry
444
465
  * naming a member a kept row carries, for the same reason. A member is
445
- * settled by a settled copy in `## Dispatched` or on a row, except that a
446
- * PR-bound member's settled copy counts only on its own PR's row — the reading
466
+ * settled as settledOnLedger() reads it — the reading
447
467
  * fleet-tick.mjs folds liveness by, so nothing that fold calls live is left
448
468
  * out. A launch is open while unanswered and not `=failed`, paired across its
449
469
  * PR's rows as `rowWriteRefusal` pairs them. Malformed tokens are the caller's
450
470
  * to have refused: one here is read as nothing.
451
471
  */
452
472
  export function liveOnLedger(rows, dispatched) {
453
- const settled = new Set();
454
- for (const e of dispatched) {
455
- const t = parseToken(e);
456
- if (t !== null && t.error === null && t.outcome !== null) settled.add(t.name);
457
- }
458
- for (const text of rows) {
459
- const { pr } = rowNums(text);
460
- for (const t of memberTokens(text)) {
461
- if (t.error === null && t.outcome !== null && (t.bound !== "pr" || t.number === pr)) settled.add(t.name);
462
- }
463
- }
473
+ const settled = settledOnLedger(rows, dispatched);
464
474
  const live = (t) => t !== null && t.error === null && t.outcome === null && !settled.has(t.name);
465
475
  const open = new Map();
466
476
  for (const [id, tokens] of streams(rows)) {
@@ -5,7 +5,8 @@
5
5
  // that.
6
6
  //
7
7
  // Rows are rewritten in place, one per ticket: `row` replaces a whole line
8
- // and `token` appends tokens to one. `filed` and `ruled` are
8
+ // and `token` appends tokens to one, or replaces its `tier=`, which it refuses
9
+ // while the row's implementer has not settled. `filed` and `ruled` are
9
10
  // append-only, because their whole purpose is to outlive the reasoning that
10
11
  // produced them; `filed` appends at most one row per issue.
11
12
  //
@@ -55,7 +56,7 @@ import { fileURLToPath } from "node:url";
55
56
  import { spawnSync, execFileSync } from "node:child_process";
56
57
  import { makeDie, isFlagLike, hasEqualsForm, isDigits, writeAll } from "./arg.mjs";
57
58
  import { fleetFile, FleetDirUnresolvable } from "./fleet-dir.mjs";
58
- import { parseMember, parseToken, memberTokens, nextMergeBot, nextRetry, liveOnLedger, retiredOnLedger, retiredName, readRetired, agentDefinition, CELL, tierValues, rowPr, rowWriteRefusal, isReviewToken, addedReviewToken, newSplitRow, splitRowClause, added, premisesOf, MEMBER_FAMILIES, HALT_CAUSES, ROWS, DISPATCHED, FILED, RULED, DRAIN, RETIRED, RECEIPT, archiveStamp, receiptArchiveName, CLOSEOUT_RECEIPT, closeoutArchiveName, BRIEFS, briefsArchiveName, memberRole, parseDispatchedEntry, dispatchedEntry, headerRe, escapeText, unescapeText, nextReviewerName } from "./ledger-grammar.mjs";
59
+ import { parseMember, parseToken, memberTokens, nextMergeBot, nextRetry, liveOnLedger, settledOnLedger, retiredOnLedger, retiredName, readRetired, agentDefinition, expectedDefinition, CELL, POLICY_CELL, tierValues, rowPr, rowWriteRefusal, isReviewToken, addedReviewToken, newSplitRow, splitRowClause, added, premisesOf, MEMBER_FAMILIES, HALT_CAUSES, ROWS, DISPATCHED, FILED, RULED, DRAIN, RETIRED, RECEIPT, archiveStamp, receiptArchiveName, CLOSEOUT_RECEIPT, closeoutArchiveName, BRIEFS, briefsArchiveName, memberRole, parseDispatchedEntry, dispatchedEntry, headerRe, escapeText, unescapeText, nextReviewerName } from "./ledger-grammar.mjs";
59
60
  import { deriveRun, LedgerError, labelOffMember, HELD, CONFLICT_HOLD, PARKED } from "./fleet-tick.mjs";
60
61
  import { stateFileIn, readState, writeState, assessBeat, stallsAt } from "./fleet-state.mjs";
61
62
  import { processSource, ancestry, controllerOf, judgeController, ProcUnreadable } from "./proc.mjs";
@@ -760,6 +761,13 @@ if (cmd === "read") {
760
761
  // only be refused later, by `dispatch`, after the claim.
761
762
  const badTier = tierValues(line).find((v) => !CELL.test(v));
762
763
  if (badTier !== undefined) die(`row ${key}: 'tier=${badTier}' is not tier=<cell> — a cell is <slow|task|smol>-<level>`);
764
+ // A Pull's cell binds its implementer until that member settles; see
765
+ // cellChangeRefusal(). A new row binds nothing yet.
766
+ const i = data.rows.findIndex((r) => r.split(/\s/)[0] === key);
767
+ const created = i === -1;
768
+ const oldRow = created ? null : data.rows[i];
769
+ const cellWhy = cellChangeRefusal(oldRow, line);
770
+ if (cellWhy !== null) die(`row ${key}: ${cellWhy}`);
763
771
  // `label-off=<attempt>` tells the tick a missing `ready-to-merge` is
764
772
  // the controller's own removal, not a finisher's miss — so one naming no
765
773
  // finisher-pr member this run has would mask a real miss behind a record of
@@ -768,9 +776,6 @@ if (cmd === "read") {
768
776
  if (badOff !== undefined) {
769
777
  die(`row ${key}: '${badOff}' ${LABEL_OFF_UNKNOWN}`);
770
778
  }
771
- const i = data.rows.findIndex((r) => r.split(/\s/)[0] === key);
772
- const created = i === -1;
773
- const oldRow = created ? null : data.rows[i];
774
779
  const after = created ? [...data.rows, line] : data.rows.with(i, line);
775
780
  // Every review token is `review`'s to write, onto the PR's row in write
776
781
  // order, so `row` adds none; one the row already holds is carried.
@@ -1894,34 +1899,155 @@ function dispositionsRefusal(pr, review) {
1894
1899
  return null;
1895
1900
  }
1896
1901
 
1897
- // Why PR `pr`'s finisher waits on its ruling, or null when it does not: the
1898
- // PR is an implementer's — a settled `impl-<N>=PR#<pr>` token on a row names
1899
- // it, the token tier-outcomes.mjs `append` resolves a ticket from — and the
1900
- // main checkout's tier-outcomes.tsv has no row whose `pr` is it. Any row
1901
- // passes, whatever its date or verdicts, so a replacement finisher is never
1902
- // held once the first passed. The ledger lives in the main checkout's
1903
- // `.fleet/` (fleet-dir.mjs), so the file is found beside that directory —
1904
- // no git, no gh. A missing file is no row (`append` creates it); one that
1905
- // cannot be read or parsed refuses, naming it, and is never a pass.
1902
+ // PR `pr`'s ruling, as `{tickets, ruling, tsv, refusal}`: `tickets` the
1903
+ // tickets a settled `impl-<N>=PR#<pr>` token on a row names — the token
1904
+ // tier-outcomes.mjs `append` resolves a ticket from — and `ruling` the PR's
1905
+ // latest row in `tsv`, the main checkout's tier-outcomes.tsv, or null when
1906
+ // `refusal` is set or `tickets` is empty. `refusal` says why the
1907
+ // finisher waits, or is null when it does not: the PR is an implementer's and
1908
+ // the file has no row whose `pr` is it. Any row passes, whatever its date or
1909
+ // verdicts, so a replacement finisher is never held once the first passed.
1910
+ // The ledger lives in the main checkout's `.fleet/` (fleet-dir.mjs), so the
1911
+ // file is found beside that directory — no git, no gh. A missing file is no
1912
+ // row (`append` creates it); one that cannot be read or parsed refuses,
1913
+ // naming it, and is never a pass.
1906
1914
  // tier-outcomes.mjs is imported here, not at the top: its import graph is
1907
1915
  // several modules no other subcommand loads.
1908
- async function rulingRefusal(pr) {
1916
+ async function rulingOf(pr) {
1909
1917
  const { parseTierOutcomes, implementerClaims } = await import("./tier-outcomes.mjs");
1910
1918
  const tickets = [...new Set(implementerClaims(data, pr).map((c) => c.ticket))];
1911
- if (tickets.length === 0) return null;
1919
+ if (tickets.length === 0) return { tickets, ruling: null, refusal: null };
1912
1920
  const tsv = join(dirname(dirname(resolve(file))), "docs", "metrics", "tier-outcomes.tsv");
1913
1921
  if (existsSync(tsv)) {
1914
1922
  let rows;
1915
1923
  try {
1916
1924
  rows = parseTierOutcomes(readFileSync(tsv, "utf8"));
1917
1925
  } catch (e) {
1918
- return `cannot read the PR's ruling off ${tsv}: ${e.code ?? e.message} — fix the file, then dispatch the finisher again`;
1926
+ return { tickets, ruling: null, refusal: `cannot read the PR's ruling off ${tsv}: ${e.code ?? e.message} — fix the file, then dispatch the finisher again` };
1919
1927
  }
1920
- if (rows.some((r) => r.pr === String(pr))) return null;
1928
+ const ruling = rows.findLast((r) => r.pr === String(pr));
1929
+ if (ruling !== undefined) return { tickets, ruling, tsv, refusal: null };
1921
1930
  }
1922
- return `PR #${pr}, the implementer PR of ${tickets.length === 1 ? "ticket" : "tickets"} ${tickets.map((n) => `#${n}`).join(", ")}, has no ruling — `
1931
+ return { tickets, ruling: null, refusal: `PR #${pr}, the implementer PR of ${tickets.length === 1 ? "ticket" : "tickets"} ${tickets.map((n) => `#${n}`).join(", ")}, has no ruling — `
1923
1932
  + `${tsv} has no row for it; rule its review with \`~/.fleet/bin/fleet-run tier-outcomes.mjs append ${pr} --closed-own-ticket yes|no `
1924
- + `--minted-false-claim yes|no --note "<text>"\`, then dispatch the finisher again`;
1933
+ + `--minted-false-claim yes|no --note "<text>"\`, then dispatch the finisher again` };
1934
+ }
1935
+
1936
+ // The reads a check makes of GitHub and git, shared by settle --from-report
1937
+ // and the finisher gate. Every child runs from the ledger's repository with
1938
+ // runCheck()'s environment, for runCheck()'s reasons: GH_REPO would point gh
1939
+ // at another tracker whatever the cwd, and GIT_DIR/GIT_WORK_TREE git at
1940
+ // another repo. A read that cannot answer dies, naming itself, so nothing is
1941
+ // written past it.
1942
+ function checkReaders(where) {
1943
+ // The bound on each child a check runs: generous, because verify-sha.sh
1944
+ // fetches, and still a bound, because the child may never answer at all.
1945
+ const checkTimeoutMs = 60_000;
1946
+ const verifySha = join(dirname(fileURLToPath(import.meta.url)), "verify-sha.sh");
1947
+ const env = { ...process.env, GH_REPO: "" };
1948
+ delete env.GIT_DIR;
1949
+ delete env.GIT_WORK_TREE;
1950
+ let repo = null;
1951
+ const repoDir = () => {
1952
+ if (repo !== null) return repo;
1953
+ let dir = dirname(resolve(file));
1954
+ while (!existsSync(dir) && dirname(dir) !== dir) dir = dirname(dir);
1955
+ const r = spawnSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], { encoding: "utf8", env, timeout: GIT_TIMEOUT_MS });
1956
+ if (r.status !== 0) {
1957
+ const why = cause(r.error && r.error.message, r.stderr);
1958
+ die(`${where}: could not look — cannot resolve the ledger's repository (${dir})${why ? `: ${why}` : ""}; nothing written`);
1959
+ }
1960
+ repo = r.stdout.trim();
1961
+ return repo;
1962
+ };
1963
+ // A child that must answer with one of `answers`; anything else — another
1964
+ // status, a spawn failure, a timeout — could not look.
1965
+ const look = (what, cmd, args, answers = [0]) => {
1966
+ const r = spawnSync(cmd, args, { encoding: "utf8", cwd: repoDir(), env, timeout: checkTimeoutMs, stdio: ["ignore", "pipe", "pipe"] });
1967
+ if (r.status === null || !answers.includes(r.status)) {
1968
+ const why = cause(r.error && r.error.message, r.stderr);
1969
+ die(`${where}: could not look — ${what}${r.status === null ? "" : ` exited ${r.status}`}${why ? `: ${why}` : ""}; nothing written`);
1970
+ }
1971
+ return r;
1972
+ };
1973
+ const verifyShaOf = (branch, sha) => look(`verify-sha.sh ${branch} ${sha}`, "sh", [verifySha, branch, sha], [0, 1]);
1974
+ // A gh answer, parsed: `v` the JSON it printed, or null when it printed
1975
+ // none, beside the answer `r` itself for the caller's refusal.
1976
+ const lookJson = (what, args) => {
1977
+ const r = look(what, "gh", args);
1978
+ try {
1979
+ return { r, v: JSON.parse(r.stdout) };
1980
+ } catch {
1981
+ return { r, v: null };
1982
+ }
1983
+ };
1984
+ // A `gh pr view --json <fields>` answer: an object whose every requested
1985
+ // field has the type that field has. Anything else was not what was asked
1986
+ // for, so it could not look.
1987
+ const prFieldOk = { state: (v) => typeof v === "string", labels: Array.isArray, closingIssuesReferences: Array.isArray };
1988
+ const prView = (pr, fields) => {
1989
+ const what = `gh pr view ${pr} --json ${fields}`;
1990
+ const { r, v } = lookJson(what, ["pr", "view", String(pr), "--json", fields]);
1991
+ if (v === null || typeof v !== "object") {
1992
+ die(`${where}: could not look — ${what} printed no JSON object: ${cause(r.stdout)}; nothing written`);
1993
+ }
1994
+ const missing = fields.split(",").filter((f) => !prFieldOk[f](v[f]));
1995
+ if (missing.length > 0) {
1996
+ die(`${where}: could not look — ${what} answered without a usable ${missing.join(", ")}: ${cause(r.stdout)}; nothing written`);
1997
+ }
1998
+ return v;
1999
+ };
2000
+ // The title of issue `n`, as `gh issue view <n> --json title` answers it.
2001
+ const issueTitle = (n) => {
2002
+ const what = `gh issue view ${n} --json title`;
2003
+ const { r, v } = lookJson(what, ["issue", "view", String(n), "--json", "title"]);
2004
+ if (v === null || typeof v !== "object" || typeof v.title !== "string") {
2005
+ die(`${where}: could not look — ${what} printed no title: ${cause(r.stdout)}; nothing written`);
2006
+ }
2007
+ return v.title;
2008
+ };
2009
+ return { repoDir, look, verifyShaOf, prView, issueTitle };
2010
+ }
2011
+
2012
+ // The finisher gate's closing-reference checks for the PR `member` finishes,
2013
+ // read off the PR's closingIssuesReferences and the titles of the issues it
2014
+ // names; its second argument is rulingOf()'s answer, which let the PR
2015
+ // through. Check 1, when a settled implementer's token names the PR: each
2016
+ // ticket it names is closed by the body when the PR's ruling is
2017
+ // closed_own_ticket=yes, and is not when the ruling is no. Check 2, for every
2018
+ // PR: no reference may be the suggestion-band record of this PR's review — an
2019
+ // issue whose title, trimmed, is recordTitle(), as dispositions-check.mjs
2020
+ // finds the record. Returns one line per failure. A read the checks need
2021
+ // dies, naming the read, and writes nothing.
2022
+ async function closingRefusals(member, { tickets, ruling, tsv }) {
2023
+ const pr = member.number;
2024
+ const where = `dispatch ${member.name}`;
2025
+ const { recordTitle } = await import("./dispositions-check.mjs");
2026
+ const { prView, issueTitle } = checkReaders(where);
2027
+ const refs = [...new Set(prView(pr, "closingIssuesReferences").closingIssuesReferences.map((r) => r?.number))];
2028
+ if (!refs.every(Number.isInteger)) {
2029
+ die(`${where}: could not look — gh pr view ${pr} --json closingIssuesReferences answered a reference with no issue number; nothing written`);
2030
+ }
2031
+ const failures = [];
2032
+ if (tickets.length > 0) {
2033
+ const verdict = ruling.closed_own_ticket;
2034
+ if (verdict === "") {
2035
+ failures.push(`PR #${pr}'s row in ${tsv} has no closed_own_ticket verdict, so its closing references cannot be checked — set the verdict in that row, then dispatch the finisher again`);
2036
+ } else if (verdict === "yes") {
2037
+ for (const n of tickets) {
2038
+ if (!refs.includes(n)) failures.push(`PR #${pr} does not close #${n}, ruled closed_own_ticket=yes — add "Closes #${n}" to its body (gh pr edit ${pr} --body-file …)`);
2039
+ }
2040
+ } else {
2041
+ for (const n of tickets) {
2042
+ if (refs.includes(n)) failures.push(`PR #${pr} closes #${n}, ruled closed_own_ticket=no — rephrase it as "Refs #${n}"`);
2043
+ }
2044
+ }
2045
+ }
2046
+ const own = recordTitle(pr);
2047
+ for (const n of refs) {
2048
+ if (issueTitle(n).trim() === own) failures.push(`PR #${pr} closes #${n}, its own suggestion-band record — rephrase the mention as "Refs #${n}" or "issue #${n}"`);
2049
+ }
2050
+ return failures;
1925
2051
  }
1926
2052
 
1927
2053
  // The open `ready-to-merge` PRs, ascending, as gh answers from the ledger's
@@ -2079,9 +2205,11 @@ async function runDispatch() {
2079
2205
  // nothing for a fix-applier to rule on, and one never reviewed has no
2080
2206
  // review file: neither is gated on a verdict. Then an implementer's PR
2081
2207
  // waits on its ruling, its tier-outcomes row, written while the PR is open
2082
- // and its review evidence still in scratch; see rulingRefusal(). Refused
2083
- // here, before anything is written, so no check can be skipped by
2084
- // forgetting to run it.
2208
+ // and its review evidence still in scratch; see rulingOf(). Last, the
2209
+ // PR's closing references must match that ruling and exclude its own
2210
+ // suggestion-band record, read off gh from the ledger's repository; see
2211
+ // closingRefusals(). Refused here, before anything is written, so no check
2212
+ // can be skipped by forgetting to run it.
2085
2213
  if (member.family === "finisher-pr") {
2086
2214
  const fold = tickFold("conflict hold and dispositions verdict");
2087
2215
  if (fold.conflictHeld.includes(member.number)) {
@@ -2089,8 +2217,12 @@ async function runDispatch() {
2089
2217
  + `and let it settle applied:/no-op, then dispatch the finisher again — not dispatching ${member.name}`);
2090
2218
  }
2091
2219
  const review = fold.reviewed.find((r) => r.pr === member.number);
2092
- const refusal = dispositionsRefusal(member.number, review) ?? await rulingRefusal(member.number);
2093
- if (refusal !== null) die(`${member.name}: ${refusal} — not dispatching ${member.name}`);
2220
+ const disposed = dispositionsRefusal(member.number, review);
2221
+ if (disposed !== null) die(`${member.name}: ${disposed} — not dispatching ${member.name}`);
2222
+ const ruled = await rulingOf(member.number);
2223
+ if (ruled.refusal !== null) die(`${member.name}: ${ruled.refusal} — not dispatching ${member.name}`);
2224
+ const closing = await closingRefusals(member, ruled);
2225
+ if (closing.length > 0) die(`${member.name}: ${closing.join("; ")} — not dispatching ${member.name}`);
2094
2226
  }
2095
2227
  // Every error caught here carries its own cause and remedy — a row's `tier=`
2096
2228
  // (fix it with `ledger.mjs row`) for an implementer, a family
@@ -2429,12 +2561,8 @@ function mismatch(msg) {
2429
2561
 
2430
2562
  async function runSettleFromReport() {
2431
2563
  const usage = "usage: ledger.mjs settle <member> --from-report [--reports <dir>]";
2432
- const verifySha = join(dirname(fileURLToPath(import.meta.url)), "verify-sha.sh");
2433
2564
  const deriveTestcmd = join(dirname(fileURLToPath(import.meta.url)), "derive-testcmd.sh");
2434
2565
  const ready = "ready-to-merge";
2435
- // The bound on each child a check runs: generous, because verify-sha.sh
2436
- // fetches, and still a bound, because the child may never answer at all.
2437
- const checkTimeoutMs = 60_000;
2438
2566
  let member;
2439
2567
  let reports = null;
2440
2568
  for (let k = 0; k < rest.length; k++) {
@@ -2474,58 +2602,8 @@ async function runSettleFromReport() {
2474
2602
  const invalid = (violation) => die(`${where}: ${read.path} is not a ${reportFamily(member)} report — ${violation}; nothing written`);
2475
2603
  if (report.member !== member) invalid(`$.member: ${JSON.stringify(report.member)} is not ${member}`);
2476
2604
 
2477
- // Every child runs from the ledger's repository with runCheck()'s
2478
- // environment, for runCheck()'s reasons: GH_REPO would point gh at another
2479
- // tracker whatever the cwd, and GIT_DIR/GIT_WORK_TREE git at another repo.
2480
- const env = { ...process.env, GH_REPO: "" };
2481
- delete env.GIT_DIR;
2482
- delete env.GIT_WORK_TREE;
2483
- let repo = null;
2484
- const repoDir = () => {
2485
- if (repo !== null) return repo;
2486
- let dir = dirname(resolve(file));
2487
- while (!existsSync(dir) && dirname(dir) !== dir) dir = dirname(dir);
2488
- const r = spawnSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], { encoding: "utf8", env, timeout: GIT_TIMEOUT_MS });
2489
- if (r.status !== 0) {
2490
- const why = cause(r.error && r.error.message, r.stderr);
2491
- die(`${where}: could not look — cannot resolve the ledger's repository (${dir})${why ? `: ${why}` : ""}; nothing written`);
2492
- }
2493
- repo = r.stdout.trim();
2494
- return repo;
2495
- };
2496
- // A child that must answer with one of `answers`; anything else — another
2497
- // status, a spawn failure, a timeout — could not look.
2498
- const look = (what, cmd, args, answers = [0]) => {
2499
- const r = spawnSync(cmd, args, { encoding: "utf8", cwd: repoDir(), env, timeout: checkTimeoutMs, stdio: ["ignore", "pipe", "pipe"] });
2500
- if (r.status === null || !answers.includes(r.status)) {
2501
- const why = cause(r.error && r.error.message, r.stderr);
2502
- die(`${where}: could not look — ${what}${r.status === null ? "" : ` exited ${r.status}`}${why ? `: ${why}` : ""}; nothing written`);
2503
- }
2504
- return r;
2505
- };
2506
- const verifyShaOf = (branch, sha) => look(`verify-sha.sh ${branch} ${sha}`, "sh", [verifySha, branch, sha], [0, 1]);
2507
- // A `gh pr view --json <fields>` answer: an object whose every requested
2508
- // field has the type that field has. Anything else was not what was asked
2509
- // for, so it could not look.
2510
- const prFieldOk = { state: (v) => typeof v === "string", labels: Array.isArray, closingIssuesReferences: Array.isArray };
2511
- const prView = (pr, fields) => {
2512
- const what = `gh pr view ${pr} --json ${fields}`;
2513
- const r = look(what, "gh", ["pr", "view", String(pr), "--json", fields]);
2514
- let v = null;
2515
- try {
2516
- v = JSON.parse(r.stdout);
2517
- } catch {
2518
- // Falls through to the refusal below.
2519
- }
2520
- if (v === null || typeof v !== "object") {
2521
- die(`${where}: could not look — ${what} printed no JSON object: ${cause(r.stdout)}; nothing written`);
2522
- }
2523
- const missing = fields.split(",").filter((f) => !prFieldOk[f](v[f]));
2524
- if (missing.length > 0) {
2525
- die(`${where}: could not look — ${what} answered without a usable ${missing.join(", ")}: ${cause(r.stdout)}; nothing written`);
2526
- }
2527
- return v;
2528
- };
2605
+ // The gh and git reads this check makes, shared with the finisher gate.
2606
+ const { repoDir, look, verifyShaOf, prView } = checkReaders(where);
2529
2607
  // origin's head of `branch`, or null when origin has no such branch.
2530
2608
  const remoteHead = (branch) => {
2531
2609
  const ref = `refs/heads/${branch}`;
@@ -2772,17 +2850,77 @@ function badLabelOff(words) {
2772
2850
  });
2773
2851
  }
2774
2852
 
2775
- // Why `token` may not write `tok` onto a row whose text is `rowText` (the row
2776
- // as it stands plus the call's earlier tokens), or null when it may. A token
2777
- // is one word: an empty argument, one with a space in it (prose, which would
2778
- // split into words the row's own words then dedupe against), and the row's
2779
- // ` · ` separator are no token. A member token is `dispatch`'s or `settle`'s,
2780
- // a review token `review`'s. A word shaped like a member that the grammar
2781
- // cannot parse, a `tier=` that is not a cell, a `tier=` naming a second tier
2782
- // on a row that already has one, a `held-behind`, `conflict-hold` or `parked`
2783
- // token the tick cannot read, and a lone `→` — an arrow with no PR beside it
2784
- // points at nothing — are refused. Every other word is a freeform token.
2785
- function tokenRefusal(tok, rowText) {
2853
+ // The cell a row's implementer runs at — its `tier=`, or POLICY_CELL on a row
2854
+ // with none — read the way `dispatch` reads it: null for a row whose `tier=`
2855
+ // values conflict or name no agents/<definition>.agent.md, which `dispatch`
2856
+ // refuses, so such a row has no draw to bind.
2857
+ function runnableCell(rowText) {
2858
+ let definition;
2859
+ try {
2860
+ definition = expectedDefinition(rowText);
2861
+ } catch {
2862
+ return null;
2863
+ }
2864
+ if (!existsSync(join(AGENTS_DIR, `${definition}.agent.md`))) return null;
2865
+ return tierValues(rowText)[0] ?? POLICY_CELL;
2866
+ }
2867
+
2868
+ // The member a row's cell binds, while it has not settled, else null: the
2869
+ // newest implementer for the row's ticket, on the row or in `## Dispatched`,
2870
+ // newest by retry suffix as nextRetry() orders them. That is the Pull's first
2871
+ // implementer on an ordinary row, and a resumed or re-Pulled row's latest.
2872
+ // Not settled is live and not yet dispatched alike: a bare token on the row
2873
+ // binds as a bare `## Dispatched` entry does.
2874
+ function unsettledImplementer(rowText) {
2875
+ const ticket = Number(rowKey(rowText).slice(1));
2876
+ const newest = [...memberTokens(rowText), ...data.dispatched.map(parseToken)]
2877
+ .filter((t) => t !== null && t.error === null && t.family === "impl" && t.number === ticket)
2878
+ .map((t) => ({ name: t.name, attempt: t.retry ?? "a" }))
2879
+ .sort((a, b) => (a.attempt < b.attempt ? -1 : a.attempt > b.attempt ? 1 : 0))
2880
+ .at(-1);
2881
+ if (newest === undefined || settledOnLedger(data.rows, data.dispatched).has(newest.name)) return null;
2882
+ return newest.name;
2883
+ }
2884
+
2885
+ // Why a write turning row `oldLine` into `line` may not land, or null when it
2886
+ // may: a Pull's cell binds its implementer, so a write that changes the
2887
+ // row's cell is refused until that member has settled, when a replacement
2888
+ // may run at another cell. A new row, a row with no runnable definition and
2889
+ // a write that leaves the cell as it was bind nothing.
2890
+ function cellChangeRefusal(oldLine, line) {
2891
+ if (oldLine === null) return null;
2892
+ const cell = runnableCell(oldLine);
2893
+ if (cell === null) return null;
2894
+ const tiers = [...new Set(tierValues(line))];
2895
+ const to = tiers.length === 0 ? POLICY_CELL : tiers.join(" and ");
2896
+ if (to === cell) return null;
2897
+ const member = unsettledImplementer(oldLine);
2898
+ if (member === null) return null;
2899
+ return `changes the Pull's cell from ${cell} to ${to} while ${member} has not settled — the cell binds its implementer; once ${member} has settled, \`ledger.mjs token ${rowKey(oldLine).slice(1)} tier=<cell>\` sets a replacement's cell`;
2900
+ }
2901
+
2902
+ // `line` with `tok` in place of its first `tier=` word, every other `tier=`
2903
+ // word dropped with the ` · ` before it.
2904
+ function withTier(line, tok) {
2905
+ let placed = false;
2906
+ return line.replace(/(\s+·)?\s+tier=\S*/g, (m) => {
2907
+ if (placed) return "";
2908
+ placed = true;
2909
+ return m.replace(/tier=\S*$/, () => tok);
2910
+ });
2911
+ }
2912
+
2913
+ // Why `token` may not write `tok`, or null when it may; `earlier` is the
2914
+ // call's tokens before it. A token is one word: an empty argument, one with
2915
+ // a space in it (prose, which would split into words the row's own words
2916
+ // then dedupe against), and the row's ` · ` separator are no token. A member
2917
+ // token is `dispatch`'s or `settle`'s, a review token `review`'s. A word
2918
+ // shaped like a member that the grammar cannot parse, a `tier=` that is not
2919
+ // a cell, a `tier=` naming a second tier in one call, a `held-behind`,
2920
+ // `conflict-hold` or `parked` token the tick cannot read, and a lone `→` — an
2921
+ // arrow with no PR beside it points at nothing — are refused. Every other
2922
+ // word is a freeform token.
2923
+ function tokenRefusal(tok, earlier) {
2786
2924
  if (tok === "" || /\s/.test(tok)) {
2787
2925
  return "is not one token — pass each token as its own argument, with no space in it";
2788
2926
  }
@@ -2808,9 +2946,9 @@ function tokenRefusal(tok, rowText) {
2808
2946
  const [tier] = tierValues(tok);
2809
2947
  if (tier !== undefined) {
2810
2948
  if (!CELL.test(tier)) return "is not tier=<cell> — a cell is <slow|task|smol>-<level>";
2811
- const other = tierValues(rowText).find((v) => v !== tier);
2949
+ const other = tierValues(earlier).find((v) => v !== tier);
2812
2950
  if (other !== undefined) {
2813
- return `names a second tier — the row already carries tier=${other}, and a row names one; replacing it takes ledger.mjs row, which rewrites the whole line`;
2951
+ return `names a second tier — this call already names tier=${other}, and a row names one`;
2814
2952
  }
2815
2953
  }
2816
2954
  if (tok === "→") return "is the row's arrow, which is written with the PR it points at — ledger.mjs row writes the whole line";
@@ -2822,8 +2960,11 @@ function tokenRefusal(tok, rowText) {
2822
2960
  // — the PR's row for a PR key, the ticket row once `settle impl-<N>=PR#M` has
2823
2961
  // run, else `#M`, created when neither exists: `review`'s lookup — keeping
2824
2962
  // every token already there, in order, as ` · <token>`. A token the row
2825
- // already carries is a no-op. Every refusal is judged before anything is
2826
- // written, whole call alike, under the lock.
2963
+ // already carries is a no-op. A `tier=` replaces the row's: in place of its
2964
+ // first `tier=`, dropping any other, refused by cellChangeRefusal() while the
2965
+ // row's implementer has not settled; it is a no-op only on a row whose one
2966
+ // tier it already is. Every refusal is judged before anything is written,
2967
+ // whole call alike, under the lock.
2827
2968
  function runToken() {
2828
2969
  const usage = "usage: ledger.mjs token <ticket|pr> <token>…";
2829
2970
  const [keyArg, ...tokens] = rest;
@@ -2832,20 +2973,23 @@ function runToken() {
2832
2973
  const i = prRowIndex(num);
2833
2974
  const created = i === -1;
2834
2975
  const oldLine = created ? null : data.rows[i];
2835
- let carried = oldLine ?? "";
2976
+ let earlier = "";
2836
2977
  for (const tok of tokens) {
2837
- const why = tokenRefusal(tok, carried);
2978
+ const why = tokenRefusal(tok, earlier);
2838
2979
  if (why !== null) die(`token #${num}: '${tok}' ${why}`);
2839
- carried += ` ${tok}`;
2980
+ earlier += ` ${tok}`;
2840
2981
  }
2841
2982
  const key = created ? `#${num}` : rowKey(oldLine);
2842
2983
  const have = new Set(created ? [] : rowTokens(oldLine));
2984
+ let tiers = [...new Set(tierValues(oldLine))];
2843
2985
  const appended = [];
2844
2986
  for (const tok of tokens) {
2845
- if (have.has(tok)) {
2987
+ const [tier] = tierValues(tok);
2988
+ if (tier === undefined ? have.has(tok) : tiers.length === 1 && tiers[0] === tier) {
2846
2989
  console.error(` already present on row ${key}: ${tok}`);
2847
2990
  } else {
2848
- have.add(tok);
2991
+ if (tier === undefined) have.add(tok);
2992
+ else tiers = [tier];
2849
2993
  appended.push(tok);
2850
2994
  }
2851
2995
  }
@@ -2853,7 +2997,16 @@ function runToken() {
2853
2997
  console.log(JSON.stringify({ key, line: oldLine, added: appended }));
2854
2998
  return;
2855
2999
  }
2856
- const line = created ? `${key} ${appended.join(" · ")}` : `${oldLine} · ${appended.join(" · ")}`;
3000
+ const tierTok = appended.find((tok) => tierValues(tok).length > 0);
3001
+ const replaced = tierTok === undefined ? [] : tierValues(oldLine).map((v) => `tier=${v}`);
3002
+ // `appended` holds at most one `tier=`: tokenRefusal() refuses a second
3003
+ // tier in one call, and the loop above drops a repeat of it.
3004
+ const others = replaced.length > 0 ? appended.filter((tok) => tok !== tierTok) : appended;
3005
+ const line = created
3006
+ ? `${key} ${appended.join(" · ")}`
3007
+ : [replaced.length > 0 ? withTier(oldLine, tierTok) : oldLine, ...others].join(" · ");
3008
+ const cellWhy = cellChangeRefusal(oldLine, line);
3009
+ if (cellWhy !== null) die(`token ${key}: ${cellWhy}`);
2857
3010
  const after = created ? [...data.rows, line] : data.rows.with(i, line);
2858
3011
  // A freeform token can still move the PR a row maps to (a `PR#<n>` mention
2859
3012
  // on a row with no implementer), or split one PR across two rows.
@@ -2867,6 +3020,7 @@ function runToken() {
2867
3020
  if (refusal !== null) die(`token ${key}: ${refusal}`);
2868
3021
  data.rows = after;
2869
3022
  save(data);
2870
- console.error(` appended ${appended.join(" ")} to row ${key}`);
3023
+ if (replaced.length > 0) console.error(` replaced ${replaced.join(" ")} with ${tierTok} on row ${key}`);
3024
+ if (others.length > 0) console.error(` appended ${others.join(" ")} to row ${key}`);
2871
3025
  console.log(JSON.stringify({ key, line, added: appended }));
2872
3026
  }
@@ -558,6 +558,7 @@ Dispatch a finisher only when every test here passes at once — the review retu
558
558
  bullet and the one above are a pair, and on a clean PR this one carries the
559
559
  whole route to a finisher**: a `no-op` report satisfies the gate above by
560
560
  itself, 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.
561
+ - **The PR's closing references match its ruling and exclude its own suggestion-band record** → `ledger.mjs dispatch` reads the PR's closing references from `gh pr view <M>` before it dispatches any finisher. When the ruled row says `closed_own_ticket=yes`, each ticket a settled `impl-<N>=PR#<M>` token names must be closed by the body; when it says `no`, none may be, and no reference may be the PR's own suggestion-band record. A refusal names the PR and the ticket; repair the body yourself with `gh pr edit <M> --body-file <file>`, then dispatch the finisher again.
561
562
  - **`ci-state.mjs --pr <N>` reads `verdict: "no-ci"`** → the CI edge never
562
563
  fires: dispatch the finisher off the reviewer's final verdict the moment it
563
564
  lands, never while you still owe it a reply. Its gate is then the
@@ -568,18 +569,6 @@ Dispatch a finisher only when every test here passes at once — the review retu
568
569
  Gate on the `check` job, **not** on `ci-state --quiet` exit 0, which a behind
569
570
  PR never reaches; the finisher reads per-job state, without `--quiet`.
570
571
 
571
- **The `--declare-no-ci` declaration is caller-side, and reading it back out of
572
- the payload verifies nothing.** Check the two facts it stands for instead:
573
-
574
- 1. `ci-state.mjs` **without** the flag still reads `verdict: "no-ci"`; a merely
575
- misconfigured CI exits 2 there.
576
- 2. The reviewer's own final verdict reports a green `testCmd` run on the SHA it
577
- pushed.
578
-
579
- Both → add the label, and say it rests on the reviewer's suite run, not on CI.
580
- Either missing → halt here, same as a dirty worktree: report `no CI configured,
581
- no verified suite run on record` and add no label.
582
-
583
572
  ### The dispatch and its refusals
584
573
 
585
574
  **The fix-applier pushes and exits — it does not hold the CI wait**; you own the
@@ -117,6 +117,7 @@ and will act on it. Relay everything — reversals too — then dispatch.
117
117
  append` command to run. Any row for the PR passes, so a replacement finisher
118
118
  is never held once the first passed; a PR no such token names is not gated.
119
119
  No flag skips it: a PR `append` refuses is fixed on the ledger, as at close-out.
120
+ - **The PR's closing references match its ruling and exclude its own suggestion-band record** → `ledger.mjs dispatch` reads the PR's `closingIssuesReferences` from `gh pr view <M>` before it dispatches any finisher. When the ruled row says `closed_own_ticket=yes`, each ticket a settled `impl-<N>=PR#<M>` token names must be closed by the body (`Closes #<N>`); when it says `no`, none may be, and no reference may be the PR's own suggestion-band record. A blank verdict is refused until it is set. A refusal names the PR and the ticket. The controller repairs the body itself with `gh pr edit <M> --body-file <file>`, then dispatches the finisher again. A `gh` read that fails refuses the dispatch and writes nothing.
120
121
 
121
122
  Gate on the `check` job, **not** on `ci-state --quiet` exit 0: a behind PR never
122
123
  reaches full green, so an exit-0 gate strands it unlabelled. The finisher reads
@@ -62,7 +62,7 @@ One git-ignored `.fleet/ledger.md` per run is updated at every state change thro
62
62
 
63
63
  ## One line per ticket
64
64
 
65
- One line per ticket, rewritten in place (`ledger.mjs row <ticket> <text>`; `ledger.mjs token <key> <token>…` adds a token and keeps every token already on the row):
65
+ One line per ticket, rewritten in place (`ledger.mjs row <ticket> <text>`; `ledger.mjs token <key> <token>…` adds a token and keeps every token already on the row, and replaces the row's `tier=`, refused while its implementer has not settled):
66
66
 
67
67
  ```
68
68
  #<t1> impl-<t1>=PR#<p1> → PR#<p1> → MERGED 73b356de
@@ -108,4 +108,4 @@ The ledger half reports its own readability as its own field, `ledger.ok`, the p
108
108
 
109
109
  ## Adding a token
110
110
 
111
- 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.
111
+ Token additions go through `ledger.mjs token`. `ledger.mjs token <key> <token>…` adds a token and keeps every token already on the row. A `tier=` replaces the row's `tier=` instead and keeps every other token; while the row's implementer has not settled, `token` and `row` both refuse a write that changes the row's cell. Re-typing the row with `row` replaces the whole line, so it drops the tokens you do not restate.
@@ -434,7 +434,11 @@ true every Pull draws, over every cell, the policy cell included. An
434
434
  Exploration Pull records `tier=<cell>` in the row, and a replacement inherits the row's tier —
435
435
  written at the Pull's row step, ahead of `ledger.mjs dispatch`, which reads it
436
436
  and prints `fleet-implementer-<cell>` as the `agent` to dispatch; a row with
437
- no `tier=` runs the policy cell. The
437
+ no `tier=` runs the policy cell. The Pull's cell binds the row's implementer
438
+ (its newest, on a resumed or re-Pulled row):
439
+ `ledger.mjs row` and `ledger.mjs token` refuse a write that changes it until that implementer has
440
+ settled. Once it has, a replacement may run at another cell, set with
441
+ `ledger.mjs token <N> tier=<cell>`. The
438
442
  assignment rolls to the next Pull when another open ticket sequences after
439
443
  it — a ticket the rest of the run
440
444
  depends on; `class` is not read. Do not tell the member it is a control: a member that