Files
gstack/bin/gstack-no-stop
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

157 lines
11 KiB
TypeScript
Executable File

#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* gstack-no-stop — record one of the five no-stop defaults and go on (GSTA-258 K1, docs/review-policy.md#no-stop).
*
* gstack-no-stop record --class <class> --reason <text> --evidence <ref> [--range-base <sha>] [--json]
* gstack-no-stop import --from <autoplan run>/no-stop.json [--json]
* gstack-no-stop list [--json]
*
* `record` checks the stop against its class (lib/no-stop.ts) and, when it qualifies, appends
* {class, reason, evidence} to the branch's ledger (NO_STOP_FILE:, which /ship passes to
* `gstack-ship-receipt write --no-stop @<file>`). A scoped-fix-check also records the standing grant in
* the approval record and prints GRANT: <id> for `gstack-review-plan --grant`. Anything that does not
* 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, readRecord } from '../lib/approval-record';
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
import { appendLedger, checkNoStop, ledgerPath, readLedger } from '../lib/no-stop';
import { runScriptSync } from '../lib/script-invocation';
const HELP = `gstack-no-stop — record one of the five no-stop defaults (docs/review-policy.md#no-stop)
record --class <class> --reason <text> --evidence <run id, commit or file> [--range-base <sha>] [--json]
NO_STOP: <class> recorded: go on. NO_STOP: refused (exit 3): the stop stands. Anything else still stops.
scoped-fix-check round 2 stops on a docs-only or timeout-only fix (--range-base round 2's PASS_HEAD):
run the one fix-check its NEXT: names with the printed GRANT:
test-timeout-fix a fix that only raises a test's timeout (--range-base the commit before it): its gate
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 (rule 16): green is flaky, red is real
stamp-only-carry carry refused a head whose change is a version stamp (--range-base the receipt head,
--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, 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
import --from <file> [--json] add an autoplan run's no-stop.json records to the branch's ledger
list [--json] the branch's ledger and its path
${exitTableHelp()}`;
const flag = (args: string[], name: string) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; };
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'];
const K1_BY = 'gstack no-stop default (K1)';
const AUTOPLAN_EVIDENCE = /^autoplan:\S+@gate_rev=\d+$/;
/** 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 = runScriptSync(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 });
if ('code' in loc) return { error: loc.message };
const row = buildRow({
ask: `No-stop default: one scoped fix-check past R51's cap (review_loop_grant ${head})`,
words: `K1 no-stop default, docs/review-policy.md#no-stop: ${detail}`,
by: K1_BY, scope: `review_loop_grant ${head}`, outcome: 'approved', source: `no-stop:${ledger.split('/').pop()}`,
}, branch);
if ('code' in row) return { error: row.message };
const added = appendRows(loc, [row]);
return 'code' in added ? { error: added.message } : row.id;
}
function record(args: string[], json: boolean): number {
const cls = flag(args, '--class') ?? '';
const commit = (ref: string | undefined) => { if (!ref) return undefined; const r = git(['rev-parse', '--verify', '--quiet', '--end-of-options', `${ref}^{commit}`]); return r.status === 0 ? r.stdout.trim() : null; };
const rangeBase = commit(flag(args, '--range-base'));
const base = cls === 'stamp-only-carry' ? commit(flag(args, '--base')) : undefined;
if (rangeBase === null || base === null) { process.stderr.write(`gstack-no-stop record: --range-base and --base must name commits\n`); return EXIT.usage; }
if (!cls) { process.stderr.write(`gstack-no-stop record: --class is required\n${HELP}\n`); return EXIT.usage; }
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();
if (!pass || pass !== rangeBase) {
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;
}
const loc = locate({ cwd: process.cwd(), env: process.env, branch: git(['rev-parse', '--abbrev-ref', 'HEAD']).stdout.trim() });
if (!readLedger(file).some(r => r.class === cls) && !('code' in loc) && readRecord(loc).rows.some(r => r.by === K1_BY)) {
process.stderr.write(`NO_STOP: refused ${cls} (the approval record already holds this branch's K1 grant, from this or another checkout; a second goes to the owner); stop for the owner\n`);
return EXIT.refused;
}
}
let diff: string | undefined;
if (rangeBase) {
let from = rangeBase;
if (base) {
const merged = git(['merge-tree', '--write-tree', '-X', 'ours', base, rangeBase]);
if (merged.status !== 0) { process.stderr.write(`NO_STOP: refused ${cls} (git merge-tree ${base} ${rangeBase} failed: ${merged.stderr.trim() || 'conflict'}); stop for the owner\n`); return EXIT.refused; }
from = merged.stdout.split('\n')[0]!.trim();
}
const d = git(['diff', '--no-color', '--no-ext-diff', '--no-renames', from, 'HEAD']);
if (d.status !== 0) { process.stderr.write(`gstack-no-stop record: git diff ${rangeBase} HEAD failed: ${d.stderr.trim()}\n`); return EXIT.fail; }
diff = d.stdout;
}
const v = checkNoStop({ cls, reason: flag(args, '--reason') ?? '', evidence: flag(args, '--evidence') ?? '', prior: readLedger(file), diff, root: process.cwd() });
if (!v.ok) {
process.stderr.write(`NO_STOP: refused ${cls} (${v.reason}); stop for the owner\n`);
if (json) process.stdout.write(JSON.stringify({ ok: false, class: cls, reason: v.reason }) + '\n');
return EXIT.refused;
}
const lines = [`NO_STOP: ${cls} recorded (${v.detail})`, `NO_STOP_FILE: ${file}`];
let grantId: string | undefined;
if (cls === 'scoped-fix-check') {
const head = git(['rev-parse', 'HEAD']).stdout.trim();
const g = grant(head, v.detail, file);
if (typeof g !== 'string') { process.stderr.write(`gstack-no-stop record: the grant was not recorded (${g.error}); stop for the owner\n`); return EXIT.fail; }
grantId = g;
lines.push(`GRANT: ${g}`, `NEXT: gstack-review-plan --grant ${g} --range-base ${rangeBase} (one scoped fix-check; a blocking finding there goes to the owner)`);
}
if (cls === 'test-timeout-fix') lines.push(`GATE_DISPOSITION: kept reason="no-stop test-timeout-fix: ${v.record.reason}"`);
appendLedger(file, v.record);
process.stdout.write(json ? JSON.stringify({ ok: true, record: v.record, detail: v.detail, file, ...(grantId ? { grant: grantId } : {}) }) + '\n' : lines.join('\n') + '\n');
return EXIT.ok;
}
function main(argv: string[]): number {
const [cmd, ...args] = argv;
const json = args.includes('--json');
if (!cmd || cmd === '--help' || cmd === '-h') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; }
if (cmd === 'record') return record(args, json);
if (cmd === 'import') {
const src = flag(args, '--from');
const file = ledgerPath(process.cwd());
if (!src || !file) { process.stderr.write(`gstack-no-stop import: ${src ? 'not inside a git checkout' : '--from <file> is required'}\n`); return EXIT.usage; }
const have = readLedger(file);
const rows = readLedger(src);
const eligible = rows.filter(r => r.class === 'autoplan-regate' && r.reason && AUTOPLAN_EVIDENCE.test(r.evidence ?? ''));
const fresh = eligible.filter(r => !have.some(h => JSON.stringify(h) === JSON.stringify(r)));
for (const r of fresh) appendLedger(file, r);
const skipped = rows.length - eligible.length;
process.stdout.write(json ? JSON.stringify({ file, imported: fresh.length, skipped }) + '\n' : [`NO_STOP: imported ${fresh.length} record(s) from ${src}`, ...(skipped ? [`NO_STOP: skipped ${skipped} record(s): import takes only autoplan-regate records with autoplan:<run>@gate_rev=N evidence; record the other classes with gstack-no-stop record`] : []), `NO_STOP_FILE: ${file}`].join('\n') + '\n');
return EXIT.ok;
}
if (cmd === 'list') {
const file = ledgerPath(process.cwd());
const rows = readLedger(file);
process.stdout.write(json ? JSON.stringify({ file, records: rows }) + '\n' : [...rows.map(r => `NO_STOP: ${r.class} ${r.reason} (${r.evidence})`), `NO_STOP_FILE: ${file ?? 'none (not a git checkout)'}`].join('\n') + '\n');
return EXIT.ok;
}
process.stderr.write(`gstack-no-stop: unknown command ${cmd}\n${HELP}\n`);
return EXIT.usage;
}
process.exit(main(process.argv.slice(2)));