diff --git a/bin/gstack-no-stop b/bin/gstack-no-stop
index 6d6dc4a94..9ec362ba6 100755
--- a/bin/gstack-no-stop
+++ b/bin/gstack-no-stop
@@ -13,6 +13,7 @@
* qualify prints `NO_STOP: refused` and exits 3: that stop still goes to the owner.
*/
import { spawnSync } from 'node:child_process';
+import * as path from 'node:path';
import { appendRows, buildRow, locate } from '../lib/approval-record';
import { EXIT, NO_STOP_CLASSES, exitTableHelp } from '../lib/headless-artifacts';
import { appendLedger, checkNoStop, ledgerPath, readLedger } from '../lib/no-stop';
@@ -30,7 +31,8 @@ const HELP = `gstack-no-stop — record one of the five no-stop defaults (docs/r
--base origin/): take the carry's RECHECK: route instead of Steps 2-16
autoplan-regate gstack-autoplan records it; import --from /no-stop.json adds it here
Step 19's receipt write adds --no-stop @ when list shows records.
- --range-base: the commit the fix or change is measured from (round 2's PASS_HEAD for scoped-fix-check,
+ --range-base: the commit the fix or change is measured from (round 2's PASS_HEAD for scoped-fix-check, which must be
+ the newest review-log pass row's commit,
the commit before the fix for test-timeout-fix, the receipt head for stamp-only-carry); required for those three
--base (stamp-only-carry): compare HEAD with the receipt head merged into (base side on conflict),
so a base merge plus a stamp qualifies and a product conflict resolution does not
@@ -43,6 +45,14 @@ const flag = (args: string[], name: string) => { const i = args.indexOf(name); r
const git = (args: string[]) => spawnSync('git', args, { cwd: process.cwd(), encoding: 'utf8', timeout: 60_000, maxBuffer: 64 * 1024 * 1024 });
const NEEDS_RANGE = ['scoped-fix-check', 'test-timeout-fix', 'stamp-only-carry'];
+/** The commit the newest full or scoped pass reviewed (its review-log row's commit_full): round 2's PASS_HEAD. */
+function lastPassHead(): string | null {
+ const r = spawnSync(path.join(import.meta.dir, 'gstack-review-read'), [], { cwd: process.cwd(), encoding: 'utf8', timeout: 60_000, maxBuffer: 64 * 1024 * 1024 });
+ const rows = (r.stdout ?? '').split('---CONFIG---')[0]!.split('\n').flatMap(l => { try { return [JSON.parse(l)]; } catch { return []; } });
+ const pass = rows.filter(x => x.skill === 'review' && typeof x.commit_full === 'string').at(-1);
+ return pass ? pass.commit_full : null;
+}
+
function grant(head: string, detail: string, ledger: string): string | { error: string } {
const branch = git(['rev-parse', '--abbrev-ref', 'HEAD']).stdout.trim();
const loc = locate({ cwd: process.cwd(), env: process.env, branch });
@@ -64,6 +74,14 @@ function record(args: string[], json: boolean): number {
if (NEEDS_RANGE.includes(cls) && !rangeBase) { process.stderr.write(`gstack-no-stop record: --class ${cls} needs --range-base \n`); return EXIT.usage; }
const file = ledgerPath(process.cwd());
if (!file) { process.stderr.write('gstack-no-stop record: not inside a git checkout\n'); return EXIT.fail; }
+ if (cls === 'scoped-fix-check') {
+ const pass = lastPassHead();
+ const base = git(['rev-parse', '--verify', `${rangeBase}^{commit}`]).stdout.trim();
+ if (!pass || pass !== base) {
+ process.stderr.write(`NO_STOP: refused ${cls} (--range-base ${rangeBase} is not the commit the last review pass read (${pass ? pass.slice(0, 12) : 'no review-log row'}), so the checked range would not be the range the fix-check covers); stop for the owner\n`);
+ return EXIT.refused;
+ }
+ }
let diff: string | undefined;
if (rangeBase) {
const base = cls === 'stamp-only-carry' ? flag(args, '--base') : undefined;
diff --git a/changelog.d/gsta258-l5-no-stop.md b/changelog.d/gsta258-l5-no-stop.md
index bb34c1745..c0df4b0bf 100644
--- a/changelog.d/gsta258-l5-no-stop.md
+++ b/changelog.d/gsta258-l5-no-stop.md
@@ -2,15 +2,15 @@
### Added
- **`gstack-no-stop record --class --reason --evidence `** (`lib/no-stop.ts`) checks a stop and appends `{class, reason, evidence}` to the branch's ledger, or prints `NO_STOP: refused` and exits 3. The five classes:
- - `scoped-fix-check`: one scoped fix-check past R51's two-round cap, when the round-2 fix is docs class only or only raises timeouts. It records the standing grant in the approval record and prints `GRANT:` for `gstack-review-plan --grant`.
- - `test-timeout-fix`: a fix whose every changed line is in a test file and only raises a timeout. Its gate finding takes a printed `kept` reason instead of `open`.
- - `pregate-flake-rerun`: one rerun of a failed pregate case at the same tree (rule 16).
+ - `scoped-fix-check`: one scoped fix-check past R51's two-round cap, when the round-2 fix is docs class only or only raises timeouts. The range must start at the commit the last review pass read. It records the standing grant in the approval record and prints `GRANT:` for `gstack-review-plan --grant`. One per branch.
+ - `test-timeout-fix`: a fix whose every changed line is in a test file and only raises a timeout value. A line with an assertion or a comparison never qualifies. Its gate finding takes a printed `kept` reason instead of `open`.
+ - `pregate-flake-rerun`: one rerun of a failed pregate case at the same tree (rule 16). One per branch.
- `stamp-only-carry`: a refused carry where HEAD differs from the receipt head merged into the base only in a version stamp. /ship then takes the carry's `RECHECK:` route instead of running Steps 2–16 again.
- `autoplan-regate`: see below.
- **`gstack-no-stop import` and `list`** add an autoplan run's records to the branch and print the ledger. /ship's receipt write passes it as `--no-stop @`.
### Changed
-- **An autoplan plan edit made after the owner's answer carries their words when it changes no spend, cap, arm or verdict.** A word only one side of the edit has counts as changed. So does a number on a line that names spend, a cap, an arm or a verdict. An edit with neither carries as `reason=no-stop autoplan-regate: …` and is written to `/no-stop.json`. Moving "cap 30 s" to "cap 90 s" still goes to the owner. The run keeps a copy of each plan file it hashes, so the edit is diffed against what the owner answered.
+- **An autoplan plan edit made after the owner's answer carries their words when it changes no spend, cap, arm or verdict.** Each edited line is compared word by word with the line it replaced. Any changed number counts, and so does any edit to a line that names spend, a budget, a cap, a limit, an arm, a model, a judge or a verdict. An edit with none of those carries as `reason=no-stop autoplan-regate: …` and is written to `/no-stop.json`. Moving "cap 30 s" to "cap 90 s", swapping two arms' caps, or an edit to the reviewed plan itself still goes to the owner. The run keeps a copy of each plan file it hashes, so the edit is diffed against what the owner answered.
- docs/review-policy.md#no-stop has the table. docs/ship-policy.md lists the receipt's `no_stop` field, and docs/unattended.md describes the carry-forward change.
### Tests
diff --git a/docs/review-policy.md b/docs/review-policy.md
index eebf7a50c..990d10849 100644
--- a/docs/review-policy.md
+++ b/docs/review-policy.md
@@ -179,11 +179,11 @@ stop goes to the owner as before. Every other stop still stops.
| Class | Qualifies when | What happens next |
|---|---|---|
-| `scoped-fix-check` | Round 2 stops for a fix whose range since round 2's `PASS_HEAD` is docs class only, or only raises timeout values. One per run. | It records the standing grant in the approval record (`by: gstack no-stop default (K1)`) and prints `GRANT:`. `gstack-review-plan --grant` plans the one scoped fix-check. A blocking finding there goes to the owner. |
-| `test-timeout-fix` | Every changed line is in a test file and only raises a timeout: a `timeout` key or bun's and jest's trailing `}, 30_000);` argument. | The gate scan's relaxation finding takes the printed `GATE_DISPOSITION: kept` reason instead of `open`. |
-| `pregate-flake-rerun` | One rerun of a failed pregate case at the same tree (CREW rule 16). One per run. | Green counts as flaky. Red again is a real failure. |
+| `scoped-fix-check` | Round 2 stops for a fix whose range since round 2's `PASS_HEAD` is docs class only, or only raises timeout values. `--range-base` must be the commit the newest review-log pass row read. One per branch: the ledger keeps every record, so a later /ship on the branch stops for the owner. | It records the standing grant in the approval record (`by: gstack no-stop default (K1)`) and prints `GRANT:`. `gstack-review-plan --grant` plans the one scoped fix-check. A blocking finding there goes to the owner. |
+| `test-timeout-fix` | Every changed line is in a test file and only raises a timeout value: the value of a timeout key (`timeout: 5_000`, `setDefaultTimeout(5_000)`, `--timeout=5000`) or bun's and jest's trailing `}, 30_000);` argument. A line with `expect`, `assert` or a comparison never qualifies. | The gate scan's relaxation finding takes the printed `GATE_DISPOSITION: kept` reason instead of `open`. |
+| `pregate-flake-rerun` | One rerun of a failed pregate case at the same tree (CREW rule 16). One per branch. | Green counts as flaky. Red again is a real failure. |
| `stamp-only-carry` | A refused carry, where the receipt head merged into the base (base side on conflict) differs from HEAD only in a version stamp: `VERSION`, the `"version"` line of `package.json`, `CHANGELOG.md`, the agents digest or `changelog.d/` fragments. | Take the carry's `RECHECK:` route instead of running Steps 2–16 again. |
-| `autoplan-regate` | A plan edit made after the owner's answer, or after the revise that took their changes. The edit's changed words name no spend, cost, budget, cap, credits, arm, verdict, threshold, bar, trial, sample or stop rule, no dollar figure, and no number on a line that names one. | `gstack-autoplan` carries the owner's words (`reason=no-stop autoplan-regate: …`) and writes `/no-stop.json`. `gstack-no-stop import` adds it to the branch's ledger. |
+| `autoplan-regate` | An edit to the plan file made after the owner's answer, or after the revise that took their changes, while the reviewed plan itself is unchanged. Each edited line is compared with the line it replaced, word by word. The edit changes no number and no word for spend, cost, budget, price, cap, credits, limit, max, arm, model, judge, verdict, threshold, bar, trial, sample or stop rule, and no line that names one of those words is reworded. | `gstack-autoplan` carries the owner's words (`reason=no-stop autoplan-regate: …`) and writes `/no-stop.json`. `gstack-no-stop import` adds it to the branch's ledger. |
The ledger is one JSON array per branch under the checkout's git directory
(`NO_STOP_FILE:`). /ship's receipt write passes it as `--no-stop @`.
diff --git a/lib/autoplan-carry.ts b/lib/autoplan-carry.ts
index d28a38b4d..27421ac46 100644
--- a/lib/autoplan-carry.ts
+++ b/lib/autoplan-carry.ts
@@ -24,7 +24,7 @@ import * as path from 'node:path';
import { attemptEstimate } from './autoplan-budget';
import { lineDiff } from './autoplan-confirm';
import { answerGate } from './autoplan-gate';
-import { driftLine } from './autoplan-plan-text';
+import { driftLine, reviewHash } from './autoplan-plan-text';
import { BOUNDARY, RESULT_FORMAT, REVIEWER_TRAILER } from './autoplan-prompts';
import { parseReviewResult } from './autoplan-reconcile';
import { FILES, RunError, journal, now, readDecisions, readJournal, readState, writeDecisions, writeState, type RunState } from './autoplan-state';
@@ -129,10 +129,12 @@ function assess(state: RunState, prev: GateList, list: GateList): Plan {
const journalRows = readJournal(state.out);
const at = journalRows.map(r => r.event === 'gate_written' && r.gate_rev === from).lastIndexOf(true);
let expected: string | undefined = at >= 0 ? journalRows[at]!.source_file_sha256 : undefined;
- for (const r of journalRows.slice(at + 1)) if (r.event === 'plan_revised') expected = r.source_file_sha256;
+ const hashOf = (file: string) => { try { return reviewHash(fs.readFileSync(file, 'utf8')); } catch { return undefined; } };
+ let reviewed: string | undefined = hashOf(atGateCopy(state.out, from));
+ for (const r of journalRows.slice(at + 1)) if (r.event === 'plan_revised') { expected = r.source_file_sha256; reviewed = r.new_plan_sha256; }
if (!expected) return { reason: 'no plan evidence recorded for the gate or the revise (a run written before carry-forward)', from };
- const changed = fileSha(planFile(state)) !== expected ? `the plan file ${path.basename(planFile(state))} changed after the owner's answer or the revise that took it`
- : driftLine(state, 'PLAN_SOURCE_CHANGED') ? 'the reviewed plan differs from the plan file' : undefined;
+ const changed = fileSha(planFile(state)) !== expected ? `the plan file ${path.basename(planFile(state))} changed after the owner's answer or the revise that took it` : undefined;
+ if ((!changed && driftLine(state, 'PLAN_SOURCE_CHANGED')) || (changed && (!reviewed || hashOf(state.active_plan) !== reviewed))) return { reason: 'the reviewed plan differs from the plan file', from };
const urgent = readJsonl(path.join(state.out, 'findings.jsonl')).rows.find(r => (r.urgent === true || r.outside_plan === true) && !r.settled_by);
if (urgent) return { reason: `urgent finding open: ${String(urgent.title ?? urgent.id)}`, from };
const doIt: GateItem[] = []; const deltas: GateItem[] = [];
diff --git a/lib/no-stop.ts b/lib/no-stop.ts
index 4e8f95244..29fccf797 100644
--- a/lib/no-stop.ts
+++ b/lib/no-stop.ts
@@ -5,9 +5,9 @@
* still stops for the owner.
*
* - scoped-fix-check: one scoped fix-check past R51's two-round cap when the round-2 fix is docs-only
- * (docs class, lib/review-depth.ts classifyPath) or timeout-only. One per ledger.
+ * (docs class, lib/review-depth.ts classifyPath) or timeout-only. One per branch.
* - test-timeout-fix: a fix whose every changed line, all in test files, only raises a timeout value.
- * - pregate-flake-rerun: one rerun of a failed pregate case at the same tree (CREW rule 16). One per ledger.
+ * - pregate-flake-rerun: one rerun of a failed pregate case at the same tree (CREW rule 16). One per branch.
* - stamp-only-carry: a refused receipt carry whose changes since the receipt head are version stamps only.
* - autoplan-regate: a later autoplan gate revision whose plan delta names no spend, cap, arm or verdict
* (lib/autoplan-carry.ts asks regateTouches before it carries).
@@ -25,16 +25,16 @@ import { classifyPath } from './review-depth';
export interface NoStopRecord { class: NoStopClass; reason: string; evidence: string }
export type NoStopVerdict = { ok: true; record: NoStopRecord; detail: string } | { ok: false; reason: string };
-export interface FileDiff { path: string; pairs: Array<[string, string]>; added: string[]; removed: string[]; whole: boolean }
+export interface FileDiff { path: string; pairs: Array<[string, string]>; added: string[]; removed: string[]; whole: boolean; inHunk?: boolean }
-const NUMBER = /\d[\d_]*(?:\.\d+)?/g;
-/** A timeout key, or bun's and jest's trailing per-test timeout argument (`}, 30_000);`). */
-const TIMEOUT_KEY = /timeout|\}\s*,\s*\d[\d_]*\s*\)\s*;?\s*$/i;
+/** A number that is a timeout's value: after a timeout key (`timeout: 5`, `timeoutMs = 5`, `setDefaultTimeout(5)`, `--timeout=5`) or bun's and jest's trailing per-test argument (`}, 30_000);`). */
+const TIMEOUT_VALUE = /(\b[a-z]*timeout\w*\s*(?::|=|\()\s*|--timeout[= ]|\}\s*,\s*)(\d[\d_]*)/gi;
+const ASSERTION = /\b(?:expect|assert\w*)\b|\s[<>]=?\s/;
const VERSION = /\d+\.\d+\.\d+(?:\.\d+)?/g;
const TEST_PATH = /(?:^|\/)(?:test|tests|__tests__|spec)\/|\.(?:test|spec)\.[cm]?[jt]sx?$/;
const STAMP_PATHS = ['VERSION', 'package.json', 'CHANGELOG.md', 'agents-digest/gstack-AGENTS.md'];
/** Plan-delta words that mean spend, a cap, an eval arm or a verdict changed: those re-gates go to the owner. */
-export const REGATE_WORDS = /\$|\b(?:spend|spent|cost|costs|budget|price|cap|caps|capped|credits?|arms?|verdicts?|thresholds?|bars?|pass(?:es)? at|trials?|samples?|stop rules?|kill rule)\b|\bn\s*=/i;
+export const REGATE_WORDS = /\$|\b(?:spend|spending|spent|costs?|budgets?|prices?|caps?|capped|credits?|limits?|max|maximum|arms?|models?|judges?|verdicts?|thresholds?|bars?|pass(?:es)? at|trials?|samples?|stop rules?|kill rules?)\b|\bn\s*=/i;
/** Unified diff (git diff) to files, each with its one-for-one changed line pairs; a run of unequal - and + counts is unpaired. */
export function parseDiff(diff: string): FileDiff[] {
@@ -53,9 +53,10 @@ export function parseDiff(diff: string): FileDiff[] {
cur = { path: line.replace(/^diff --git a\/(.*) b\/.*$/, '$1'), pairs: [], added: [], removed: [], whole: false };
files.push(cur);
} else if (!cur) continue;
- else if (line.startsWith('+++ ')) { if (line !== '+++ /dev/null') cur.path = line.slice(4).replace(/^b\//, ''); }
- else if (line.startsWith('--- ')) continue;
- else if (/^(?:new|deleted) file mode|^rename (?:from|to) |^Binary files /.test(line)) cur.whole = true;
+ else if (!cur.inHunk && line.startsWith('+++ ')) { if (line !== '+++ /dev/null') cur.path = line.slice(4).replace(/^b\//, ''); }
+ else if (!cur.inHunk && line.startsWith('--- ')) continue;
+ else if (!cur.inHunk && /^(?:new|deleted) file mode|^rename (?:from|to) |^Binary files /.test(line)) cur.whole = true;
+ else if (line.startsWith('@@')) { flush(); cur.inHunk = true; }
else if (line.startsWith('-')) { if (plus.length) flush(); minus.push(line.slice(1)); }
else if (line.startsWith('+')) plus.push(line.slice(1));
else flush();
@@ -64,17 +65,18 @@ export function parseDiff(diff: string): FileDiff[] {
return files;
}
-const numbers = (s: string) => (s.match(NUMBER) ?? []).map(n => Number(n.replace(/_/g, '')));
+const timeoutValues = (s: string) => [...s.matchAll(TIMEOUT_VALUE)].filter(m => !m[1]!.startsWith('}') || /\)\s*;?\s*$/.test(s.slice(m.index! + m[0].length))).map(m => Number(m[2]!.replace(/_/g, '')));
+const maskTimeouts = (s: string) => s.replace(TIMEOUT_VALUE, (_m, key: string, _n: string, at: number) => (!key.startsWith('}') || /\)\s*;?\s*$/.test(s.slice(at + _m.length)) ? `${key}#` : _m));
-/** Why the diff is not timeout-only, or null: every changed line pairs with one that differs only in larger numbers on a timeout key. */
+/** Why the diff is not timeout-only, or null: every changed line pairs with one that differs only in larger timeout values (never an assertion). */
export function notTimeoutOnly(files: FileDiff[]): string | null {
if (!files.length) return 'the fix changes nothing';
for (const f of files) {
if (f.whole || f.added.length || f.removed.length || !f.pairs.length) return `${f.path} adds, removes or replaces whole lines`;
for (const [was, now] of f.pairs) {
- if (was.replace(NUMBER, '#') !== now.replace(NUMBER, '#') || !TIMEOUT_KEY.test(now)) return `${f.path} changes more than a timeout value: ${now.trim().slice(0, 80)}`;
- const a = numbers(was); const b = numbers(now);
- if (b.some((n, i) => n < a[i]!) || b.every((n, i) => n === a[i])) return `${f.path} does not raise a timeout: ${now.trim().slice(0, 80)}`;
+ if (ASSERTION.test(now) || maskTimeouts(was) !== maskTimeouts(now)) return `${f.path} changes more than a timeout value: ${now.trim().slice(0, 80)}`;
+ const a = timeoutValues(was); const b = timeoutValues(now);
+ if (a.length !== b.length || b.some((n, i) => n < a[i]!) || b.every((n, i) => n === a[i])) return `${f.path} does not raise a timeout: ${now.trim().slice(0, 80)}`;
}
}
return null;
@@ -103,27 +105,42 @@ export function notStampOnly(files: FileDiff[]): string | null {
const QUANTITY = /^[$~]?-?\d[\d_,.]*(?:%|x|[a-z]{1,3})?$/i;
const tokens = (line: string) => line.split(/\s+/).map(t => t.replace(/[,.;:!?)]+$/, '')).filter(Boolean);
+/** The tokens of a and b outside their longest common subsequence: what the edit changed, in place. */
+function changedTokens(a: string[], b: string[]): string[] {
+ const m = a.map(() => new Array(b.length + 1).fill(0));
+ m.push(new Array(b.length + 1).fill(0));
+ for (let i = a.length - 1; i >= 0; i--) for (let j = b.length - 1; j >= 0; j--) m[i]![j] = a[i] === b[j] ? m[i + 1]![j + 1]! + 1 : Math.max(m[i + 1]![j]!, m[i]![j + 1]!);
+ const out: string[] = [];
+ let i = 0; let j = 0;
+ while (i < a.length && j < b.length) {
+ if (a[i] === b[j]) { i++; j++; } else if (m[i + 1]![j]! >= m[i]![j + 1]!) out.push(a[i++]!); else out.push(b[j++]!);
+ }
+ return [...out, ...a.slice(i), ...b.slice(j)];
+}
+
/**
- * The plan-delta lines (lineDiff's "+ " and "- " rows) whose changed words name spend, a cap, an arm or a verdict:
- * a word that only one side has is changed, and a changed number counts when its line names one of those. A word
- * both sides keep (a reworded line that still says "cap 30 s") changes nothing. An autoplan re-gate with none is a no-stop.
+ * The plan-delta lines (lineDiff's "+ " and "- " rows) whose edit changes spend, a cap, an arm, a verdict or any
+ * number. A run of removed lines pairs in order with the added run after it; the tokens outside the pair's
+ * longest common subsequence are its change. A pair counts when that change holds one of those words or a number,
+ * or when the line names one and anything on it changed, so swapping two caps or doubling a budget counts. An
+ * unpaired line counts when it holds one. An untouched line never counts. An autoplan re-gate with none is a no-stop.
*/
export function regateTouches(delta: string): string[] {
- const lines = delta.split('\n').filter(l => /^[+-] /.test(l));
- const bag = (sign: string) => {
- const m = new Map();
- for (const l of lines) if (l[0] === sign) for (const t of tokens(l.slice(2))) m.set(t, (m.get(t) ?? 0) + 1);
- return m;
- };
- const other = { '+': bag('-'), '-': bag('+') } as Record>;
- return lines.filter(l => {
- const keep = other[l[0]!]!;
- return tokens(l.slice(2)).some(t => {
- const n = keep.get(t) ?? 0;
- if (n > 0) { keep.set(t, n - 1); return false; }
- return REGATE_WORDS.test(t) || (QUANTITY.test(t) && REGATE_WORDS.test(l.slice(2)));
- });
- }).map(l => l.slice(0, 100));
+ const rows = delta.split('\n').filter(l => /^[+-] /.test(l));
+ const hits = (ts: string[]) => ts.some(t => REGATE_WORDS.test(t) || QUANTITY.test(t));
+ const out: string[] = [];
+ for (let i = 0; i < rows.length;) {
+ const minus: string[] = []; const plus: string[] = [];
+ while (i < rows.length && rows[i]![0] === '-') minus.push(rows[i++]!);
+ while (i < rows.length && rows[i]![0] === '+') plus.push(rows[i++]!);
+ for (let k = 0; k < Math.max(minus.length, plus.length); k++) {
+ const a = minus[k]; const b = plus[k];
+ const changed = a && b ? changedTokens(tokens(a.slice(2)), tokens(b.slice(2))) : tokens((a ?? b)!.slice(2));
+ const named = [a, b].some(l => l && REGATE_WORDS.test(l.slice(2)));
+ if (hits(changed) || (named && changed.length)) out.push(...[a, b].filter((l): l is string => !!l).map(l => l.slice(0, 100)));
+ }
+ }
+ return out;
}
export interface NoStopInput { cls: string; reason: string; evidence: string; prior: readonly NoStopRecord[]; diff?: string; root?: string }
@@ -134,7 +151,7 @@ export function checkNoStop(i: NoStopInput): NoStopVerdict {
const cls = i.cls as NoStopClass;
if (!i.reason.trim() || !i.evidence.trim()) return { ok: false, reason: 'a no-stop record needs a reason and evidence that resolves (run id, commit or file)' };
const record = { class: cls, reason: i.reason.trim(), evidence: i.evidence.trim() };
- if ((cls === 'scoped-fix-check' || cls === 'pregate-flake-rerun') && i.prior.some(r => r.class === cls)) return { ok: false, reason: `one ${cls} per run is recorded already; a second goes to the owner` };
+ if ((cls === 'scoped-fix-check' || cls === 'pregate-flake-rerun') && i.prior.some(r => r.class === cls)) return { ok: false, reason: `one ${cls} per branch is recorded already; a second goes to the owner` };
if (cls === 'pregate-flake-rerun') return { ok: true, record, detail: 'one rerun at the same tree (rule 16): green is flaky, red again is real' };
if (cls === 'autoplan-regate') return { ok: false, reason: 'gstack-autoplan decides re-gates at the gate (lib/autoplan-carry.ts), never by hand' };
const files = parseDiff(i.diff ?? '');
diff --git a/ship/sections/tests.md b/ship/sections/tests.md
index d2443a6b0..32cf548d5 100644
--- a/ship/sections/tests.md
+++ b/ship/sections/tests.md
@@ -216,7 +216,7 @@ remote gate (`gstack-pregate --allow-repo-commands`: stage 1 regen, secrets, str
stage 2 the selected lanes under pipefail), then the lanes inside the evidence ledger (one at
a time on 4 or fewer cores unless `--lanes serial|parallel`), and prints `PREGATE:`, `LANES:` and
one `LANE: