@feigi/fleet-ctl 3.82.13 → 3.82.15

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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: fleet-implementer-slow-high
3
- description: A `/skill:run-team` implementer at cell slow-high. Dispatched by the controller in phase 2, never invoked directly; the body is byte-identical across every fleet-implementer-* definition.
3
+ description: A `/skill:run-team` implementer at cell slow-high. Dispatched by the controller at the Pull's dispatch, never invoked directly; the body is byte-identical across every fleet-implementer-* definition.
4
4
  model: "@slow:high"
5
5
  ---
6
6
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: fleet-implementer-slow-medium
3
- description: A `/skill:run-team` implementer at cell slow-medium. Dispatched by the controller in phase 2, never invoked directly; the body is byte-identical across every fleet-implementer-* definition.
3
+ description: A `/skill:run-team` implementer at cell slow-medium. Dispatched by the controller at the Pull's dispatch, never invoked directly; the body is byte-identical across every fleet-implementer-* definition.
4
4
  model: "@slow:medium"
5
5
  ---
6
6
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: fleet-implementer-smol-high
3
- description: A `/skill:run-team` implementer at cell smol-high. Dispatched by the controller in phase 2, never invoked directly; the body is byte-identical across every fleet-implementer-* definition.
3
+ description: A `/skill:run-team` implementer at cell smol-high. Dispatched by the controller at the Pull's dispatch, never invoked directly; the body is byte-identical across every fleet-implementer-* definition.
4
4
  model: "@smol:high"
5
5
  ---
6
6
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: fleet-implementer-task-high
3
- description: A `/skill:run-team` implementer at cell task-high. Dispatched by the controller in phase 2, never invoked directly; the body is byte-identical across every fleet-implementer-* definition.
3
+ description: A `/skill:run-team` implementer at cell task-high. Dispatched by the controller at the Pull's dispatch, never invoked directly; the body is byte-identical across every fleet-implementer-* definition.
4
4
  model: "@task:high"
5
5
  ---
6
6
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: fleet-implementer-task-max
3
- description: A `/skill:run-team` implementer at cell task-max. Dispatched by the controller in phase 2, never invoked directly; the body is byte-identical across every fleet-implementer-* definition.
3
+ description: A `/skill:run-team` implementer at cell task-max. Dispatched by the controller at the Pull's dispatch, never invoked directly; the body is byte-identical across every fleet-implementer-* definition.
4
4
  model: "@task:max"
5
5
  ---
6
6
 
