fix(no-stop): review pass 1: drift stays refused, timeout values only, range-base bound to the last pass, word-by-word re-gate, hunk-aware diff parse

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-11-04 00:20:59 +00:00
1 parent 627b739a17
commit 68ef40d8b2
10 files changed
+125 -54

No files matched your search

+19 -1
View File
@@ -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/<base>): take the carry's RECHECK: route instead of Steps 2-16
autoplan-regate gstack-autoplan records it; import --from <run>/no-stop.json adds it here
Step 19's receipt write adds --no-stop @<NO_STOP_FILE> 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 <ref> (stamp-only-carry): compare HEAD with the receipt head merged into <ref> (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 <sha>\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;
+4 -4
View File
@@ -2,15 +2,15 @@
### Added
- **`gstack-no-stop record --class <class> --reason <text> --evidence <ref>`** (`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 @<file>`.
### 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 `<run>/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 `<run>/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
+4 -4
View File
@@ -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 `<run>/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 `<run>/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 @<file>`.
+6 -4
View File
@@ -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[] = [];
+51 -34
View File
@@ -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<number>(b.length + 1).fill(0));
m.push(new Array<number>(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<string, number>();
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<string, Map<string, number>>;
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 ?? '');
+1 -1
View File
@@ -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: <label> <state> exit= tests_ran= log=` line each.
`fail` or `incomplete` blocks: fix, rerun. **No-stop** (docs/review-policy.md#no-stop): a pregate flake rerun, a
`fail` or `incomplete` blocks: fix, rerun. **No-stop** (docs/review-policy.md#no-stop): a pregate flake rerun (one per branch), a
timeout-only fix and three other routine stops go through `gstack-no-stop record` (`--help`); `recorded` goes on,
`refused` stops. `requires-remote` (other platforms; the remote gate under 8 cores) needs
`gstack-pregate clear` or, under a user cap, `--remote --spend-cap <usd>`. Rerun
+1 -1
View File
@@ -12,7 +12,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: <label> <state> exit= tests_ran= log=` line each.
`fail` or `incomplete` blocks: fix, rerun. **No-stop** (docs/review-policy.md#no-stop): a pregate flake rerun, a
`fail` or `incomplete` blocks: fix, rerun. **No-stop** (docs/review-policy.md#no-stop): a pregate flake rerun (one per branch), a
timeout-only fix and three other routine stops go through `gstack-no-stop record` (`--help`); `recorded` goes on,
`refused` stops. `requires-remote` (other platforms; the remote gate under 8 cores) needs
`gstack-pregate clear` or, under a user cap, `--remote --spend-cap <usd>`. Rerun
+15
View File
@@ -159,6 +159,21 @@ describe('carry-forward', () => {
expect(readRunJson(edited.out).status).toBe('gate_pending');
});
test('an edit to the reviewed plan itself is never a no-stop, whether or not the plan file also changed', () => {
const drift = answeredAndRevised();
const active = path.join(drift.out, 'plan-active.md');
fs.writeFileSync(active, fs.readFileSync(active, 'utf8').replace('cap 30 s', 'cap 300 s'));
phaseVia(r, drift.out, 'ceo', NONE);
expect(phaseVia(r, drift.out, 'eng', NONE).outside.stdout).toMatch(/^CARRY: no \(the reviewed plan differs from the plan file\)$/m);
const both = answeredAndRevised();
const active2 = path.join(both.out, 'plan-active.md');
fs.writeFileSync(active2, fs.readFileSync(active2, 'utf8').replace('cap 30 s', 'cap 300 s'));
fs.appendFileSync(both.v2, '\nAn ELI5 sentence added after the revise.\n');
phaseVia(r, both.out, 'ceo', NONE);
expect(phaseVia(r, both.out, 'eng', NONE).outside.stdout).toMatch(/^CARRY: no \(the reviewed plan differs from the plan file\)$/m);
expect(fs.existsSync(path.join(both.out, 'no-stop.json'))).toBe(false);
});
test('a new scope challenge or an urgent finding refuses the carry', () => {
const scope = answeredAndRevised();
phaseVia(r, scope.out, 'ceo', NONE);
+23 -4
View File
@@ -40,7 +40,15 @@ describe('lib/no-stop: what qualifies', () => {
expect(check('test-timeout-fix', diff('test/worker.test.ts', [], [' // a new line, timeout 30_000']))).toMatchObject({ ok: false });
});
test('pregate-flake-rerun: one rerun per run; a second stops', () => {
test('review pass 1 holes: an assertion bound, a name that only contains "timeout", and a "-- " line inside a hunk are not timeout-only', () => {
expect(check('test-timeout-fix', diff('test/perf.test.ts', [' expect(elapsedMs).toBeLessThan(timeoutMs + 100);'], [' expect(elapsedMs).toBeLessThan(timeoutMs + 5000);']))).toMatchObject({ ok: false });
expect(check('scoped-fix-check', diff('lib/retry.ts', ['const MAX_ATTEMPTS_BEFORE_TIMEOUT = 3;'], ['const MAX_ATTEMPTS_BEFORE_TIMEOUT = 30;']))).toMatchObject({ ok: false });
expect(check('test-timeout-fix', diff('test/x.test.ts', [' setDefaultTimeout(5_000);'], [' setDefaultTimeout(30_000);']))).toMatchObject({ ok: true });
const hidden = 'diff --git a/test/x.test.ts b/test/x.test.ts\n--- a/test/x.test.ts\n+++ b/test/x.test.ts\n@@ -1,2 +1,1 @@\n--- guard\n- }, 5000);\n+ }, 50000);\n';
expect(check('test-timeout-fix', hidden)).toMatchObject({ ok: false });
});
test('pregate-flake-rerun: one rerun per branch; a second stops', () => {
expect(check('pregate-flake-rerun')).toMatchObject({ ok: true });
expect(check('pregate-flake-rerun', undefined, [{ class: 'pregate-flake-rerun', reason: 'x', evidence: 'y' }])).toMatchObject({ ok: false });
});
@@ -59,13 +67,17 @@ describe('lib/no-stop: what qualifies', () => {
expect(check('autoplan-regate')).toMatchObject({ ok: false, reason: expect.stringContaining('gstack-autoplan decides') });
});
test('regateTouches: a changed number next to a cap, a new arm or a dollar figure counts; kept words and other edits do not', () => {
expect(regateTouches('- base 500 ms, cap 30 s\n+ base 500 ms, cap 30 s, behind RETRY_BACKOFF=1')).toEqual([]);
test('regateTouches: a changed number, an edited line that names a cap, a new arm or a dollar figure counts; other edits do not', () => {
expect(regateTouches('- Retry the webhook worker\n+ Retry the webhook worker behind RETRY_BACKOFF=1')).toEqual([]);
expect(regateTouches('- base 500 ms, cap 30 s\n+ base 500 ms, cap 30 s, behind RETRY_BACKOFF=1')).toHaveLength(2);
expect(regateTouches('+ An ELI5 sentence added after the revise.')).toEqual([]);
expect(regateTouches('- cap 30 s\n+ cap 90 s')).toHaveLength(2);
expect(regateTouches('+ Add a no-gstack arm to the panel.')).toHaveLength(1);
expect(regateTouches('+ Budget up to $40 for the pilot.')).toHaveLength(1);
expect(regateTouches('+ Runs on capy-host machines.')).toEqual([]);
expect(regateTouches('- Arm A cap 30 s; arm B cap 90 s\n+ Arm A cap 90 s; arm B cap 30 s').length).toBeGreaterThan(0);
expect(regateTouches('- Run 40 jobs per sweep\n+ Run 400 jobs per sweep').length).toBeGreaterThan(0);
expect(regateTouches('- Keep the budgets\n+ Double the budgets').length).toBeGreaterThan(0);
});
});
@@ -85,10 +97,17 @@ describe('gstack-no-stop record: the ledger, the grant and the receipt', () => {
fs.writeFileSync(path.join(work, 'lib.ts'), 'export const a = 1;\n');
git('add', '-A'); git('commit', '-qm', 'base');
const base = git('rev-parse', 'HEAD');
const slug = Object.fromEntries(spawnSync(path.join(ROOT, 'bin', 'gstack-slug'), [], { cwd: work, encoding: 'utf8', timeout: 30_000, env }).stdout.trim().split('\n').map(l => l.split('=') as [string, string]));
const reviewLog = path.join(state, 'projects', slug.SLUG!, `${slug.BRANCH}-reviews.jsonl`);
fs.mkdirSync(path.dirname(reviewLog), { recursive: true });
fs.writeFileSync(reviewLog, JSON.stringify({ skill: 'review', commit_full: base }) + '\n');
test('a docs-only round-2 fix records the class, prints the grant, and the ledger is a valid receipt no_stop', () => {
fs.writeFileSync(path.join(work, 'docs', 'guide.md'), 'new wording\n');
git('commit', '-qam', 'docs fix');
const narrow = run('record', '--class', 'scoped-fix-check', '--range-base', 'HEAD~0', '--reason', 'r', '--evidence', 'e');
expect(narrow.status).toBe(3);
expect(narrow.stderr).toContain('is not the commit the last review pass read');
const r = run('record', '--class', 'scoped-fix-check', '--range-base', base, '--reason', 'round-2 fix reworded docs/guide.md', '--evidence', `commit ${git('rev-parse', 'HEAD').slice(0, 12)}`);
expect(r.status, r.stderr).toBe(0);
expect(r.stdout).toMatch(/^NO_STOP: scoped-fix-check recorded \(docs-only fix in docs\/guide\.md\)$/m);
@@ -108,7 +127,7 @@ describe('gstack-no-stop record: the ledger, the grant and the receipt', () => {
const before = run('list', '--json');
const second = run('record', '--class', 'scoped-fix-check', '--range-base', base, '--reason', 'again', '--evidence', 'commit x');
expect(second.status).toBe(3);
expect(second.stderr).toMatch(/^NO_STOP: refused scoped-fix-check \(one scoped-fix-check per run is recorded already; a second goes to the owner\); stop for the owner$/m);
expect(second.stderr).toMatch(/^NO_STOP: refused scoped-fix-check \(one scoped-fix-check per branch is recorded already; a second goes to the owner\); stop for the owner$/m);
fs.writeFileSync(path.join(work, 'lib.ts'), 'export const a = 2;\n');
git('commit', '-qam', 'code fix');
const code = run('record', '--class', 'test-timeout-fix', '--range-base', 'HEAD~1', '--reason', 'r', '--evidence', 'e');
@@ -1,4 +1,4 @@
{
"bytes": 329,
"bytes": 346,
"why": "GSTA-258 L5 (K1): the no-stop pointer in Step 5, the --no-stop receipt flag in Step 19's write, and the driver's gstack-no-stop exception"
}