Files
gstack/lib/no-stop.ts
T
Garry Tanandcapy-ai[bot] c07eddb5af feat(ship,autoplan): five routine stops record a no_stop and keep going (GSTA-258 L5, K1) (#3976)
* feat(ship,autoplan): five routine stops record a no_stop and keep going (GSTA-258 L5, K1)

gstack-no-stop record checks scoped-fix-check, test-timeout-fix,
pregate-flake-rerun and stamp-only-carry against lib/no-stop.ts and
appends {class, reason, evidence} to the branch ledger the receipt
write passes as --no-stop. A scoped fix-check records the standing
grant for gstack-review-plan --grant. gstack-autoplan carries a plan
edit after the owner answered when its changed words name no spend,
cap, arm or verdict, and writes <run>/no-stop.json. Anything that does
not qualify, and every other stop, still stops.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* 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>

* fix: pre-landing review fixes

Review pass 2 (below floor): import takes only autoplan-regate records with autoplan:<run>@gate_rev=N evidence; record diffs with --no-renames so a rename is a delete plus an add; a scoped-fix-check also checks the approval record for the branch's K1 grant, so a fresh checkout with an empty ledger still stops; a trailing }, N) counts as a timeout only where it closes a test callback; the re-gate words add paid, eval and model names; Step 19 sets NS on its own line so a path with spaces survives. Also launches gstack-review-read through runScriptSync (free-tests #3197 tripwire, run 41746166882).

Review-Finding: specialist-batched#1 reader=specialist-batched
Review-Finding: specialist-batched#3 reader=specialist-batched
Review-Finding: specialist-batched#4 reader=specialist-batched
Review-Finding: red-team#1 reader=red-team
Review-Finding: red-team#2 reader=red-team
Review-Finding: red-team#3 reader=red-team
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: autoplan-regate carries only ELI5 additions; timeout and ref checks fail closed

Step 11 voices (round 2): the autoplan-regate word filter failed open on markup, spelled-out counts and reworded verdicts. A plan-file edit now carries only when it adds lines inside ## ELI5 that hold no number and no spend, cap, arm, model or verdict word; any removed or reworded line goes to the owner, and the carry still exports the reviewed plan. A trailing }, N) counts as a timeout only on a line that opens the test call. --range-base and --base resolve to commits with --end-of-options before git sees them.

Review-Finding: targeted#1 reader=targeted
Review-Finding: outside-voice#1 reader=outside-voice
Review-Finding: outside-voice#2 reader=outside-voice
Review-Finding: outside-voice#3 reader=outside-voice
Review-Finding: adversarial-native#1 reader=adversarial-native
Review-Finding: adversarial-native#6 reader=adversarial-native
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: test-timeout-fix counts a trailing timeout only on the line that opens the test call

Step 14.5 docs audit l5-docs-1.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(ship): keep ship/DRIVER.md under its 12 KB cap after the main merge

Main brought the driver to exactly 12288 bytes, so the K1 'except gstack-no-stop' clause put it 20 bytes over (quick-bun-default run on ca43af4). Trim two phrases; the pinned fallback sentence is unchanged.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-11-04 03:03:39 +00:00

183 lines
12 KiB
TypeScript

/**
* The five no-stop defaults (GSTA-258 plan K1, docs/review-policy.md#no-stop): routine stops /ship and
* /autoplan record in the receipt's `no_stop` field and go on past instead of waiting for a Captain.
* Each class has one deterministic test here; anything that fails it, and any stop outside the five,
* 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 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 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-file edit only adds ELI5 lines that name no
* number, spend, cap, arm or verdict (lib/autoplan-carry.ts asks notEli5Only before it carries).
*
* The ledger is a JSON array of {class, reason, evidence} under the checkout's git dir, one file per
* branch, so `gstack-ship-receipt write --no-stop @<ledger>` takes it as it stands.
*/
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { branchKey } from './approval-record';
import { NO_STOP_CLASSES, type NoStopClass } from './headless-artifacts';
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; inHunk?: boolean }
/** 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'];
/** Words that mean spend, a cap, an eval arm, a model or a verdict: an ELI5 addition naming one goes to the owner. */
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?|paid|evals?|opus|sonnet|haiku|gpt[\w.-]*|gemini|claude)\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[] {
const files: FileDiff[] = [];
let cur: FileDiff | undefined;
let minus: string[] = []; let plus: string[] = [];
const flush = () => {
if (!cur) return;
if (minus.length === plus.length) minus.forEach((m, i) => cur!.pairs.push([m, plus[i]!]));
else { cur.removed.push(...minus); cur.added.push(...plus); }
minus = []; plus = [];
};
for (const line of diff.split('\n')) {
if (line.startsWith('diff --git ')) {
flush();
cur = { path: line.replace(/^diff --git a\/(.*) b\/.*$/, '$1'), pairs: [], added: [], removed: [], whole: false };
files.push(cur);
} else if (!cur) continue;
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();
}
flush();
return files;
}
const TEST_CALL = /^\s*(?:test|it|describe|bench|beforeAll|beforeEach|afterAll|afterEach)(?:\.\w+)*\(/;
/** A trailing `}, N)` is a timeout only where the same line opens the test call it closes; a multi-line callback's `}, N);` could close any call. */
const isTimeout = (s: string, key: string, at: number, len: number) => !key.startsWith('}') || (TEST_CALL.test(s) && /\)\s*;?\s*$/.test(s.slice(at + len)));
const timeoutValues = (s: string) => [...s.matchAll(TIMEOUT_VALUE)].filter(m => isTimeout(s, m[1]!, 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) => (isTimeout(s, key, 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 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 (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;
}
/** Why the paths are not docs-only, or null: each is docs class as /ship's review classifies it (agent-read and skill prose is not). */
export function notDocsOnly(paths: string[], root: string): string | null {
if (!paths.length) return 'the fix changes nothing';
const code = paths.filter(p => classifyPath(p, root) !== 'docs');
return code.length ? `not docs: ${code.slice(0, 3).join(', ')}` : null;
}
/** Why the diff is not a version stamp, or null: only stamp files change, and in VERSION or package.json only a version string moves. */
export function notStampOnly(files: FileDiff[]): string | null {
if (!files.length) return 'nothing changed since the receipt head';
for (const f of files) {
if (!STAMP_PATHS.includes(f.path) && !/^changelog\.d\/[^/]+\.md$/.test(f.path)) return `${f.path} is not a stamp file`;
if (f.path !== 'VERSION' && f.path !== 'package.json') continue;
if (f.whole || f.added.length || f.removed.length) return `${f.path} adds or removes lines`;
const bad = f.pairs.find(([was, now]) => was.replace(VERSION, 'V') !== now.replace(VERSION, 'V') || (f.path === 'package.json' && !/^\s*"version"\s*:/.test(now)));
if (bad) return `${f.path} changes more than the version: ${bad[1].trim().slice(0, 80)}`;
}
return null;
}
const HEADING = /^#{1,6}\s+(.*)$/;
const ELI5 = /^eli5\b/i;
/**
* Why a plan-file edit (lineDiff rows) is not an ELI5-only addition, or null. Every changed line must be added (none
* removed or rewritten), inside the plan's `## ELI5` section (an added `## ELI5` heading opens one), and hold no digit
* and no spend, cap, arm, model or verdict word. Anything else goes to the owner: the carry exports the reviewed
* plan, so the added explanation is never built.
*/
export function notEli5Only(lines: ReadonlyArray<{ op: string; text: string }>): string | null {
let section = '';
let added = 0;
for (const l of lines) {
const heading = l.text.match(HEADING);
if (l.op === '-') return `it removes or rewrites a line: ${l.text.trim().slice(0, 80)}`;
if (l.op !== '+') { if (heading) section = heading[1]!.trim(); continue; }
if (!l.text.trim()) continue;
if (heading && !ELI5.test(heading[1]!.trim())) return `it adds a heading: ${l.text.trim().slice(0, 80)}`;
if (heading) { section = heading[1]!.trim(); continue; }
if (!ELI5.test(section)) return `it adds a line outside ## ELI5: ${l.text.trim().slice(0, 80)}`;
if (/\d/.test(l.text.replace(/\bELI5\b/gi, '')) || REGATE_WORDS.test(l.text)) return `it names a number, spend, a cap, an arm, a model or a verdict: ${l.text.trim().slice(0, 80)}`;
added++;
}
return added ? null : 'the edit adds no ELI5 line';
}
export interface NoStopInput { cls: string; reason: string; evidence: string; prior: readonly NoStopRecord[]; diff?: string; root?: string }
/** The verdict for one stop: the record to add, or why it still stops. */
export function checkNoStop(i: NoStopInput): NoStopVerdict {
if (!(NO_STOP_CLASSES as readonly string[]).includes(i.cls)) return { ok: false, reason: `${i.cls} is not one of the five no-stop classes (${NO_STOP_CLASSES.join(', ')})` };
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 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 ?? '');
const where = files.map(f => f.path).join(', ');
if (cls === 'stamp-only-carry') {
const why = notStampOnly(files);
return why ? { ok: false, reason: why } : { ok: true, record, detail: `version stamp only in ${where}` };
}
const timeout = notTimeoutOnly(files);
if (cls === 'test-timeout-fix') {
const outside = files.filter(f => !TEST_PATH.test(f.path)).map(f => f.path);
if (outside.length) return { ok: false, reason: `not a test file: ${outside.slice(0, 3).join(', ')}` };
return timeout ? { ok: false, reason: timeout } : { ok: true, record, detail: `timeout raised in ${where}` };
}
if (!timeout) return { ok: true, record, detail: `timeout-only fix in ${where}` };
const docs = notDocsOnly(files.map(f => f.path), i.root ?? process.cwd());
return docs ? { ok: false, reason: `the fix is neither docs-only (${docs}) nor timeout-only (${timeout})` } : { ok: true, record, detail: `docs-only fix in ${where}` };
}
/** The branch's ledger under the checkout's git dir, or null outside a git checkout. */
export function ledgerPath(cwd: string): string | null {
const run = (args: string[]) => spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000 });
const branch = run(['rev-parse', '--abbrev-ref', 'HEAD']);
const dir = run(['rev-parse', '--git-path', 'gstack']);
if (branch.status !== 0 || dir.status !== 0) return null;
return path.resolve(cwd, dir.stdout.trim(), `no-stop-${branchKey(branch.stdout.trim())}.json`);
}
export function readLedger(file: string | null): NoStopRecord[] {
if (!file || !fs.existsSync(file)) return [];
const rows = JSON.parse(fs.readFileSync(file, 'utf8'));
return Array.isArray(rows) ? rows : [];
}
export function appendLedger(file: string, record: NoStopRecord): NoStopRecord[] {
const rows = [...readLedger(file), record];
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify(rows, null, 2) + '\n');
return rows;
}