@@ -13,8 +13,9 @@ argument-hint: [pr-number]
13
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
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`.
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
+ **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. 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.
16
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.
17
- 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 **Run ledger** 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.
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.
18
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.
19
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
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feigi/fleet-ctl",
3
- "version": "3.82.13",
3
+ "version": "3.82.15",
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": {
@@ -56,7 +56,7 @@ import { deriveRun, LedgerError, labelOffMember, HELD, CONFLICT_HOLD } from "./f
56
56
  import { stateFileIn, readState, writeState, assessBeat, stallsAt } from "./fleet-state.mjs";
57
57
  import { processSource, ancestry, controllerOf, judgeController, ProcUnreadable } from "./proc.mjs";
58
58
  import { attemptLock, releaseLockFile, reapPathOf } from "./pid-lock.mjs";
59
- import { readReportIn, reportsDir, reportFamily, defaultScratch } from "./report-files.mjs";
59
+ import { readCheckRecordIn, readReportIn, reportsDir, reportFamily, defaultScratch } from "./report-files.mjs";
60
60
 
61
61
  const NAME = "ledger";
62
62
  // The plugin's own agent definitions — the directory tier-check.mjs reads
@@ -2250,9 +2250,12 @@ function recordFiled(id, subject) {
2250
2250
  // checked before anything is written:
2251
2251
  //
2252
2252
  // impl-N=PR#M verify-sha.sh <branch> <head> exits 0; PR M
2253
- // reads OPEN and its closingIssuesReferences name N
2253
+ // reads OPEN and its closingIssuesReferences name N;
2254
+ // the Check-step record (below) holds for <head>
2254
2255
  // impl-N=bailed bailCause is not null; the receipt names it
2255
- // fix-pr-M=applied:<head> origin's refs/heads/<branch> is <head>
2256
+ // fix-pr-M=applied:<head> origin's refs/heads/<branch> is <head>, fetched
2257
+ // by verify-sha.sh; the Check-step record (below)
2258
+ // holds for <head>
2256
2259
  // fix-pr-M=no-op origin's refs/heads/<branch> is the head PR M's
2257
2260
  // latest reviewed= names, as the tick folds it
2258
2261
  // finisher-pr-M=labelled PR M's labels carry ready-to-merge, and so does
@@ -2261,6 +2264,16 @@ function recordFiled(id, subject) {
2261
2264
  // evidence is not empty
2262
2265
  // merge-bot-n=done every merged[].pr reads MERGED
2263
2266
  //
2267
+ // THE CHECK-STEP RECORD, for `impl-N=PR#M` and `fix-pr-M=applied:<head>`:
2268
+ // recipe-check.mjs writes `<reports>/<member>.check.json` on the member's
2269
+ // staged tree, and settle requires that it exists and parses, that the Check
2270
+ // step and the Test entrypoint both exited 0 in it, and that its `tree` is
2271
+ // `<head>^{tree}`, so a message-only amend keeps it valid while any content
2272
+ // change or rebase does not. Settle runs neither command: the record is the
2273
+ // member's word, at the trust level of its report. A repository whose Recipe
2274
+ // cache derive-testcmd.sh refuses has no gate. A `<head>^{tree}` git cannot
2275
+ // read is "could not look", never a tree mismatch.
2276
+ //
2264
2277
  // A `failed` report claims no state, so it settles `=failed` unchecked. A
2265
2278
  // fix-applier's `refuted` deferral claims one more thing: that its disposition
2266
2279
  // record gives the reversal a reason, which is the text `## Ruled` gets.
