@feigi/fleet-ctl 3.78.0 → 3.79.1
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.
- package/agents/fleet-recipe-deriver.agent.md +49 -26
- package/commands/review-and-fix.md +1 -1
- package/package.json +1 -1
- package/scripts/derive-testcmd.sh +26 -14
- package/scripts/recipe-prove.mjs +93 -26
- package/scripts/review-core.mjs +8 -8
- package/skills/next-ticket/SKILL.md +9 -2
- package/skills/run-team/SKILL.md +3 -3
- package/skills/run-team/briefs/fix-applier.md +12 -4
- package/skills/run-team/references/phase-0.md +9 -4
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: fleet-recipe-deriver
|
|
3
|
-
description: Derives a repository's Recipe — its Install step and Test entrypoint — by reading the repository, proves
|
|
3
|
+
description: Derives a repository's Recipe — its Install step, Check step and Test entrypoint — by reading the repository, proves all three with recipe-prove.mjs, and reports. Dispatched by the run-team controller in phase 0, or by a standalone next-ticket or review-and-fix session, when the repository has no usable Recipe cache. Never invoked directly.
|
|
4
4
|
model: "@task:medium"
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
You derive one repository's **Recipe**: the **Install step** (the shell command
|
|
8
|
-
that materialises its dependencies in a fresh checkout)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
that materialises its dependencies in a fresh checkout), the **Check step**
|
|
9
|
+
(the shell command that runs the repository's merge-gating CI checks that are
|
|
10
|
+
not tests) and the **Test entrypoint** (the shell command that runs its suite).
|
|
11
|
+
The fleet keeps no table of technologies — no lockfile map, no test-file
|
|
12
|
+
pattern, no list of build tools — so you are the only part of it that reads a
|
|
13
|
+
repository and decides
|
|
12
14
|
how it is built and tested. You decide by reading; a script proves what you
|
|
13
15
|
decided; nothing is cached on a guess.
|
|
14
16
|
|
|
@@ -35,8 +37,8 @@ ls-tree -r --name-only origin/main`):
|
|
|
35
37
|
- scripts the repository tracks for running tests (a `Makefile` target, a
|
|
36
38
|
`scripts/test`, a tracked `agent-test`).
|
|
37
39
|
|
|
38
|
-
Propose one Install step and one Test entrypoint,
|
|
39
|
-
from the repository root by `sh -c`:
|
|
40
|
+
Propose one Install step, one Check step and one Test entrypoint, all shell
|
|
41
|
+
commands run from the repository root by `sh -c`:
|
|
40
42
|
|
|
41
43
|
- **Install step** — the form that installs exactly what the repository pins
|
|
42
44
|
and rewrites nothing: the frozen or locked mode where the tool has one.
|
|
@@ -56,13 +58,22 @@ from the repository root by `sh -c`:
|
|
|
56
58
|
resolves under its own directory option, e.g.
|
|
57
59
|
`git -C web ls-files -- '*.test.ts'`: the reader accepts a quoted pattern
|
|
58
60
|
unchecked.
|
|
59
|
-
- **
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
61
|
+
- **Check step** — every merge-gating CI step that is not a test step and that
|
|
62
|
+
the Test entrypoint does not run: the format, lint and type checks the
|
|
63
|
+
repository's CI gates on, whatever they are. One invocation, in the same form
|
|
64
|
+
as the Test entrypoint: a single command, run from the repository root, with
|
|
65
|
+
the same shape rules. `true` when the repository's CI has no such step. It
|
|
66
|
+
reads the tree and leaves it exactly as checked out. A step that is red on
|
|
67
|
+
`origin/main` is not part of it: leave it out and list it as uncovered.
|
|
68
|
+
- **Uncovered steps** — every merge-gating CI step that neither the Test
|
|
69
|
+
entrypoint nor the Check step runs, passed as `--uncovered`: one JSON array
|
|
70
|
+
of `{"step": "<the CI step>", "reason": "<why it is left out>"}`, `'[]'` when
|
|
71
|
+
it leaves out nothing. When no single invocation covers every qualifying
|
|
72
|
+
step, take the widest one and list every qualifying step it leaves out; that
|
|
73
|
+
is never a reason to report `RECIPE NOT PROVEN`. A test step that cannot run
|
|
74
|
+
here — it needs a service this host lacks, secrets, or another OS — is listed
|
|
75
|
+
too, with that reason. A non-test step that is red on `origin/main` is listed
|
|
76
|
+
with the reason `red on origin/main`.
|
|
66
77
|
|
|
67
78
|
## 2. Prove it — this procedure exactly
|
|
68
79
|
|
|
@@ -71,9 +82,9 @@ script creates and removes; `<repo>`'s own tree is never touched.
|
|
|
71
82
|
|
|
72
83
|
**First run, to see the output** — no proof flags:
|
|
73
84
|
|
|
74
|
-
~/.fleet/bin/fleet-run recipe-prove.mjs <repo> --install '<install>' --test '<test>' --uncovered '<uncovered>'
|
|
85
|
+
~/.fleet/bin/fleet-run recipe-prove.mjs <repo> --install '<install>' --check '<check>' --test '<test>' --uncovered '<uncovered>'
|
|
75
86
|
|
|
76
|
-
It exits 1 with `NOT PROVEN — no proof given` once
|
|
87
|
+
It exits 1 with `NOT PROVEN — no proof given` once every command ran, and
|
|
77
88
|
names the Test entrypoint's output file (`test output: <path>`). Any other
|
|
78
89
|
`NOT PROVEN` reason is about your commands — read it, and the log it names,
|
|
79
90
|
and revise.
|
|
@@ -84,7 +95,7 @@ and revise.
|
|
|
84
95
|
text off that summary line, as printed, into `--count-line`, and the number
|
|
85
96
|
of tests that ran into `--test-count`:
|
|
86
97
|
|
|
87
|
-
~/.fleet/bin/fleet-run recipe-prove.mjs <repo> --install '<install>' --test '<test>' --uncovered '<uncovered>' \
|
|
98
|
+
~/.fleet/bin/fleet-run recipe-prove.mjs <repo> --install '<install>' --check '<check>' --test '<test>' --uncovered '<uncovered>' \
|
|
88
99
|
--count-line '<literal text of the count line>' --test-count <n>
|
|
89
100
|
|
|
90
101
|
The number is the tests that ran — never the skipped, the failed, or a
|
|
@@ -102,18 +113,28 @@ and revise.
|
|
|
102
113
|
must change a tracked file; the script runs it in the throwaway worktree,
|
|
103
114
|
requires the suite to go red, and discards the worktree after:
|
|
104
115
|
|
|
105
|
-
~/.fleet/bin/fleet-run recipe-prove.mjs <repo> --install '<install>' --test '<test>' --uncovered '<uncovered>' \
|
|
116
|
+
~/.fleet/bin/fleet-run recipe-prove.mjs <repo> --install '<install>' --check '<check>' --test '<test>' --uncovered '<uncovered>' \
|
|
106
117
|
--mutate '<shell command that breaks one test>' --mutation '<one line: what it breaks>'
|
|
107
118
|
|
|
119
|
+
**The Check step's own proof** — when `--check` is not `true`, the Check step
|
|
120
|
+
must run green on `origin/main`, and one mutation must turn it red. Give
|
|
121
|
+
`--check-mutate` and `--check-mutation` with it, and give neither with
|
|
122
|
+
`--check true`. Both proofs ride in one run: the Test entrypoint's proof flags
|
|
123
|
+
from above, and these:
|
|
124
|
+
|
|
125
|
+
~/.fleet/bin/fleet-run recipe-prove.mjs <repo> --install '<install>' --check '<check>' --test '<test>' --uncovered '<uncovered>' \
|
|
126
|
+
--check-mutate '<shell command that breaks one file the Check step reads>' --check-mutation '<one line: what it breaks>' \
|
|
127
|
+
<the Test entrypoint's proof flags>
|
|
128
|
+
|
|
108
129
|
Exit 0 is the proof holding: the script wrote the Recipe cache and printed
|
|
109
130
|
`PROVEN` and the cache's contents. Exit 1 is `NOT PROVEN`, with the reason;
|
|
110
131
|
no cache was written. Exit 2 means no verdict on the proof: it could not be
|
|
111
132
|
attempted (no `origin/main`, not a repository, a malformed `--uncovered`) or
|
|
112
133
|
the cache could not be written — report it, do not work around it.
|
|
113
134
|
|
|
114
|
-
The script bounds each command it runs itself — the Install step,
|
|
115
|
-
runs and the
|
|
116
|
-
`timed out`. **Never wrap the `recipe-prove.mjs` call in a timeout of your
|
|
135
|
+
The script bounds each command it runs itself — the Install step, the Check
|
|
136
|
+
step, both Test runs and the mutations — and refuses one still running at that
|
|
137
|
+
bound as `timed out`. **Never wrap the `recipe-prove.mjs` call in a timeout of your
|
|
117
138
|
own**: an outside kill leaves no refusal to report. A deadline on the tool
|
|
118
139
|
call that runs it does the same when it is shorter than the script's bound,
|
|
119
140
|
so give that call `timeout: 0` and wait for the script.
|
|
@@ -131,10 +152,12 @@ Your last message is exactly one of:
|
|
|
131
152
|
- `RECIPE PROVEN` followed by the JSON line `recipe-prove.mjs` printed.
|
|
132
153
|
- `RECIPE NOT PROVEN` followed by the last run's `NOT PROVEN` reason
|
|
133
154
|
verbatim, the commands you tried, and one line naming the cause:
|
|
134
|
-
**vacuous** (the suite ran no tests, or none a mutation could reach
|
|
135
|
-
**did not run** (a command not
|
|
136
|
-
machine lacks), **dirty install**
|
|
137
|
-
|
|
138
|
-
|
|
155
|
+
**vacuous** (the suite ran no tests, or none a mutation could reach, or the
|
|
156
|
+
Check step ran nothing its mutation changed), **did not run** (a command not
|
|
157
|
+
found or not executable — a toolchain this machine lacks), **dirty install**
|
|
158
|
+
(the Install step or the Check step changes the tree), **red on origin/main**
|
|
159
|
+
(the Check step is red there, so it cannot be the Check step),
|
|
160
|
+
**timed out** (a command the proof runs — the Install step, the Check step,
|
|
161
|
+
the Test entrypoint or a mutation — hung until the script's own bound stopped it),
|
|
139
162
|
or **unreadable** (you could not tell from the repository how it is built or
|
|
140
163
|
tested).
|
|
@@ -12,7 +12,7 @@ argument-hint: [pr-number]
|
|
|
12
12
|
See **Specialists** — those constraints are not optional.
|
|
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
|
-
3. **Run `testCmd` before you commit**, from the worktree, copied verbatim — not a runner you picked. `testCmd` is the command your dispatcher handed you; **standalone, with no dispatcher to hand you
|
|
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`.** 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 — 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
|
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
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
18
|
**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.
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/bin/sh
|
|
2
2
|
# Read one field of a repository's proven Recipe out of its Recipe cache:
|
|
3
|
-
# `install` (the Install step), `
|
|
4
|
-
#
|
|
3
|
+
# `install` (the Install step), `check` (the Check step: the merge-gating CI
|
|
4
|
+
# checks that are not tests), `test` (the Test entrypoint) or `uncovered` (the
|
|
5
|
+
# merge-gating CI steps neither the Test entrypoint nor the Check step runs).
|
|
5
6
|
#
|
|
6
7
|
# The fleet keeps no table of technologies. A Recipe is DERIVED by an agent
|
|
7
8
|
# reasoning over the repository and PROVEN before it is written — the Install
|
|
@@ -26,19 +27,24 @@
|
|
|
26
27
|
# private copy each. A single JSON object:
|
|
27
28
|
#
|
|
28
29
|
# install string the Install step, a shell command run from the root
|
|
30
|
+
# check string the Check step, a shell command run from the root, or
|
|
31
|
+
# | true the JSON boolean `true` (never the string) when the
|
|
32
|
+
# repository has no non-test merge-gating step
|
|
29
33
|
# test string the Test entrypoint, a shell command run from the root
|
|
30
34
|
# derivedAt string the full commit id (40 or 64 hex) it was proven at
|
|
31
35
|
# installClean true the Install step left `git status --porcelain` empty
|
|
32
36
|
# testCount integer >0, the test count the proving run reported, and/or
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
37
|
+
# checkMutation string when `check` is not `true`: the deliberate change that
|
|
38
|
+
# turned the Check step red (one line)
|
|
39
|
+
# mutation string the deliberate failing mutation that turned the Test entrypoint red
|
|
40
|
+
# uncovered array every merge-gating CI step neither the Test entrypoint nor the
|
|
41
|
+
# Check step runs, `[]` when they run them all: each entry
|
|
42
|
+
# `{ "step": <string>, "reason": <string> }`, both non-empty
|
|
38
43
|
#
|
|
39
44
|
# At least one of testCount/mutation is the proof; a cache carrying neither,
|
|
40
|
-
# `installClean` anything but `true`,
|
|
41
|
-
# UNPROVEN Recipe and refused like an absent one.
|
|
45
|
+
# `installClean` anything but `true`, no well-formed `check`, or no well-formed
|
|
46
|
+
# `uncovered` is an UNPROVEN Recipe and refused like an absent one. A `check`
|
|
47
|
+
# other than `true` needs its `checkMutation`. Written only by
|
|
42
48
|
# recipe-prove.mjs, the derivation step's proof, and only once that proof
|
|
43
49
|
# holds; this script never writes it.
|
|
44
50
|
#
|
|
@@ -126,7 +132,7 @@ derive="run the Recipe derivation step (run-team phase 0, before the first claim
|
|
|
126
132
|
# the checkout still lists the old layout until someone updates it. Without
|
|
127
133
|
# `--at` the probe reads <repo>'s own index, as for a caller that runs the
|
|
128
134
|
# command in <repo> itself. Three arguments is neither shape: a refusal.
|
|
129
|
-
usage="usage: derive-testcmd.sh <repo> <install|test|uncovered> [--at <rev>]"
|
|
135
|
+
usage="usage: derive-testcmd.sh <repo> <install|check|test|uncovered> [--at <rev>]"
|
|
130
136
|
rev=
|
|
131
137
|
case $# in
|
|
132
138
|
2) ;;
|
|
@@ -149,8 +155,8 @@ gitout=$(git -C "$repo" rev-parse --git-dir 2>&1) || {
|
|
|
149
155
|
}
|
|
150
156
|
|
|
151
157
|
case $field in
|
|
152
|
-
install|test|uncovered) ;;
|
|
153
|
-
*) die "unknown Recipe field '$field' — expected install, test or uncovered" ;;
|
|
158
|
+
install|check|test|uncovered) ;;
|
|
159
|
+
*) die "unknown Recipe field '$field' — expected install, check, test or uncovered" ;;
|
|
154
160
|
esac
|
|
155
161
|
|
|
156
162
|
# A <rev> is only ever consulted by the probe, which only the Test entrypoint
|
|
@@ -197,7 +203,7 @@ cache="$ws/.fleet/recipe.json"
|
|
|
197
203
|
# Absent is its own refusal, ahead of the interpreter probe: it is the one
|
|
198
204
|
# state every fresh repository starts in, and its message must name the step
|
|
199
205
|
# that ends it rather than anything about a parser.
|
|
200
|
-
[ -e "$cache" ] || die "no Recipe cache at $cache — refusing to infer an Install step or a Test entrypoint; $derive"
|
|
206
|
+
[ -e "$cache" ] || die "no Recipe cache at $cache — refusing to infer an Install step, a Check step or a Test entrypoint; $derive"
|
|
201
207
|
|
|
202
208
|
# An INVOCATION, not a name lookup: a version-manager shim satisfies
|
|
203
209
|
# `command -v node` and then fails, and its stderr would arrive below under the
|
|
@@ -250,13 +256,19 @@ try { r = JSON.parse(fs.readFileSync(file, "utf8")); } catch (e) { bad(`it does
|
|
|
250
256
|
if (r === null || typeof r !== "object" || Array.isArray(r)) bad("it is not a JSON object");
|
|
251
257
|
for (const k of ["install", "test"])
|
|
252
258
|
if (typeof r[k] !== "string" || !r[k].trim() || r[k].includes("\0")) bad(`\`${k}\` is not a non-empty command string`);
|
|
259
|
+
if (r.check !== true) {
|
|
260
|
+
if (typeof r.check !== "string" || !r.check.trim() || r.check.includes("\0"))
|
|
261
|
+
bad("`check` is neither `true` nor a non-empty command string — re-derive the Recipe so it names the Check step");
|
|
262
|
+
if (typeof r.checkMutation !== "string" || !r.checkMutation.trim())
|
|
263
|
+
bad("`check` is not `true` but `checkMutation` is missing — the change that turned the Check step red");
|
|
264
|
+
}
|
|
253
265
|
if (typeof r.derivedAt !== "string" || !/^[0-9a-f]{40}([0-9a-f]{24})?$/.test(r.derivedAt))
|
|
254
266
|
bad("`derivedAt` is not a full commit id");
|
|
255
267
|
if (r.installClean !== true) bad("`installClean` is not true — the Install step was never proven to leave the tree clean");
|
|
256
268
|
const counted = Number.isInteger(r.testCount) && r.testCount > 0;
|
|
257
269
|
const mutated = typeof r.mutation === "string" && r.mutation.trim() !== "";
|
|
258
270
|
if (!counted && !mutated) bad("it carries no proof of real tests — neither a positive `testCount` nor a `mutation`");
|
|
259
|
-
if (!Array.isArray(r.uncovered)) bad("`uncovered` is not a list of { step, reason } entries — the CI
|
|
271
|
+
if (!Array.isArray(r.uncovered)) bad("`uncovered` is not a list of { step, reason } entries — the merge-gating CI steps neither the Test entrypoint nor the Check step runs, `[]` for none");
|
|
260
272
|
const filled = (s) => typeof s === "string" && s.trim() !== "";
|
|
261
273
|
r.uncovered.forEach((u, i) => {
|
|
262
274
|
if (u === null || typeof u !== "object" || Array.isArray(u) || !filled(u.step) || !filled(u.reason))
|
package/scripts/recipe-prove.mjs
CHANGED
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Prove a repository's Recipe — its Install step and Test entrypoint —
|
|
3
|
-
// write the Recipe cache only if the proof holds. This is the Recipe
|
|
2
|
+
// Prove a repository's Recipe — its Install step, Check step and Test entrypoint —
|
|
3
|
+
// and write the Recipe cache only if the proof holds. This is the Recipe
|
|
4
4
|
// derivation step's proof, and the ONE writer of the cache derive-testcmd.sh
|
|
5
|
-
// reads: the deriving agent chooses
|
|
5
|
+
// reads: the deriving agent chooses all three commands by reading the repository,
|
|
6
6
|
// and this script is what turns that choice into a cache, or refuses to.
|
|
7
7
|
//
|
|
8
|
-
// recipe-prove.mjs <repo> --install <cmd> --test <cmd> --uncovered <json>
|
|
8
|
+
// recipe-prove.mjs <repo> --install <cmd> --check <cmd|true> --test <cmd> --uncovered <json>
|
|
9
9
|
// [--count-line <literal> --test-count <n>]
|
|
10
10
|
// [--mutate <cmd> --mutation <description>]
|
|
11
|
+
// [--check-mutate <cmd> --check-mutation <description>]
|
|
11
12
|
//
|
|
12
|
-
// `--uncovered` is one JSON array, `[]` when the Test entrypoint
|
|
13
|
-
//
|
|
14
|
-
// `{ "step": <string>, "reason": <string> }` per step
|
|
15
|
-
// non-empty. It is not proven — nothing here can run CI — but
|
|
16
|
-
// cache never stays silent about a step
|
|
13
|
+
// `--uncovered` is one JSON array, `[]` when the Test entrypoint and the Check
|
|
14
|
+
// step together run every merge-gating CI step that is not a test, and
|
|
15
|
+
// otherwise one `{ "step": <string>, "reason": <string> }` per such step that
|
|
16
|
+
// neither runs, both non-empty. It is not proven — nothing here can run CI — but
|
|
17
|
+
// required, so a cache never stays silent about a CI step left unrun; it is
|
|
17
18
|
// written as given, as the cache's `uncovered`.
|
|
18
19
|
//
|
|
19
20
|
// Every check runs in a throwaway worktree of `origin/main`, detached and
|
|
@@ -25,9 +26,18 @@
|
|
|
25
26
|
// that rewrites a tracked file, or creates one the tree neither tracks
|
|
26
27
|
// nor ignores, corrupts every worktree it later runs in. That is the
|
|
27
28
|
// same check, in the same form, a claim re-asserts on every Install.
|
|
28
|
-
// 2. The
|
|
29
|
+
// 2. The Check step, unless `--check true`, runs and must RUN, as the Test
|
|
30
|
+
// entrypoint must, and must be green on origin/main. It must leave the tree
|
|
31
|
+
// as it found it. A red Check step is not proven: a merge-gating non-test
|
|
32
|
+
// step that is red on origin/main is left out of `check` and listed in
|
|
33
|
+
// `uncovered`. Its mutation proof: `--check-mutate` must change a tracked
|
|
34
|
+
// file, and the Check step must then go red (exit not 126/127, and not 0);
|
|
35
|
+
// the tree is then restored to HEAD — tracked files reset, untracked ones
|
|
36
|
+
// removed, ignored ones kept — so nothing the mutation or the red run left
|
|
37
|
+
// behind reaches the Test entrypoint. `--check true` needs none.
|
|
38
|
+
// 3. The Test entrypoint runs and must RUN — 126/127 is the shell's "cannot
|
|
29
39
|
// execute"/"not found". A red suite is not a failed proof by itself.
|
|
30
|
-
//
|
|
40
|
+
// 4. Real tests must be shown to have executed, by at least one of:
|
|
31
41
|
// - the count proof: `--count-line` is a literal the run's own output
|
|
32
42
|
// must contain (text off the runner's summary line, read off a
|
|
33
43
|
// previous run), carrying `--test-count`, a positive integer no larger
|
|
@@ -46,11 +56,11 @@
|
|
|
46
56
|
// With neither, nothing is proven: the refusal still names where the test
|
|
47
57
|
// run's output is, so the caller can read the count line off it.
|
|
48
58
|
//
|
|
49
|
-
// Each of those commands — the Install step, both
|
|
50
|
-
// runs under a time bound of its own,
|
|
51
|
-
// (whole seconds) can shorten and never
|
|
52
|
-
// the bound is killed, and the proof is
|
|
53
|
-
// the bound and the command's log.
|
|
59
|
+
// Each of those commands — the Install step, both Check runs and the check
|
|
60
|
+
// mutation, both Test runs, the mutation — runs under a time bound of its own,
|
|
61
|
+
// 20 minutes, that RECIPE_PROVE_TIMEOUT (whole seconds) can shorten and never
|
|
62
|
+
// lengthen. A command still running at the bound is killed, and the proof is
|
|
63
|
+
// NOT PROVEN for it, the refusal naming the bound and the command's log.
|
|
54
64
|
//
|
|
55
65
|
// On proof, the cache is written atomically to <workspace>/.fleet/recipe.json
|
|
56
66
|
// — <workspace> being the directory workspaceDirFromGitCommonDir() names for the repository,
|
|
@@ -92,7 +102,7 @@ import { isDigits } from "./arg.mjs";
|
|
|
92
102
|
|
|
93
103
|
const NAME = "recipe-prove";
|
|
94
104
|
const USAGE =
|
|
95
|
-
"usage: recipe-prove.mjs <repo> --install <cmd> --test <cmd> --uncovered <json> [--count-line <literal> --test-count <n>] [--mutate <cmd> --mutation <description>]";
|
|
105
|
+
"usage: recipe-prove.mjs <repo> --install <cmd> --check <cmd|true> --test <cmd> --uncovered <json> [--count-line <literal> --test-count <n>] [--mutate <cmd> --mutation <description>] [--check-mutate <cmd> --check-mutation <description>]";
|
|
96
106
|
const READER = join(dirname(fileURLToPath(import.meta.url)), "derive-testcmd.sh");
|
|
97
107
|
|
|
98
108
|
// Scrubbed once, for every child: an ambient GIT_DIR or GIT_WORK_TREE — set
|
|
@@ -140,7 +150,7 @@ const READER_COULD_NOT_RUN = 3;
|
|
|
140
150
|
function parseArgs(argv) {
|
|
141
151
|
const [repo, ...rest] = argv;
|
|
142
152
|
if (!repo || repo.startsWith("--")) throw cannot(USAGE);
|
|
143
|
-
const flags = new Set(["--install", "--test", "--uncovered", "--count-line", "--test-count", "--mutate", "--mutation"]);
|
|
153
|
+
const flags = new Set(["--install", "--check", "--test", "--uncovered", "--count-line", "--test-count", "--mutate", "--mutation", "--check-mutate", "--check-mutation"]);
|
|
144
154
|
const a = {};
|
|
145
155
|
for (let i = 0; i < rest.length; i += 2) {
|
|
146
156
|
const [flag, value] = [rest[i], rest[i + 1]];
|
|
@@ -153,7 +163,7 @@ function parseArgs(argv) {
|
|
|
153
163
|
for (const f of ["--install", "--test"]) {
|
|
154
164
|
if (!a[f] || !a[f].trim()) throw cannot(`${f} needs a non-empty command — ${USAGE}`);
|
|
155
165
|
}
|
|
156
|
-
if (!("--uncovered" in a)) throw cannot(`--uncovered is required: the CI
|
|
166
|
+
if (!("--uncovered" in a)) throw cannot(`--uncovered is required: the merge-gating CI steps neither the Test entrypoint nor the Check step runs, as one JSON array, \`[]\` for none — ${USAGE}`);
|
|
157
167
|
const uncovered = parseUncovered(a["--uncovered"]);
|
|
158
168
|
if (("--count-line" in a) !== ("--test-count" in a)) throw cannot("--count-line and --test-count go together");
|
|
159
169
|
if ("--test-count" in a && !isDigits(a["--test-count"])) {
|
|
@@ -165,6 +175,15 @@ function parseArgs(argv) {
|
|
|
165
175
|
throw cannot(`--test-count must be at most ${Number.MAX_SAFE_INTEGER}, got '${a["--test-count"]}'`);
|
|
166
176
|
}
|
|
167
177
|
if (("--mutate" in a) !== ("--mutation" in a)) throw cannot("--mutate and --mutation go together");
|
|
178
|
+
if (!("--check" in a)) throw cannot(`--check is required: the merge-gating CI checks that are not tests, as one command, or \`true\` when the CI has none — ${USAGE}`);
|
|
179
|
+
if (!a["--check"].trim()) throw cannot(`--check needs a non-empty command, or \`true\` — ${USAGE}`);
|
|
180
|
+
if (a["--check"] === "true") {
|
|
181
|
+
if (("--check-mutate" in a) || ("--check-mutation" in a)) throw cannot("--check-mutate and --check-mutation belong to a Check step other than `true`, which is not run here");
|
|
182
|
+
} else {
|
|
183
|
+
if (("--check-mutate" in a) !== ("--check-mutation" in a)) throw cannot("--check-mutate and --check-mutation go together");
|
|
184
|
+
if (!("--check-mutate" in a) || !a["--check-mutate"].trim()) throw cannot(`a Check step other than \`true\` needs --check-mutate, the command that turns it red — ${USAGE}`);
|
|
185
|
+
if (!a["--check-mutation"].trim()) throw cannot("--check-mutation needs a non-empty description");
|
|
186
|
+
}
|
|
168
187
|
return {
|
|
169
188
|
repo,
|
|
170
189
|
install: a["--install"],
|
|
@@ -173,6 +192,9 @@ function parseArgs(argv) {
|
|
|
173
192
|
testCount: a["--test-count"],
|
|
174
193
|
mutate: a["--mutate"],
|
|
175
194
|
mutation: a["--mutation"],
|
|
195
|
+
check: a["--check"],
|
|
196
|
+
checkMutate: a["--check-mutate"],
|
|
197
|
+
checkMutation: a["--check-mutation"],
|
|
176
198
|
uncovered,
|
|
177
199
|
};
|
|
178
200
|
}
|
|
@@ -268,24 +290,70 @@ const didNotRun = (rc) => rc === 126 || rc === 127;
|
|
|
268
290
|
// install creates dirties the tree as surely as a rewrite, and the untracked
|
|
269
291
|
// mode is config — under status.showUntrackedFiles=no it is invisible unless
|
|
270
292
|
// pinned.
|
|
271
|
-
function treeChanges(wt,
|
|
293
|
+
function treeChanges(wt, where) {
|
|
272
294
|
const s = git(["status", "--porcelain", "-uall"], wt);
|
|
273
|
-
if (!s.ok) throw notProven(`could not read the tree state in the throwaway worktree: ${s.err};
|
|
295
|
+
if (!s.ok) throw notProven(`could not read the tree state in the throwaway worktree: ${s.err}; ${where}`);
|
|
274
296
|
return s.out;
|
|
275
297
|
}
|
|
276
298
|
|
|
299
|
+
// Puts the throwaway worktree back to HEAD after a mutation, so the next run
|
|
300
|
+
// starts from the clean tree the proof requires: a reset puts every tracked
|
|
301
|
+
// file back, and only a clean removes the untracked files a mutation or the
|
|
302
|
+
// run it turned red created. No `-x`: ignored files — the Install step's
|
|
303
|
+
// output among them — stay, as they stay through every other step.
|
|
304
|
+
function restoreTree(wt, why, log) {
|
|
305
|
+
const r = git(["reset", "--hard", "-q", "HEAD"], wt);
|
|
306
|
+
if (!r.ok) throw notProven(`could not restore the tree ${why}: ${r.err}; output: ${log}`);
|
|
307
|
+
const c = git(["clean", "-fdq"], wt);
|
|
308
|
+
if (!c.ok) throw notProven(`could not remove the untracked files ${why}: ${c.err}; output: ${log}`);
|
|
309
|
+
}
|
|
310
|
+
|
|
277
311
|
function prove(o, wt, logs) {
|
|
312
|
+
const proof = {};
|
|
278
313
|
const installLog = join(logs, "install.log");
|
|
279
314
|
const irc = sh(o.install, wt, installLog);
|
|
280
315
|
if (didNotRun(irc)) {
|
|
281
316
|
throw notProven(`the Install step '${o.install}' did not run (exit ${irc}: not executable or not found); install output: ${installLog}`);
|
|
282
317
|
}
|
|
283
318
|
if (irc !== 0) throw notProven(`the Install step '${o.install}' failed (exit ${irc}); install output: ${installLog}`);
|
|
284
|
-
const dirty = treeChanges(wt, installLog);
|
|
319
|
+
const dirty = treeChanges(wt, `install output: ${installLog}`);
|
|
285
320
|
if (dirty) {
|
|
286
321
|
throw notProven(`the Install step changed the tree (first: ${dirty.split("\n")[0]}); it must leave every file as checked out; install output: ${installLog}`);
|
|
287
322
|
}
|
|
288
323
|
|
|
324
|
+
// The Check step: the merge-gating CI checks that are not tests. It must run
|
|
325
|
+
// and be green on origin/main, leave the tree as it found it, and turn red
|
|
326
|
+
// under one mutation of a tracked file. It runs before the Test entrypoint,
|
|
327
|
+
// and restoreTree puts the tree back to HEAD after its mutation, so nothing
|
|
328
|
+
// the mutation or the mutated run left in the tree reaches the Test run.
|
|
329
|
+
if (o.check !== "true") {
|
|
330
|
+
const checkLog = join(logs, "check.log");
|
|
331
|
+
const crc = sh(o.check, wt, checkLog);
|
|
332
|
+
if (didNotRun(crc)) {
|
|
333
|
+
throw notProven(`the Check step '${o.check}' did not run (exit ${crc}: not executable or not found); check output: ${checkLog}`);
|
|
334
|
+
}
|
|
335
|
+
if (crc !== 0) {
|
|
336
|
+
throw notProven(`the Check step '${o.check}' is red on origin/main (exit ${crc}) — a merge-gating step that is red there belongs in \`uncovered\` with reason \`red on origin/main\`, not in \`check\`; check output: ${checkLog}`);
|
|
337
|
+
}
|
|
338
|
+
const checkDirty = treeChanges(wt, `check output: ${checkLog}`);
|
|
339
|
+
if (checkDirty) {
|
|
340
|
+
throw notProven(`the Check step changed the tree (first: ${checkDirty.split("\n")[0]}); it must only read the tree; check output: ${checkLog}`);
|
|
341
|
+
}
|
|
342
|
+
restoreTree(wt, "before the check mutation", checkLog);
|
|
343
|
+
const checkMutateLog = join(logs, "check-mutate.log");
|
|
344
|
+
const mrc = sh(o.checkMutate, wt, checkMutateLog);
|
|
345
|
+
if (mrc !== 0) throw notProven(`the check mutation command '${o.checkMutate}' failed (exit ${mrc}); its output: ${checkMutateLog}`);
|
|
346
|
+
const changed = git(["diff", "HEAD", "--name-only"], wt);
|
|
347
|
+
if (!changed.ok) throw notProven(`could not read what the check mutation changed: ${changed.err}; its output: ${checkMutateLog}`);
|
|
348
|
+
if (!changed.out) throw notProven(`the check mutation (${o.checkMutation}) changed no tracked file — mutate a file the Check step reads; its output: ${checkMutateLog}`);
|
|
349
|
+
const checkRedLog = join(logs, "check-mutated.log");
|
|
350
|
+
const red = sh(o.check, wt, checkRedLog);
|
|
351
|
+
if (didNotRun(red)) throw notProven(`the Check step did not run on the mutated tree (exit ${red}); check output: ${checkRedLog}`);
|
|
352
|
+
if (red === 0) throw notProven(`vacuous: the check mutation (${o.checkMutation}) did not turn the Check step red — the Check step does not read the file it changed; check output: ${checkRedLog}`);
|
|
353
|
+
restoreTree(wt, "after the check mutation", checkRedLog);
|
|
354
|
+
proof.checkMutation = `${o.checkMutation} — turned the Check step red (exit ${red}); changed ${changed.out.split("\n").join(", ")}`;
|
|
355
|
+
}
|
|
356
|
+
|
|
289
357
|
const testLog = join(logs, "test.log");
|
|
290
358
|
const trc = sh(o.test, wt, testLog);
|
|
291
359
|
const where = `test output: ${testLog}`;
|
|
@@ -296,7 +364,6 @@ function prove(o, wt, logs) {
|
|
|
296
364
|
throw notProven(`no proof given — pass --count-line/--test-count or --mutate/--mutation (the run exited ${trc}); ${where}`);
|
|
297
365
|
}
|
|
298
366
|
|
|
299
|
-
const proof = {};
|
|
300
367
|
if (o.countLine !== undefined) {
|
|
301
368
|
const n = Number(o.testCount);
|
|
302
369
|
if (n === 0) throw notProven(`vacuous: a test count of 0 is a run that executed no tests; ${where}`);
|
|
@@ -374,9 +441,9 @@ function writeCache(cache, recipe, repo) {
|
|
|
374
441
|
rmSync(tmp, { force: true });
|
|
375
442
|
throw e;
|
|
376
443
|
}
|
|
377
|
-
for (const field of ["install", "test", "uncovered"]) {
|
|
444
|
+
for (const field of ["install", "check", "test", "uncovered"]) {
|
|
378
445
|
const r = spawnSync("sh", [READER, repo, field, ...(field === "test" ? ["--at", recipe.derivedAt] : [])], { env: ENV, encoding: "utf8" });
|
|
379
|
-
const expected = field === "uncovered" ? JSON.stringify(recipe.uncovered) : recipe[field];
|
|
446
|
+
const expected = field === "uncovered" ? JSON.stringify(recipe.uncovered) : String(recipe[field]);
|
|
380
447
|
const got = r.status === 0 ? r.stdout.replace(/\n$/, "") : null;
|
|
381
448
|
if (got === expected) continue;
|
|
382
449
|
if (prior === null) unlinkSync(cache);
|
|
@@ -477,7 +544,7 @@ function main(argv) {
|
|
|
477
544
|
removeWorktree(wt, o.repo);
|
|
478
545
|
}
|
|
479
546
|
|
|
480
|
-
recipe = { install: o.install, test: o.test, derivedAt: sha.out, installClean: true, ...proof, uncovered: o.uncovered };
|
|
547
|
+
recipe = { install: o.install, check: o.check === "true" ? true : o.check, test: o.test, derivedAt: sha.out, installClean: true, ...proof, uncovered: o.uncovered };
|
|
481
548
|
writeCache(cache, recipe, o.repo);
|
|
482
549
|
} catch (e) {
|
|
483
550
|
if (!(e instanceof Refusal && e.message.includes(logs))) removeLogs(logs);
|
package/scripts/review-core.mjs
CHANGED
|
@@ -122,8 +122,8 @@ export const SNAPSHOT_SCHEMA = {
|
|
|
122
122
|
prHead: { type: "string" },
|
|
123
123
|
testCmd: { type: "string" },
|
|
124
124
|
testCmdError: { type: "string" },
|
|
125
|
-
// The Recipe's `uncovered` as derive-testcmd.sh printed it: the
|
|
126
|
-
// steps the Test entrypoint
|
|
125
|
+
// The Recipe's `uncovered` as derive-testcmd.sh printed it: the merge-gating
|
|
126
|
+
// CI steps neither the Test entrypoint nor the Check step runs, one line of JSON, `[]` for none.
|
|
127
127
|
// `uncoveredError` carries the reader's refusal instead when it could not
|
|
128
128
|
// be read, so an unread list never reads as an empty one.
|
|
129
129
|
uncovered: { type: "string" },
|
|
@@ -515,8 +515,8 @@ export function environmentNote(snap) {
|
|
|
515
515
|
return note;
|
|
516
516
|
}
|
|
517
517
|
|
|
518
|
-
// The CI
|
|
519
|
-
// agent read them through the cache's one reader, ride on the end as one line
|
|
518
|
+
// The merge-gating CI steps that neither the Recipe's Test entrypoint nor its Check step runs,
|
|
519
|
+
// as the snapshot agent read them through the cache's one reader, ride on the end as one line
|
|
520
520
|
// naming each step and why: a green run here says nothing about them, and no
|
|
521
521
|
// specialist runs them. An empty list adds nothing. A list the agent could
|
|
522
522
|
// not read, or reported in another shape, is said so — never left to read as
|
|
@@ -533,12 +533,12 @@ function uncoveredNote(snap) {
|
|
|
533
533
|
}
|
|
534
534
|
if (Array.isArray(list) && list.every((u) => u && typeof u.step === "string" && typeof u.reason === "string")) {
|
|
535
535
|
if (!list.length) return "";
|
|
536
|
-
return `\nThe Recipe's Test entrypoint
|
|
536
|
+
return `\nThe Recipe's Test entrypoint and Check step do not run these merge-gating CI steps, and nothing in this review ran them: ${list.map((u) => `${oneLine(u.step)} (${oneLine(u.reason)})`).join("; ")}.`;
|
|
537
537
|
}
|
|
538
538
|
const why =
|
|
539
539
|
(snap && typeof snap.uncoveredError === "string" && snap.uncoveredError.trim() && oneLine(snap.uncoveredError)) ||
|
|
540
540
|
(snap && typeof snap.uncovered === "string" ? `it reported ${JSON.stringify(oneLine(snap.uncovered))}, not a list of steps` : "the snapshot agent reported nothing for it");
|
|
541
|
-
return `\nWhich merge-gating CI
|
|
541
|
+
return `\nWhich merge-gating CI steps neither the Recipe's Test entrypoint nor its Check step runs could not be read (${why}), so a passing run here is not known to cover them.`;
|
|
542
542
|
}
|
|
543
543
|
|
|
544
544
|
function repoNote(snap) {
|
|
@@ -1111,8 +1111,8 @@ guess a command yourself: a refusal about the cache names the step that
|
|
|
1111
1111
|
derives it; an exit-3 refusal names an environment fault, and the caller fixes
|
|
1112
1112
|
that instead of deriving.
|
|
1113
1113
|
|
|
1114
|
-
Then read, out of the same cache, the merge-gating CI
|
|
1115
|
-
entrypoint
|
|
1114
|
+
Then read, out of the same cache, the merge-gating CI steps that neither the
|
|
1115
|
+
Test entrypoint nor the Check step runs:
|
|
1116
1116
|
|
|
1117
1117
|
~/.fleet/bin/fleet-run derive-testcmd.sh ${worktree} uncovered
|
|
1118
1118
|
|
|
@@ -76,7 +76,7 @@ gh label create in-progress --color FBCA04 --force # first time only
|
|
|
76
76
|
gh issue edit <N> --add-label in-progress
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
-
Infer branch/worktree convention from `git worktree list` / `git branch -r` — commonly `feat|fix|refactor/<N>-slug` and `.worktrees/<N>-slug`. Branch off fresh `origin/main`, run the Install step, run the Test entrypoint as the baseline —
|
|
79
|
+
Infer branch/worktree convention from `git worktree list` / `git branch -r` — commonly `feat|fix|refactor/<N>-slug` and `.worktrees/<N>-slug`. Branch off fresh `origin/main`, run the Install step, run the Check step, run the Test entrypoint as the baseline — the repo's **Recipe**, read from its Recipe cache: `~/.fleet/bin/fleet-run derive-testcmd.sh . install`, `… . check` and `… . test --at origin/main` print them. **No usable cache (any of the three exits 1) → 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 `<scratch>/derive-recipe/` as its scratch directory, `<scratch>` the output of `~/.fleet/bin/fleet-run scratch-root.mjs`; it reads the repo, proves all three commands in a throwaway worktree and writes the cache only on proof. `RECIPE NOT PROVEN` → stop and give the maintainer its reason; never pick a command yourself. Exit 3 is no verdict on the cache — the reader could not run a tool it needs — so fix that environment fault and re-run instead of deriving.
|
|
80
80
|
|
|
81
81
|
Abandoned before PR opens → remove `in-progress`.
|
|
82
82
|
|
|
@@ -90,7 +90,8 @@ Come back here. The sizing path hands off; this step always follows.
|
|
|
90
90
|
|
|
91
91
|
```bash
|
|
92
92
|
git fetch origin && git rebase origin/main # rebase, never merge main in
|
|
93
|
-
<test command>
|
|
93
|
+
<test command> # the Recipe's Test entrypoint; re-run after rebasing
|
|
94
|
+
<check command> # the Recipe's Check step; re-run after rebasing
|
|
94
95
|
git push --force-with-lease -u origin HEAD
|
|
95
96
|
gh pr create --base main --body "…
|
|
96
97
|
|
|
@@ -98,6 +99,12 @@ Closes #N"
|
|
|
98
99
|
gh pr edit --add-label <patch|minor|major> # own command, own exit status
|
|
99
100
|
```
|
|
100
101
|
|
|
102
|
+
**A red Check step is yours to judge.** Read what it reports and fix every
|
|
103
|
+
finding in a file the PR changes, including lines you did not edit — CI checks
|
|
104
|
+
whole files. Never edit a file the PR does not otherwise change: a finding there
|
|
105
|
+
is a stale base, not your code, so rebase and re-run; still red → bail with that
|
|
106
|
+
cause. No fix command is recorded; the Check step only reports.
|
|
107
|
+
|
|
101
108
|
**In a detached worktree, push by refspec.** `run-team`'s runbook re-creates an
|
|
102
109
|
already-open PR's worktree with `git worktree add --detach <path> origin/<branch>`,
|
|
103
110
|
and a member sent into one is on `## HEAD (no branch)`, where the push above exits
|
package/skills/run-team/SKILL.md
CHANGED
|
@@ -538,7 +538,7 @@ and dispatch it with the
|
|
|
538
538
|
row's `tier=` — read it off that output, never recall it. Its `conflict` brief carries the
|
|
539
539
|
same worktree and branch identity paragraph, but in place of the review paragraphs:
|
|
540
540
|
bring `<branch>` current with `origin/main`, resolving every conflict by
|
|
541
|
-
`run-merge-bot.md`'s **No-undo audit**, run `<testCmd>`, push, report the new
|
|
541
|
+
`run-merge-bot.md`'s **No-undo audit**, run `<checkCmd>`/`<testCmd>`, push, report the new
|
|
542
542
|
head. Settle it `applied:<head>` — `no-op` if no conflict was left — which is
|
|
543
543
|
what lifts the tick's merge hold; a dispatched-but-live one does not, and
|
|
544
544
|
`failed`/`killed` leave the PR fix-due for a replacement. It answers the
|
|
@@ -580,7 +580,7 @@ before the call, and dispatch it with the `agent` that command printed
|
|
|
580
580
|
(`fleet-fix-applier`, as for a review fix-applier), in the PR's
|
|
581
581
|
existing worktree. Its `red-check` brief carries the same worktree and branch
|
|
582
582
|
identity paragraph, but in place of the review paragraphs: read the failing job's
|
|
583
|
-
log, fix the cause, run `<testCmd>`, push, report the new head, and **stop**.
|
|
583
|
+
log, fix the cause, run `<checkCmd>`/`<testCmd>`, push, report the new head, and **stop**.
|
|
584
584
|
It is told outright not to watch, poll or wait for CI: your persistent Monitor
|
|
585
585
|
owns the next run on the pushed head, and the finisher gate — review returned
|
|
586
586
|
AND fix-applier report sent — applies to this member the same way. Settle it
|
|
@@ -599,7 +599,7 @@ dispositions record and the report rules — is the body of
|
|
|
599
599
|
prompt, so none of it is pasted. The brief carries what varies, and the
|
|
600
600
|
renderer prints it: `~/.fleet/bin/fleet-run dispatch-prompt.mjs render
|
|
601
601
|
fix-applier --kind <review|conflict|dispositions-retry|red-check> --member <name>
|
|
602
|
-
--worktree <abs-path> --branch <branch> --testCmd <testCmd> --scratch <scratch>`,
|
|
602
|
+
--worktree <abs-path> --branch <branch> --testCmd <testCmd> --checkCmd <checkCmd> --scratch <scratch>`,
|
|
603
603
|
plus `--pr <pr#> --run <run>` for a review or a dispositions retry and
|
|
604
604
|
`--mismatched-record <path> --violations <list>` for a retry. Then
|
|
605
605
|
`ledger.mjs dispatch <pr#> <name>` records it before the call.
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
arg worktree abs-path
|
|
3
3
|
arg branch branch
|
|
4
4
|
arg testCmd testCmd
|
|
5
|
+
arg checkCmd checkCmd
|
|
5
6
|
arg scratch scratch
|
|
6
7
|
arg pr pr
|
|
7
8
|
arg run run
|
|
@@ -51,7 +52,7 @@ and `cwdAudit` before any result of the shared test run. Every one of those
|
|
|
51
52
|
rulings is yours — nobody upstream has made one, and the controller never
|
|
52
53
|
reads the findings.
|
|
53
54
|
|
|
54
|
-
**Run `<testCmd>` from the worktree before committing**, copied verbatim.
|
|
55
|
+
**Run `<checkCmd>` from the worktree before committing**, copied verbatim. **Run `<testCmd>` from the worktree before committing**, copied verbatim.
|
|
55
56
|
`tests 0` is a FAILED run, not a pass. Red or zero-test → fix it, or move that
|
|
56
57
|
finding to defer; never commit over it. **Never `--no-verify`** — a failing
|
|
57
58
|
hook is a finding you report, not an obstacle you route around. Report the test
|
|
@@ -65,18 +66,25 @@ claim, never your patch.
|
|
|
65
66
|
## kind: conflict
|
|
66
67
|
Bring `<branch>` current with `origin/main`, resolving every conflict by the
|
|
67
68
|
**No-undo audit** in `$(~/.fleet/bin/fleet-run --root)/commands/run-merge-bot.md`.
|
|
68
|
-
Run `<testCmd>` from the worktree before you push, then push with
|
|
69
|
+
Run `<checkCmd>` and `<testCmd>` from the worktree before you push, then push with
|
|
69
70
|
`~/.fleet/bin/fleet-run push-sync.mjs --branch <branch>`. Report the new head, or that no conflict was
|
|
70
71
|
left.
|
|
71
72
|
## kind: red-check
|
|
72
|
-
Read the failing job's log and fix the cause. Run `<
|
|
73
|
-
worktree before you push, then push with `~/.fleet/bin/fleet-run push-sync.mjs --branch <branch>`.
|
|
73
|
+
Read the failing job's log and fix the cause. Run `<checkCmd>` and `<testCmd>`
|
|
74
|
+
from the worktree before you push, then push with `~/.fleet/bin/fleet-run push-sync.mjs --branch <branch>`.
|
|
74
75
|
Report the new head and **stop**: do not watch, poll or wait for CI — the
|
|
75
76
|
controller owns the next run on the pushed head. A red that is not yours to
|
|
76
77
|
fix — a flake that passes on rerun, or staleness — gets nothing pushed: report
|
|
77
78
|
that instead.
|
|
78
79
|
## kind: review, dispositions-retry, conflict, red-check
|
|
79
80
|
|
|
81
|
+
**A red `<checkCmd>` is yours to judge.** Read what it reports and fix every
|
|
82
|
+
finding in a file the PR changes, including lines you did not edit — CI checks
|
|
83
|
+
whole files. Never edit a file the PR does not otherwise change: a finding there
|
|
84
|
+
is a stale base, not your code, so rebase onto `origin/main` and re-run; still
|
|
85
|
+
red → push nothing and report that cause. No fix command is recorded; the Check
|
|
86
|
+
step only reports.
|
|
87
|
+
|
|
80
88
|
Your report is a file, `<scratch>/reports/<your name>.json`, `<your name>` the
|
|
81
89
|
`fix-pr-` name you were dispatched as, in the shape
|
|
82
90
|
`~/.fleet/bin/fleet-run report-schema.mjs print fix-pr` prints. Write it
|
|
@@ -320,15 +320,20 @@ claim before it lands. `<repo>` is this main checkout's absolute path and
|
|
|
320
320
|
`<scratch>` is the absolute path `scratch-root.mjs` prints (SKILL.md's **The
|
|
321
321
|
scratch root is computed**); the renderer refuses a relative one. The member
|
|
322
322
|
reads the repository,
|
|
323
|
-
proposes
|
|
323
|
+
proposes all three commands — the Install step, the Check step and the Test
|
|
324
|
+
entrypoint — and proves them with `recipe-prove.mjs` in a
|
|
324
325
|
throwaway worktree of `origin/main`: the Install step must leave the tree
|
|
325
|
-
clean,
|
|
326
|
+
clean, a Check step other than `true` must be green there and turn red under
|
|
327
|
+
a deliberate mutation of a tracked file, and the Test entrypoint must be shown to run real tests — the
|
|
326
328
|
runner's own non-zero count, or a deliberate mutation of one test that
|
|
327
329
|
turns the run red. Only a proof writes the cache, with `derivedAt` (the
|
|
328
330
|
`origin/main` commit it ran against) and the proof itself. **`RECIPE
|
|
329
331
|
PROVEN`** → re-run both reads above (`derive-testcmd.sh . install` and
|
|
330
|
-
`derive-testcmd.sh . test --at origin/main`)
|
|
331
|
-
Recipe this run uses. **`RECIPE NOT PROVEN`** →
|
|
332
|
+
`derive-testcmd.sh . test --at origin/main`) and the Check step's read
|
|
333
|
+
below; their output is the Recipe this run uses. **`RECIPE NOT PROVEN`** →
|
|
334
|
+
stall, below.
|
|
335
|
+
|
|
336
|
+
**Where `checkCmd` comes from:** the repository's Check step, out of the same Recipe cache — `~/.fleet/bin/fleet-run derive-testcmd.sh . check` prints it. Pass it to the fix-applier beside `testCmd`; the review does not run it.
|
|
332
337
|
|
|
333
338
|
**Mid-run, a cache that stops running is re-derived once, then the run
|
|
334
339
|
stalls.** A claim or a review that refuses because the Recipe cache is
|