@@ -2298,6 +2311,7 @@ function mismatch(msg) {
2298
2311
  async function runSettleFromReport() {
2299
2312
  const usage = "usage: ledger.mjs settle <member> --from-report [--reports <dir>]";
2300
2313
  const verifySha = join(dirname(fileURLToPath(import.meta.url)), "verify-sha.sh");
2314
+ const deriveTestcmd = join(dirname(fileURLToPath(import.meta.url)), "derive-testcmd.sh");
2301
2315
  const ready = "ready-to-merge";
2302
2316
  // The bound on each child a check runs: generous, because verify-sha.sh
2303
2317
  // fetches, and still a bound, because the child may never answer at all.
@@ -2370,6 +2384,7 @@ async function runSettleFromReport() {
2370
2384
  }
2371
2385
  return r;
2372
2386
  };
2387
+ const verifyShaOf = (branch, sha) => look(`verify-sha.sh ${branch} ${sha}`, "sh", [verifySha, branch, sha], [0, 1]);
2373
2388
  // A `gh pr view --json <fields>` answer: an object whose every requested
2374
2389
  // field has the type that field has. Anything else was not what was asked
2375
2390
  // for, so it could not look.
@@ -2405,6 +2420,32 @@ async function runSettleFromReport() {
2405
2420
  checks.push({ name, ok });
2406
2421
  return ok;
2407
2422
  };
2423
+ // The Recipe gate, for the two reports that name a pushed head: the member's
2424
+ // Check-step record must show the Recipe's Check step and the Test
2425
+ // entrypoint both exited 0 against the very tree `head` holds. Nothing here
2426
+ // runs either command — the record is the member's word, at the trust level
2427
+ // of its report. A repository whose Recipe cache derive-testcmd.sh refuses
2428
+ // (exit 1: absent, unproven, invalid) has no Check step to have run, so no
2429
+ // gate applies; any other answer from it could not look. `head`'s commit
2430
+ // object must be local when the tree is read: `fetchHead`, when given, puts
2431
+ // it there, and runs only once a gate applies.
2432
+ const recipeGate = (head, fetchHead) => {
2433
+ for (const field of ["check", "test"]) {
2434
+ if (look(`derive-testcmd.sh . ${field}`, "sh", [deriveTestcmd, repoDir(), field], [0, 1]).status === 1) return;
2435
+ }
2436
+ const rec = readCheckRecordIn(reports, member);
2437
+ const label = rec.state === "absent" ? `a Check-step record exists at ${rec.path}`
2438
+ : rec.state === "ok" ? `Check-step record ${rec.path} exists and parses`
2439
+ : `Check-step record ${rec.path} parses: ${rec.violation}`;
2440
+ if (!check(label, rec.state === "ok")) return;
2441
+ const { check: step, test, tree } = rec.record;
2442
+ const exited = (c) => (c.exitCode === null ? "not run" : `exit ${c.exitCode}`);
2443
+ check(`the recorded Check step exited 0 (recorded: ${exited(step)})`, step.exitCode === 0);
2444
+ check(`the recorded Test entrypoint exited 0 (recorded: ${exited(test)})`, test.exitCode === 0);
2445
+ fetchHead?.();
2446
+ const headTree = look(`git rev-parse --verify ${head}^{tree}`, "git", ["rev-parse", "--verify", `${head}^{tree}`]).stdout.trim();
2447
+ check(`the recorded tree ${tree} is ${head}'s tree ${headTree}`, tree === headTree);
2448
+ };
2408
2449
  const ruledLines = [];
2409
2450
  let outcome;
2410
2451
  if (live.family === "impl") {
@@ -2412,8 +2453,9 @@ async function runSettleFromReport() {
2412
2453
  if (report.pr === null) invalid('$.pr: null is not a PR number, with outcome "pr"');
2413
2454
  if (report.head === null) invalid('$.head: null, with outcome "pr"');
2414
2455
  outcome = `PR#${report.pr}`;
2415
- const sha = look(`verify-sha.sh ${report.branch} ${report.head}`, "sh", [verifySha, report.branch, report.head], [0, 1]);
2456
+ const sha = verifyShaOf(report.branch, report.head);
2416
2457
  check(`verify-sha.sh ${report.branch} ${report.head} exits 0`, sha.status === 0);
2458
+ recipeGate(report.head);
2417
2459
  const pr = prView(report.pr, "state,closingIssuesReferences");
2418
2460
  check(`PR #${report.pr} reads OPEN`, pr.state === "OPEN");
2419
2461
  check(`PR #${report.pr} closes #${live.number}`, pr.closingIssuesReferences.some((c) => c?.number === live.number));
@@ -2426,6 +2468,12 @@ async function runSettleFromReport() {
2426
2468
  outcome = `applied:${report.head}`;
2427
2469
  const head = remoteHead(report.branch);
2428
2470
  check(`origin refs/heads/${report.branch} is ${report.head}`, head === report.head);
2471
+ if (head === report.head) {
2472
+ // verify-sha.sh is the fetch that puts the head's commit object here,
2473
+ // which the tree read needs; its exit 2 is a fetch that failed or an
2474
+ // object that is not there, so it could not look.
2475
+ recipeGate(report.head, () => verifyShaOf(report.branch, report.head));
2476
+ }
2429
2477
  } else if (report.outcome === "no-op") {
2430
2478
  outcome = "no-op";
2431
2479
  let fold;
@@ -0,0 +1,260 @@
1
+ #!/usr/bin/env node
2
+ // Run the Recipe's Check step and the Test entrypoint against the staged tree,
3
+ // and record that both passed — the record `ledger.mjs settle --from-report`
4
+ // requires before it settles an implementer's or a fix-applier's report.
5
+ //
6
+ // recipe-check.mjs --member <name> --testCmd <command> [--reports <dir>]
7
+ //
8
+ // Run from the member's worktree, after `git add` and before the commit and
9
+ // push, and again after any content change or rebase. Reached through the
10
+ // Resolver: `~/.fleet/bin/fleet-run recipe-check.mjs ...`.
11
+ //
12
+ // `--member` is the `impl-<N>[-x]` or `fix-pr-<M>[-x]` name the member was
13
+ // dispatched as. `--testCmd` is the Test entrypoint exactly as the member was
14
+ // handed it, a shell command run from the worktree root: the member's runner
15
+ // already goes through the suite-slot wrapper, so this script adds none of its
16
+ // own and never runs the suite a second time. The Check step is not an
17
+ // argument: it is read off the repository's Recipe cache, through
18
+ // derive-testcmd.sh, the one reader of it. `--reports` is the directory the
19
+ // member's report is written to, `<scratch>/reports`; omitted, the default
20
+ // scratch root's.
21
+ //
22
+ // Steps, in this order:
23
+ //
24
+ // 1 read the Check step and the Test entrypoint off the Recipe cache. A
25
+ // worktree with no usable Recipe has no Check step to run: exit 2,
26
+ // nothing run, nothing written. Settle reads the same cache from the
27
+ // ledger's repository, where derive-testcmd.sh's probes of the Test
28
+ // entrypoint can answer otherwise, so it skips its gate only where its
29
+ // own read refuses.
30
+ // 2 `git write-tree`: the tree id of the staged index. The commands run in
31
+ // the working tree, so it must hold exactly that tree: an unstaged change
32
+ // to a tracked file, or an untracked file no ignore rule covers, is a
33
+ // refusal, exit 2 — a green record would otherwise name a tree that never
34
+ // ran. Then the reports directory is created, before anything runs.
35
+ // 3 the Check step, `sh -c` from the worktree root. A Check step of
36
+ // `true` is recorded as green without being run.
37
+ // 4 the Test entrypoint, the same way — only when the Check step passed.
38
+ //
39
+ // The commands' own output passes through on stdout and stderr as it arrives.
40
+ //
41
+ // THE RECORD is `<reports>/<member>.check.json`, beside the member's report
42
+ // (report-files.mjs names the path and reads it back), written whole and
43
+ // atomically once both commands have settled:
44
+ //
45
+ // { member, tree, check: {command, ran, exitCode, pass, fail},
46
+ // test: {command, ran, exitCode, pass, fail} }
47
+ //
48
+ // tree the `git write-tree` of step 2. Settle compares it with the
49
+ // reported head's own tree, so a message-only amend keeps the
50
+ // record valid and a rebase onto a moved base, or any content
51
+ // change, does not.
52
+ // ran false for a Check step of `true`, and for a command skipped
53
+ // because the Check step failed (its exitCode is then null).
54
+ // exitCode the command's status, a signal's number plus 128.
55
+ // pass, fail the counts the runner's summary printed — a `# pass N` /
56
+ // `ℹ fail N` line, or `N passed`/`N failed`/`N passing`/`N
57
+ // failing` — null where it printed none.
58
+ //
59
+ // A record is written whenever a command ran and failed, so a red run
60
+ // replaces an older record rather than leaving it standing; it records the
61
+ // failure, and settle refuses on it. A record is a claim, no attestation: the
62
+ // member produces it, and nothing here stops a forged one.
63
+ //
64
+ // Exit status: 0 both commands passed and the record is written; 1 a command
65
+ // failed — the record says which, and the script's stderr names it; 2 a
66
+ // refusal that ran nothing and wrote nothing (usage, no usable Recipe, git
67
+ // could not be read, a working tree that is not the staged tree, a reports
68
+ // directory that could not be created); 75 the Test entrypoint printed exit
69
+ // 75 — the suite wrapper found no slot and launched nothing, which is no test
70
+ // result — so no record is written, and the same command is to be run again.
71
+
72
+ import { spawn, spawnSync } from "node:child_process";
73
+ import { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
74
+ import { constants as osConstants } from "node:os";
75
+ import { dirname, join } from "node:path";
76
+ import { fileURLToPath } from "node:url";
77
+ import { makeDie, defineFlags, writeAll } from "./arg.mjs";
78
+ import { FleetDirUnresolvable } from "./fleet-dir.mjs";
79
+ import { gitEnv } from "./git-env.mjs";
80
+ import { isCLI } from "./is-cli.mjs";
81
+ import { parseMember } from "./ledger-grammar.mjs";
82
+ import { checkRecordPathIn, defaultScratch, reportsDir } from "./report-files.mjs";
83
+
84
+ const NAME = "recipe-check";
85
+ const USAGE = "usage: recipe-check.mjs --member <name> --testCmd <command> [--reports <dir>]";
86
+ const DERIVE = join(dirname(fileURLToPath(import.meta.url)), "derive-testcmd.sh");
87
+ const GIT_TIMEOUT_MS = 30_000;
88
+ // What the suite wrapper exits with when it found no slot and launched nothing.
89
+ const NO_SLOT = 75;
90
+ // The tail of a command's output kept for the counts: a runner prints its
91
+ // summary last.
92
+ const TAIL_BYTES = 256 * 1024;
93
+
94
+ const ANSI = /\x1b\[[0-9;]*[A-Za-z]/g;
95
+
96
+ /** `{pass, fail}` the output's summary printed, each null where it printed no
97
+ * count. The last count of a kind wins: a summary follows the lines it sums. */
98
+ export function parseCounts(output) {
99
+ const text = output.replace(ANSI, "");
100
+ const last = (patterns) => {
101
+ for (const re of patterns) {
102
+ const all = [...text.matchAll(re)];
103
+ if (all.length > 0) return Number(all.at(-1)[1]);
104
+ }
105
+ return null;
106
+ };
107
+ const count = (word, loose) => last([new RegExp(`^[ \\t]*(?:#|ℹ)[ \\t]+${word}[ \\t]+(\\d+)[ \\t]*$`, "gm"), loose]);
108
+ return {
109
+ pass: count("pass", /\b(\d+)[ \t]+(?:passed|passing)\b/g),
110
+ fail: count("fail", /\b(\d+)[ \t]+(?:failed|failing)\b/g),
111
+ };
112
+ }
113
+
114
+ async function cli() {
115
+ const die = makeDie(NAME);
116
+ const { arg, sweep, stray } = defineFlags(die, { flags: { member: "value", testCmd: "value", reports: "value" } });
117
+ sweep();
118
+ stray();
119
+ const member = arg("member");
120
+ const testCmd = arg("testCmd");
121
+ let reports = arg("reports");
122
+ if (member === null || testCmd === null) die(USAGE);
123
+ const parsed = parseMember(member);
124
+ if (parsed === null || (parsed.family !== "impl" && parsed.family !== "fix-pr")) {
125
+ die(`--member '${member}' is not an impl-<N>[-x] or fix-pr-<M>[-x] name — ${USAGE}`);
126
+ }
127
+ if (testCmd.trim() === "") die(`--testCmd is empty — ${USAGE}`);
128
+ if (reports === null) {
129
+ try {
130
+ reports = reportsDir(defaultScratch());
131
+ } catch (e) {
132
+ if (!(e instanceof FleetDirUnresolvable)) throw e;
133
+ die(`no --reports, and the scratch root could not be resolved — ${e.message}`);
134
+ }
135
+ }
136
+
137
+ const env = gitEnv();
138
+ // A reader that did not answer: the refusal names it, its exit status and
139
+ // its own reason.
140
+ const noAnswer = (what, r) => {
141
+ const why = (r.error?.message ?? r.stderr ?? "").trim();
142
+ die(`${what} did not answer${r.status === null ? "" : ` (exit ${r.status})`}${why ? `: ${why}` : ""}; nothing run, nothing written`);
143
+ };
144
+ const gitOut = (what, args) => {
145
+ const r = spawnSync("git", args, { encoding: "utf8", env, timeout: GIT_TIMEOUT_MS });
146
+ if (r.status !== 0) noAnswer(what, r);
147
+ return r.stdout.trim();
148
+ };
149
+ const top = gitOut("git rev-parse --show-toplevel", ["rev-parse", "--show-toplevel"]);
150
+
151
+ // 1: the Recipe. Exit 1 is derive-testcmd.sh's refusal about the cache; any
152
+ // other failure is a reader that could not run.
153
+ const field = (name) => {
154
+ const r = spawnSync("sh", [DERIVE, top, name], { encoding: "utf8", env, timeout: GIT_TIMEOUT_MS });
155
+ if (r.status === 0) return r.stdout.replace(/\n$/, "");
156
+ if (r.status === 1) {
157
+ die(`this worktree has no usable Recipe, so there is no Check step to run; settle reads the Recipe from the ledger's repository and gates the report wherever that read finds one; nothing run, nothing written — ${(r.stderr ?? "").trim()}`);
158
+ }
159
+ noAnswer(`derive-testcmd.sh ${name}`, r);
160
+ };
161
+ const checkCmd = field("check");
162
+ field("test");
163
+
164
+ // 2: the staged tree, and a working tree that holds exactly it.
165
+ const tree = gitOut("git write-tree", ["write-tree"]);
166
+ const strays = [
167
+ ...gitOut("git diff --name-only", ["-C", top, "diff", "--name-only"]).split("\n"),
168
+ ...gitOut("git ls-files --others --exclude-standard", ["-C", top, "ls-files", "--others", "--exclude-standard", "--directory"]).split("\n"),
169
+ ].filter((p) => p !== "");
170
+ if (strays.length > 0) {
171
+ const shown = strays.slice(0, 10).join(", ") + (strays.length > 10 ? `, and ${strays.length - 10} more` : "");
172
+ die(`the working tree is not the staged tree — unstaged or untracked: ${shown}. The commands run in the working tree and the record names the staged tree, so stage, remove or ignore these first; nothing run, nothing written`);
173
+ }
174
+ try {
175
+ mkdirSync(reports, { recursive: true });
176
+ } catch (e) {
177
+ die(`could not create ${reports}: ${e.code ?? e.message}; nothing run, nothing written`);
178
+ }
179
+
180
+ // 3 and 4: the commands. A reader that closes this script's stdout or
181
+ // stderr early stops only the passthrough: the commands still run to their
182
+ // end and the record is still written. Any other stream error stays fatal.
183
+ for (const stream of [process.stdout, process.stderr]) {
184
+ stream.on("error", (e) => {
185
+ if (e.code !== "EPIPE") throw e;
186
+ });
187
+ }
188
+ const check = checkCmd.trim() === "true"
189
+ ? { command: checkCmd, ran: false, exitCode: 0, pass: null, fail: null }
190
+ : await runCommand(checkCmd, top, env);
191
+ const test = check.exitCode === 0
192
+ ? await runCommand(testCmd, top, env)
193
+ : { command: testCmd, ran: false, exitCode: null, pass: null, fail: null };
194
+
195
+ // process.exitCode, never process.exit(): the command's own output is still
196
+ // draining through async pipe writes, and exit discards what is queued.
197
+ const say = (line) => writeAll(2, `\n${NAME}: ${line}\n`);
198
+ if (test.exitCode === NO_SLOT) {
199
+ say(`the Test entrypoint exited ${NO_SLOT}: no suite slot was free and nothing was launched — run the same command again; no record written`);
200
+ process.exitCode = NO_SLOT;
201
+ return;
202
+ }
203
+
204
+ const path = checkRecordPathIn(reports, member);
205
+ const tmp = `${path}.${process.pid}.tmp`;
206
+ try {
207
+ writeFileSync(tmp, `${JSON.stringify({ member, tree, check, test })}\n`);
208
+ renameSync(tmp, path);
209
+ } catch (e) {
210
+ rmSync(tmp, { force: true });
211
+ die(`could not write ${path}: ${e.code ?? e.message}`);
212
+ }
213
+
214
+ if (check.exitCode !== 0) {
215
+ say(`the Check step exited ${check.exitCode}; red record written to ${path}, so settle refuses it — fix the cause and run again`);
216
+ process.exitCode = 1;
217
+ } else if (test.exitCode !== 0) {
218
+ say(`the Test entrypoint exited ${test.exitCode}; red record written to ${path}, so settle refuses it — fix the cause and run again`);
219
+ process.exitCode = 1;
220
+ } else {
221
+ say(`green on tree ${tree}; record written to ${path}`);
222
+ }
223
+ }
224
+
225
+ if (isCLI(import.meta.url)) await cli();
226
+
227
+ // One command, `sh -c` from `cwd`, its output passed through and its tail kept
228
+ // for the counts. A signal that stops it is its number plus 128.
229
+ function runCommand(command, cwd, env) {
230
+ return new Promise((resolve) => {
231
+ let tail = "";
232
+ const keep = (chunk) => {
233
+ tail = (tail + chunk).slice(-TAIL_BYTES);
234
+ };
235
+ const child = spawn("sh", ["-c", command], { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
236
+ const handlers = ["SIGTERM", "SIGINT", "SIGHUP"].map((sig) => [sig, () => child.kill(sig)]);
237
+ for (const [sig, h] of handlers) process.on(sig, h);
238
+ child.stdout.setEncoding("utf8");
239
+ child.stderr.setEncoding("utf8");
240
+ child.stdout.on("data", (d) => {
241
+ keep(d);
242
+ process.stdout.write(d);
243
+ });
244
+ child.stderr.on("data", (d) => {
245
+ keep(d);
246
+ process.stderr.write(d);
247
+ });
248
+ const done = (exitCode) => {
249
+ for (const [sig, h] of handlers) process.off(sig, h);
250
+ const { pass, fail } = parseCounts(tail);
251
+ resolve({ command, ran: true, exitCode, pass, fail });
252
+ };
253
+ // A spawn that never started (a missing `sh`) is the command not passing.
254
+ child.on("error", (e) => {
255
+ process.stderr.write(`${NAME}: could not start sh: ${e.message}\n`);
256
+ done(127);
257
+ });
258
+ child.on("close", (code, signal) => done(code ?? 128 + (osConstants.signals[signal] ?? 0)));
259
+ });
260
+ }
@@ -9,6 +9,10 @@
9
9
  // SCHEMAS. The review runner and every member outside those four families
10
10
  // have no schema, so no file to look up.
11
11
  //
12
+ // The Check-step record beside a report, `<reports>/<member>.check.json`, is
13
+ // looked up here the same way: recipe-check.mjs writes it, and this module
14
+ // only names where it lives and reads it back.
15
+ //
12
16
  // This module only looks files up: it writes none, and creates no scratch
13
17
  // root.
14
18
 
@@ -32,6 +36,66 @@ export const reportsDir = (scratch) => join(scratch, "reports");
32
36
 
33
37
  export const reportPath = (scratch, member) => join(reportsDir(scratch), `${member}.json`);
34
38
 
39
+ /** Where `member`'s Check-step record sits in reports directory `dir`: the
40
+ * sibling of its report, `<dir>/<member>.check.json`. recipe-check.mjs writes
41
+ * it, `ledger.mjs settle --from-report` reads it. */
42
+ export const checkRecordPathIn = (dir, member) => join(dir, `${member}.check.json`);
43
+
44
+ const HEX_TREE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
45
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
46
+
47
+ // One command's half of a record: the command string, whether it ran (a Check
48
+ // step of `true` is recorded as green without running), the exit status it
49
+ // ended with — null when it never ran because an earlier one failed — and
50
+ // the pass/fail counts its output printed, null where the runner printed none.
51
+ function commandViolation(path, c) {
52
+ if (!isObject(c)) return `${path}: not an object`;
53
+ if (typeof c.command !== "string" || c.command === "") return `${path}.command: not a non-empty string`;
54
+ if (typeof c.ran !== "boolean") return `${path}.ran: not a boolean`;
55
+ if (c.exitCode !== null && !Number.isInteger(c.exitCode)) return `${path}.exitCode: not an integer or null`;
56
+ for (const k of ["pass", "fail"]) {
57
+ if (c[k] !== null && !(Number.isInteger(c[k]) && c[k] >= 0)) return `${path}.${k}: not a non-negative integer or null`;
58
+ }
59
+ return null;
60
+ }
61
+
62
+ // A file's text as `{text}`, or the answer both readers below give for a file
63
+ // that is not there (`absent`) or cannot be read (`invalid`).
64
+ function readAt(path) {
65
+ try {
66
+ return { text: readFileSync(path, "utf8") };
67
+ } catch (e) {
68
+ if (e.code === "ENOENT" || e.code === "ENOTDIR") return { state: "absent", path };
69
+ return { state: "invalid", path, violation: `$: unreadable (${e.code ?? e.message})` };
70
+ }
71
+ }
72
+
73
+ /** The Check-step record of `member` in reports directory `dir`, the way
74
+ * readReportIn() answers for a report: `{state: "ok", path, record}`,
75
+ * `{state: "invalid", path, violation}` for a file that exists and is
76
+ * unreadable, not JSON, or the wrong shape, or `{state: "absent", path}`.
77
+ * The record is `{member, tree, check, test}`, `tree` the `git write-tree`
78
+ * of the index the two commands ran against and `check`/`test` each
79
+ * `{command, ran, exitCode, pass, fail}`. */
80
+ export function readCheckRecordIn(dir, member) {
81
+ const path = checkRecordPathIn(dir, member);
82
+ const read = readAt(path);
83
+ if (read.text === undefined) return read;
84
+ let record;
85
+ try {
86
+ record = JSON.parse(read.text);
87
+ } catch (e) {
88
+ return { state: "invalid", path, violation: `$: not JSON (${e.message})` };
89
+ }
90
+ if (!isObject(record)) return { state: "invalid", path, violation: "$: not an object" };
91
+ if (record.member !== member) return { state: "invalid", path, violation: `$.member: ${JSON.stringify(record.member)} is not ${member}` };
92
+ if (typeof record.tree !== "string" || !HEX_TREE.test(record.tree)) {
93
+ return { state: "invalid", path, violation: "$.tree: not a full lowercase hex tree id" };
94
+ }
95
+ const violation = commandViolation("$.check", record.check) ?? commandViolation("$.test", record.test);
96
+ return violation === null ? { state: "ok", path, record } : { state: "invalid", path, violation };
97
+ }
98
+
35
99
  /** The report file of `member` under `scratch`, read and checked against its
36
100
  * family's schema: `{state: "ok", path, report}`, `{state: "invalid", path,
37
101
  * violation}` for a file that exists and is unreadable, not JSON, or the
@@ -46,14 +110,9 @@ export function readReportIn(dir, member) {
46
110
  const family = reportFamily(member);
47
111
  if (family === null) throw new Error(`no report schema for '${member}'`);
48
112
  const path = join(dir, `${member}.json`);
49
- let text;
50
- try {
51
- text = readFileSync(path, "utf8");
52
- } catch (e) {
53
- if (e.code === "ENOENT" || e.code === "ENOTDIR") return { state: "absent", path };
54
- return { state: "invalid", path, violation: `$: unreadable (${e.code ?? e.message})` };
55
- }
56
- const parsed = parseReport(text);
113
+ const read = readAt(path);
114
+ if (read.text === undefined) return read;
115
+ const parsed = parseReport(read.text);
57
116
  const violation = parsed.violation ?? validate(SCHEMAS[family], parsed.value);
58
117
  return violation === null ? { state: "ok", path, report: parsed.value } : { state: "invalid", path, violation };
59
118
  }