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>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-04 03:03:39 +00:00
1 parent b2e2c166b0
commit c07eddb5af
19 files changed
+711 -30

No files matched your search

+1 -1
View File
@@ -38,7 +38,7 @@ line yourself; gstack never writes the plan. **Kinds (R65), first `next` only.**
5. `REOPENED: <phases>` means loop `next` again (Eng last). `APPROVED:` means `export` (`DISPATCHES: <n> est_input_tokens=<k>`, relative).
6. Rounds: one by default; a second only for High or Critical findings; cap 2 (`--max-rounds`). A `revise` whose decisions all resolve below
High prints `APPLIED: <ids> without re-review (below High)`. At the cap with High open, `ESCALATE: evidence sufficient, decision needed`: relay it, never ask for another round.
7. Carry-forward ([docs/unattended.md#carry-forward](../docs/unattended.md#carry-forward); `--no-carry-forward` on the first `next` opts out): a later gate that asks nothing new takes the owner's words from the gate before it (`CARRIED:`, then `APPROVED:`); `CARRY: no (<why>)` leaves it for the owner; `CARRY_CHECK:` means one reviewer from another model family reads the printed delta prompt, then `check --out <dir> --attempt <id> --result <file> --model <id>` (or `--unavailable "<reason>"`).
7. Carry-forward ([docs/unattended.md#carry-forward](../docs/unattended.md#carry-forward); `--no-carry-forward` on the first `next` opts out): a later gate that asks nothing new takes the owner's words from the gate before it (`CARRIED:`, then `APPROVED:`); a plan edit that only adds plain ELI5 lines carries as K1's `autoplan-regate` no-stop (`NO_STOP:`; before /ship run `gstack-no-stop import --from <dir>/no-stop.json`); `CARRY: no (<why>)` leaves it for the owner; `CARRY_CHECK:` means one reviewer from another model family reads the printed delta prompt, then `check --out <dir> --attempt <id> --result <file> --model <id>` (or `--unavailable "<reason>"`).
### On Capy
+1 -1
View File
@@ -35,7 +35,7 @@ line yourself; gstack never writes the plan. **Kinds (R65), first `next` only.**
5. `REOPENED: <phases>` means loop `next` again (Eng last). `APPROVED:` means `export` (`DISPATCHES: <n> est_input_tokens=<k>`, relative).
6. Rounds: one by default; a second only for High or Critical findings; cap 2 (`--max-rounds`). A `revise` whose decisions all resolve below
High prints `APPLIED: <ids> without re-review (below High)`. At the cap with High open, `ESCALATE: evidence sufficient, decision needed`: relay it, never ask for another round.
7. Carry-forward ([docs/unattended.md#carry-forward](../docs/unattended.md#carry-forward); `--no-carry-forward` on the first `next` opts out): a later gate that asks nothing new takes the owner's words from the gate before it (`CARRIED:`, then `APPROVED:`); `CARRY: no (<why>)` leaves it for the owner; `CARRY_CHECK:` means one reviewer from another model family reads the printed delta prompt, then `check --out <dir> --attempt <id> --result <file> --model <id>` (or `--unavailable "<reason>"`).
7. Carry-forward ([docs/unattended.md#carry-forward](../docs/unattended.md#carry-forward); `--no-carry-forward` on the first `next` opts out): a later gate that asks nothing new takes the owner's words from the gate before it (`CARRIED:`, then `APPROVED:`); a plan edit that only adds plain ELI5 lines carries as K1's `autoplan-regate` no-stop (`NO_STOP:`; before /ship run `gstack-no-stop import --from <dir>/no-stop.json`); `CARRY: no (<why>)` leaves it for the owner; `CARRY_CHECK:` means one reviewer from another model family reads the printed delta prompt, then `check --out <dir> --attempt <id> --result <file> --model <id>` (or `--unavailable "<reason>"`).
### On Capy
+156
View File
@@ -0,0 +1,156 @@
#!/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)));
+18
View File
@@ -0,0 +1,18 @@
**Five routine /ship and /autoplan stops no longer wait for a Captain: each is checked, recorded in the receipt's `no_stop` field, and the run keeps going (GSTA-258 L5, plan K1).** GBrain-Evals pre-granted these after GBEV-30 stopped about seven times in one day on them. A case that doesn't qualify, and every other stop, still goes to the owner.
### 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. 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 only adds ELI5 lines.** Every added line must sit in the plan's `## ELI5` section and name no number, spend, budget, cap, limit, arm, model, judge or verdict. Such an edit carries as `reason=no-stop autoplan-regate: …` and is written to `<run>/no-stop.json`, and the run still exports the reviewed plan. A removed or reworded line, a line added anywhere else, 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
- `test/no-stop.test.ts`: each class's qualifying and refused cases, the ledger, the grant row, the receipt's `no_stop` validation, stamp-only after a base merge, import, and the template wording.
- `test/autoplan-carry.test.ts`: an ELI5-only addition after the revise now carries and is recorded. A cap move still refuses.
+21
View File
@@ -168,6 +168,26 @@ The same floor and the same `NO FINDINGS` rule apply to `/review` on its own;
its re-review loop keeps its three-cycle fix cap (`CYCLES`), and a pass that
applies no edits is its exit too.
<a id="no-stop"></a>
### No-stop defaults (K1)
Five routine stops don't wait for a Captain. /ship and /autoplan record each one
as `{class, reason, evidence}` in the receipt's `no_stop` field and keep going.
`gstack-no-stop record` (`lib/no-stop.ts`) checks each case before it records it.
When a case doesn't qualify, it prints `NO_STOP: refused` and exits 3, and that
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. `--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 per-test argument, only on the line that opens the test call itself (`test('…', () => {…}, 30_000);`, likewise `it(` and `describe(`). A multi-line test's closing `}, 30_000);` does not qualify and stops for the owner. 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` | 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. The edit only adds lines, all inside the plan's `## ELI5` section (an added `## ELI5` heading opens one), and no added line holds a number or a word for spend, cost, budget, price, cap, credits, limit, max, arm, model, judge, verdict, threshold, bar, trial, sample, stop rule, paid or eval. A removed or reworded line, or a line added anywhere else, goes to the owner. The carry exports the reviewed plan, so the added explanation is never built. | `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>`.
<a id="review-depth"></a>
## Review depth by diff class
@@ -792,3 +812,4 @@ ones, and the arm lost blockers. `bin/gstack-review-dispatch` closes both ends:
- `review/checklist.md`: `prereg-with-results` under Gate Integrity and `test-keyboard-count-loop` under Pass 2; `bin/gstack-coauthor-trailers` for the contributor-credit trailers of a port.
- `lib/ship-receipt-prose.ts`, `gstack-ship-receipt read --check-prose`: the body's quoted counters against the receipt (`RECEIPT_PROSE_MISMATCH`, [ship-policy.md](ship-policy.md#ship-receipt)).
- `lib/ship-receipt.ts`, `lib/headless-artifacts.ts`: the `review_loop` receipt object, its schema and the summary line.
- `lib/no-stop.ts`, `bin/gstack-no-stop`, `lib/autoplan-carry.ts`: the [no-stop defaults](#no-stop), their ledger and the autoplan re-gate (`test/no-stop.test.ts`, `test/autoplan-carry.test.ts`).
+7 -2
View File
@@ -1240,8 +1240,8 @@ written by the pr-body section of `/ship` after the body is composed.
The ```` ```gstack-ship-receipt ```` block is the handoff record: identifiers
first (`pr`, `head`, `version`, `base`), then the evidence (`gate`, `ci`,
`head_tree`, `product_patch_id`, `gstack_version`, `review_depth`, `pregate`,
`issues`, `approval`, `docs`, `review_loop`, `review_loop_exception`, `carry`),
validated
`issues`, `approval`, `docs`, `review_loop`, `review_loop_exception`, `carry`,
`no_stop`), validated
against the `ship-receipt` schema (`gstack-artifact schema ship-receipt`) and
read back by `gstack-ship-receipt read`, `result`, `carry` and the queue.
@@ -1302,6 +1302,11 @@ checked-out HEAD, when the receipt records `gstack_version` without
`review_depth`, or when an owner exception no longer stands, each as
[`RECEIPT_CARRY_REFUSED`](troubleshooting.md#receipt-carry-refused).
**`no_stop`.** The routine stops the run recorded and went on past, from
`write --no-stop <json|@file>`: an array of `{ class, reason, evidence }`, with
`class` one of the five no-stop defaults. /ship passes the branch's
`gstack-no-stop` ledger here ([review-policy.md#no-stop](review-policy.md#no-stop)).
**`review_loop_exception`.** The owner's acceptance of a stop after a
fix-check edit under frozen scope (#3559), from `write --review-loop-exception
<json|@file>`: `{ accepted_by, date, via, reason, fix_check: { head, findings[, files] } }`.
+4 -2
View File
@@ -618,8 +618,10 @@ then the usual `APPROVED:` lines, and journals `gate_carried`. The carry needs
all of: the earlier gate approved (`p1a` or `p1b`), no item there left
pending, and the owner's words recorded; that gate was not itself carried; every accepted change incorporated
or settled; the plan file's bytes equal those recorded at the gate or at the
`revise` that took the changes (any later edit refuses, an ELI5-only one
included); no urgent or
`revise` that took the changes, or a later edit only adds ELI5 lines that name
no number, spend, cap, arm or verdict (K1's `autoplan-regate` no-stop: the
reason says so, `NO_STOP:` names `<run>/no-stop.json`, the reviewed plan is
exported, and any other edit refuses; [review-policy.md#no-stop](review-policy.md#no-stop)); no urgent or
outside-plan finding open; and every new gate item one of the cases below.
Anything else prints `CARRY: no (<why>)` (journal `carry_refused`) and the gate
goes to the owner as before. `--no-carry-forward` on the first `next` turns it
+34 -12
View File
@@ -24,13 +24,14 @@ 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';
import { appendTrailRow } from './decision-trail';
import { decisionId, parseReply, type GateItem, type GateList } from './gate-list';
import { readJsonl } from './headless-artifacts';
import { appendLedger, notEli5Only } from './no-stop';
import { modelFamily } from './outside-voice-runner';
import { release, reserve, settle } from './spend-ledger';
@@ -57,7 +58,15 @@ export const atGateCopy = (out: string, rev: number) => path.join(out, `plan-at-
/** What finishGate journals beside gate_written: the plan file's bytes and a copy of the reviewed plan the owner is asked about. */
export function gateEvidence(state: RunState, rev: number): { source_file_sha256?: string } {
if (fs.existsSync(state.active_plan)) fs.copyFileSync(state.active_plan, atGateCopy(state.out, rev));
return { source_file_sha256: fileSha(planFile(state)) };
return { source_file_sha256: keepPlanFile(state.out, planFile(state)) };
}
/** A copy of the plan file's bytes named by their hash, so a later re-gate can diff the edit against what the owner answered (K1). */
export const planFileCopy = (out: string, sha: string) => path.join(out, `plan-file.${sha.slice(0, 16)}.md`);
export function keepPlanFile(out: string, file: string): string | undefined {
const sha = fileSha(file);
if (sha) fs.copyFileSync(file, planFileCopy(out, sha));
return sha;
}
/** The lines the latest carry decision printed, for the command that wrote this gate revision. */
@@ -96,7 +105,9 @@ function deltaSince(state: RunState, rev: number): { diff: string; sha: string }
/** `p1`: an item-token answer's own p1 choice, replayed as given. */
interface Source { id: string; gate_rev: number; words: string; by?: string; p1?: string }
type Plan = { source: Source; doIt: GateItem[]; deltas: GateItem[] } | { reason: string; from?: number };
/** noStop: K1's autoplan-regate (lib/no-stop.ts): a plan edit after the owner's answer that only adds plain ELI5 lines (notEli5Only). */
interface NoStop { why: string }
type Plan = { source: Source; doIt: GateItem[]; deltas: GateItem[]; noStop?: NoStop } | { reason: string; from?: number };
/** Every condition but the delta check; returns why not, or what the carry takes. */
function assess(state: RunState, prev: GateList, list: GateList): Plan {
@@ -118,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 };
if (fileSha(planFile(state)) !== expected) return { reason: `the plan file ${path.basename(planFile(state))} changed after the owner's answer or the revise that took it`, from };
if (driftLine(state, 'PLAN_SOURCE_CHANGED')) return { reason: 'the reviewed plan differs from the plan file', 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` : 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[] = [];
@@ -132,7 +145,13 @@ function assess(state: RunState, prev: GateList, list: GateList): Plan {
if (!tokens && isDoIt(words) && item.kind === 'auto' && !item.cap && !option?.changes_plan && !['scope', 'cost', 'direction'].includes(item.changes ?? '')) { doIt.push(item); continue; }
return { reason: `new item ${item.id}: ${item.title}`, from };
}
return { source: { id: p1.id, gate_rev: from, words, by: p1.answered_by, ...(tokens ? { p1: p1.chosen } : {}) }, doIt, deltas };
const source = { id: p1.id, gate_rev: from, words, by: p1.answered_by, ...(tokens ? { p1: p1.chosen } : {}) };
if (!changed) return { source, doIt, deltas };
const before = planFileCopy(state.out, expected);
if (!fs.existsSync(before) || !fs.existsSync(planFile(state))) return { reason: changed, from };
const why = notEli5Only(lineDiff(fs.readFileSync(before, 'utf8'), fs.readFileSync(planFile(state), 'utf8')));
if (why) return { reason: `${changed}; ${why}`, from };
return { source, doIt, deltas, noStop: { why: `${changed}, adding only ELI5 lines (the reviewed plan is exported)` } };
}
/** Called by finishGate once gate_rev N+1 is written (status gate_pending): carry, set up the delta check, or say why not. */
@@ -143,7 +162,7 @@ export function evaluateCarry(state: RunState, prev: GateList | undefined, list:
const plan = assess(state, prev, list);
if ('reason' in plan) return refuse(state, list.gate_rev, plan.from, plan.reason);
if (plan.deltas.length) return dispatchCheck(state, prev, list, plan);
return carry(state, list, plan.source, plan.doIt, plan.deltas);
return carry(state, list, plan.source, plan.doIt, plan.deltas, undefined, plan.noStop);
} catch (e: any) {
if (readDecisions(state.out).some(r => r.gate_rev === list.gate_rev && r.answered_at && !r.carried_from)) throw e;
return refuse(state, list.gate_rev, prev.gate_rev, `error: ${e instanceof RunError ? `${e.detail} (${e.code})` : e?.message ?? e}`);
@@ -179,17 +198,20 @@ function dispatchCheck(state: RunState, prev: GateList, list: GateList, plan: Ex
`NEXT: dispatch one reviewer on the prompt, then gstack-autoplan check --out ${state.out} --attempt ${attempt} --result ${result} --model <id> (or --unavailable "<reason>" to relay the gate as today)`] });
}
function carry(state: RunState, list: GateList, source: Source, doIt: GateItem[], deltas: GateItem[], check?: CarryCheck): CarryRecord {
function carry(state: RunState, list: GateList, source: Source, doIt: GateItem[], deltas: GateItem[], check?: CarryCheck, noStop?: NoStop): CarryRecord {
const rev = list.gate_rev;
const p1 = list.items.find(i => i.id === 'p1')!;
const reply = list.items.map(i => `${i.id}${i.id === 'p1' ? source.p1 ?? p1.recommended : i.carried?.chosen ?? i.recommended}`).join(' ');
const reason = [doIt.length || deltas.length ? '' : 'no new choice', doIt.length ? `"do it" covers ${doIt.map(i => i.id).join(',')}` : '', deltas.length ? `delta check passed on ${deltas.map(i => i.id).join(',')}` : '',
'plan unchanged since the accepted changes'].filter(Boolean).join('; ');
const reason = [doIt.length || deltas.length || noStop ? '' : 'no new choice', doIt.length ? `"do it" covers ${doIt.map(i => i.id).join(',')}` : '', deltas.length ? `delta check passed on ${deltas.map(i => i.id).join(',')}` : '',
noStop ? `no-stop autoplan-regate: ${noStop.why}` : 'plan unchanged since the accepted changes'].filter(Boolean).join('; ');
const answered = answerGate(state, rev, reply, 'agent:gstack-autoplan', { carry: { from: source.id, gate_rev: source.gate_rev, words: source.words, by: source.by, reason } });
const marks = new Map<string, Record<string, string>>([...doIt.map(i => [decisionId(list.run, rev, i.id), { carried_under: 'do it' }] as const), ...deltas.map(i => [decisionId(list.run, rev, i.id), { carry_check: check?.attempt ?? '' }] as const)]);
if (marks.size) writeDecisions(state.out, readDecisions(state.out).map(r => (marks.has(r.id) ? { ...r, ...marks.get(r.id) } : r)));
const ledger = path.join(state.out, 'no-stop.json');
if (noStop) appendLedger(ledger, { class: 'autoplan-regate', reason: noStop.why, evidence: `autoplan:${list.run}@gate_rev=${rev}` });
const lines = [`CARRIED: gate_rev=${rev} <- gate_rev=${source.gate_rev} words=${JSON.stringify(source.words)} by=${source.by ?? 'unknown'} reason=${reason}`,
...doIt.map(i => `CARRIED_UNDER_DO_IT: ${i.id} ${i.title} -> ${i.options.find(o => o.key === i.recommended)?.text ?? i.recommended} (the owner has not seen this item)`),
...(noStop ? [`NO_STOP: autoplan-regate recorded (${noStop.why}) file=${ledger}`] : []),
...answered.lines];
return record(state, { gate_rev: rev, from_gate_rev: source.gate_rev, verdict: 'carried', reason, words: source.words, under_do_it: doIt.map(i => i.id), ...(check ? { check } : {}), lines });
}
@@ -246,7 +268,7 @@ export function submitCarryCheck(o: CheckOptions): { state: RunState; lines: str
const plan = assess(state, { ...list, gate_rev: c.from_gate_rev! } as GateList, list);
if ('reason' in plan) { out = refuse(state, c.gate_rev, c.from_gate_rev, plan.reason); out.check = done; }
else {
out = carry(state, list, plan.source, plan.doIt, plan.deltas, done);
out = carry(state, list, plan.source, plan.doIt, plan.deltas, done, plan.noStop);
out.lines.unshift(`CARRY_CHECK: passed attempt=${check.attempt} model=${o.model} findings=${done.findings} highest=${max}`);
}
}
+2 -1
View File
@@ -22,7 +22,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import { exportRun } from './autoplan-export';
import { applyRenames, approve, closeDraftDirections, finishGate, planReopen } from './autoplan-gate';
import { carryLines, voidPendingCheck } from './autoplan-carry';
import { carryLines, keepPlanFile, voidPendingCheck } from './autoplan-carry';
import { atCap, pendingDeltas, roundCap, roundOf } from './autoplan-budget';
import { diffStat, lineDiff } from './autoplan-confirm';
import { SETTLEABLE_CHANGES } from './autoplan-reconcile';
@@ -207,6 +207,7 @@ export function reviseRun(o: ReviseOptions): ReviseResult {
const committed = { event: 'plan_revised', k, plan: file, old_plan_sha256: oldHash, new_plan_sha256: newHash, source_file_sha256: createHash('sha256').update(fs.readFileSync(file)).digest('hex'), reopened: reopen, added, at_gate_rev: atGate ?? null };
fs.writeFileSync(path.join(staging, FILES.run), JSON.stringify({ state, renames, committed, settled }, null, 2) + '\n');
publish(state, staging, renames, committed, settled);
keepPlanFile(state.out, file);
const lines = [`REVISED: plan=${newHash.slice(0, 12)} reopened=${reopen.join(',') || 'none'}${added.length ? ` added=${added.join(',')}` : ''}${capped.length ? ` capped=${capped.join(',')}` : ''}`,
...(quiet ? [`APPLIED: ${incorporates.join(',')} without re-review (${state.lean ? 'lean run: one gate revision' : 'below High'}; plan +${delta.added} -${delta.removed} lines)`] : []), amendmentLine(state)];
for (const phase of capped) lines.push(`CAPPED: ${phase} used round ${roundOf(state, phase)} of ${roundCap(state, phase)}: not reopened; the gate asks the owner about the unreviewed delta (+${delta.added} -${delta.removed} lines)`);
+182
View File
@@ -0,0 +1,182 @@
/**
* 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;
}
+2 -2
View File
@@ -11,10 +11,10 @@ the `GSTACK_LAUNCH` assignments your installer printed (`. <its GSTACK_LAUNCH_FI
that runs the bins, reads, dispatches and waits, returning only result lines and reader
outputs verbatim. The model keeps only lead judgment, fixes, CHANGELOG, PR prose,
gates, the report. `STOP:` (exit 3) means nothing after it ran; `NEXT:` names
the next act. Read a section only when a phase or the list at the end sends you, never
the next act. Read a section only when a phase or the list below sends you, never
up front. A STOP or decision this file does not cover: read `ship/SKILL.md` at that step, finish there,
come back. Unattended, take a question's recommended option (log it); gate
dispositions and approvals stay pending and end the run. Keep a private invocation record (`--record`): write its six headings at Phase 1, fill each as its phase ends: **Checks**
dispositions and approvals stay pending and end the run, except `gstack-no-stop`. Keep a record (`--record`): write its six headings at Phase 1, fill each per phase: **Checks**
(Phases 2-4: lanes, pregate, evals, `Coverage:`, the plan checks, `Exploratory QA:`, `Scope Check:`), **Decisions** (any
phase: each answered or auto-taken question, verbatim), **Reviews** (Phases 3-5: the plan line,
start tokens, `Pre-Landing Review:`, `Gate edits:`, the reviewed tree), **Release** (Phases 5-7:
+2 -2
View File
@@ -9,10 +9,10 @@ the `GSTACK_LAUNCH` assignments your installer printed (`. <its GSTACK_LAUNCH_FI
that runs the bins, reads, dispatches and waits, returning only result lines and reader
outputs verbatim. The model keeps only lead judgment, fixes, CHANGELOG, PR prose,
gates, the report. `STOP:` (exit 3) means nothing after it ran; `NEXT:` names
the next act. Read a section only when a phase or the list at the end sends you, never
the next act. Read a section only when a phase or the list below sends you, never
up front. A STOP or decision this file does not cover: read `ship/SKILL.md` at that step, finish there,
come back. Unattended, take a question's recommended option (log it); gate
dispositions and approvals stay pending and end the run. Keep a private invocation record (`--record`): write its six headings at Phase 1, fill each as its phase ends: **Checks**
dispositions and approvals stay pending and end the run, except `gstack-no-stop`. Keep a record (`--record`): write its six headings at Phase 1, fill each per phase: **Checks**
(Phases 2-4: lanes, pregate, evals, `Coverage:`, the plan checks, `Exploratory QA:`, `Scope Check:`), **Decisions** (any
phase: each answered or auto-taken question, verbatim), **Reviews** (Phases 3-5: the plan line,
start tokens, `Pre-Landing Review:`, `Gate edits:`, the reviewed tree), **Release** (Phases 5-7:
+2 -1
View File
@@ -220,8 +220,9 @@ per-lane counts (`{"<lane>":{"pass":N,"fail":N,"skip":N}}`) to
```bash
rm -f .gstack/tmp/review-loop.json
NS=$(~/.claude/skills/gstack/bin/gstack-no-stop list | sed -n 's/^NO_STOP_FILE: //p'); [ -s "$NS" ] || NS=
~/.claude/skills/gstack/bin/gstack-review-scope summary --since "$REVIEW_SINCE" --no-ledger --out .gstack/tmp/review-loop.json &&
~/.claude/skills/gstack/bin/gstack-ship-receipt write --pr <pr-number> --base <base> --gate @.gstack/tmp/ship-gate.json --pregate .gstack/tmp/pregate.json --issues @.gstack/tmp/issues.json --review-loop @.gstack/tmp/review-loop.json --docs @.gstack/tmp/docs-audit.json $([ -s .gstack/tmp/targeted.json ] && echo --review-targeted @.gstack/tmp/targeted.json) --body "$PR_BODY_FILE"
~/.claude/skills/gstack/bin/gstack-ship-receipt write --pr <pr-number> --base <base> --gate @.gstack/tmp/ship-gate.json --pregate .gstack/tmp/pregate.json --issues @.gstack/tmp/issues.json --review-loop @.gstack/tmp/review-loop.json --docs @.gstack/tmp/docs-audit.json $([ -s .gstack/tmp/targeted.json ] && echo --review-targeted @.gstack/tmp/targeted.json) ${NS:+--no-stop "@$NS"} --body "$PR_BODY_FILE"
```
A Step 5 red-base pause adds `--base-red <pause file>` (a conditional receipt the queue
+2 -1
View File
@@ -203,8 +203,9 @@ per-lane counts (`{"<lane>":{"pass":N,"fail":N,"skip":N}}`) to
```bash
rm -f .gstack/tmp/review-loop.json
NS=$(~/.claude/skills/gstack/bin/gstack-no-stop list | sed -n 's/^NO_STOP_FILE: //p'); [ -s "$NS" ] || NS=
~/.claude/skills/gstack/bin/gstack-review-scope summary --since "$REVIEW_SINCE" --no-ledger --out .gstack/tmp/review-loop.json &&
~/.claude/skills/gstack/bin/gstack-ship-receipt write --pr <pr-number> --base <base> --gate @.gstack/tmp/ship-gate.json --pregate .gstack/tmp/pregate.json --issues @.gstack/tmp/issues.json --review-loop @.gstack/tmp/review-loop.json --docs @.gstack/tmp/docs-audit.json $([ -s .gstack/tmp/targeted.json ] && echo --review-targeted @.gstack/tmp/targeted.json) --body "$PR_BODY_FILE"
~/.claude/skills/gstack/bin/gstack-ship-receipt write --pr <pr-number> --base <base> --gate @.gstack/tmp/ship-gate.json --pregate .gstack/tmp/pregate.json --issues @.gstack/tmp/issues.json --review-loop @.gstack/tmp/review-loop.json --docs @.gstack/tmp/docs-audit.json $([ -s .gstack/tmp/targeted.json ] && echo --review-targeted @.gstack/tmp/targeted.json) ${NS:+--no-stop "@$NS"} --body "$PR_BODY_FILE"
```
A Step 5 red-base pause adds `--base-red <pause file>` (a conditional receipt the queue
+3 -1
View File
@@ -216,7 +216,9 @@ 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. `requires-remote` (other platforms; the remote gate under 8 cores) needs
`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
after the final stamp (`gstack-ship-release finish` does); Step 18 refuses a stale one.
+3 -1
View File
@@ -12,7 +12,9 @@ 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. `requires-remote` (other platforms; the remote gate under 8 cores) needs
`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
after the final stamp (`gstack-ship-release finish` does); Step 18 refuses a stale one.
+30 -3
View File
@@ -138,15 +138,42 @@ describe('carry-forward', () => {
expect(o.stdout).toContain('status=gate_pending');
});
test('an edit to the plan file after the revise (an ELI5-only line) refuses the carry', () => {
test('an edit to the plan file after the revise (an ELI5-only line) carries as a no-stop re-gate (K1) and is recorded', () => {
const edited = answeredAndRevised();
fs.appendFileSync(edited.v2, '\nAn ELI5 sentence added after the revise.\n');
fs.appendFileSync(edited.v2, '\n## ELI5\n\nAn ELI5 sentence added after the revise.\n');
phaseVia(r, edited.out, 'ceo', NONE);
const e = phaseVia(r, edited.out, 'eng', NONE).outside;
expect(e.stdout).toMatch(/^CARRY: no \(the plan file plan-v2\.md changed after the owner's answer or the revise that took it\)$/m);
expect(e.stdout).toMatch(/^CARRIED: gate_rev=2 <- gate_rev=1 .*reason=no-stop autoplan-regate: the plan file plan-v2\.md changed after the owner's answer or the revise that took it, adding only ELI5 lines \(the reviewed plan is exported\)$/m);
expect(e.stdout).toMatch(/^NO_STOP: autoplan-regate recorded /m);
expect(JSON.parse(fs.readFileSync(path.join(edited.out, 'no-stop.json'), 'utf8'))).toEqual([{ class: 'autoplan-regate', reason: expect.stringContaining('adding only ELI5 lines'), evidence: `autoplan:${edited.run}@gate_rev=2` }]);
expect(e.stdout).toMatch(/^APPROVED: gate_rev=2/m);
});
test('an edit after the revise that moves a cap still goes to the owner', () => {
const edited = answeredAndRevised();
fs.writeFileSync(edited.v2, V2.replace('cap 30 s', 'cap 90 s'));
phaseVia(r, edited.out, 'ceo', NONE);
const e = phaseVia(r, edited.out, 'eng', NONE).outside;
expect(e.stdout).toMatch(/^CARRY: no \(the plan file plan-v2\.md changed after the owner's answer or the revise that took it; it removes or rewrites a line: /m);
expect(fs.existsSync(path.join(edited.out, 'no-stop.json'))).toBe(false);
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);
+237
View File
@@ -0,0 +1,237 @@
/**
* The five no-stop defaults (GSTA-258 K1, lib/no-stop.ts, bin/gstack-no-stop): each routine stop that qualifies is
* recorded as {class, reason, evidence} and the run goes on; anything that does not qualify, and any stop outside
* the five, still stops for the owner. The autoplan re-gate is covered in test/autoplan-carry.test.ts.
*/
import { afterAll, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { bunIsolationArgs } from '../lib/bun-isolation';
import { validateReceipt } from '../lib/ship-receipt';
import { lineDiff } from '../lib/autoplan-confirm';
import { checkNoStop, notEli5Only, notStampOnly, parseDiff, type NoStopRecord } from '../lib/no-stop';
const ROOT = path.resolve(import.meta.dir, '..');
const BIN = path.join(ROOT, 'bin', 'gstack-no-stop');
const ID = { GIT_AUTHOR_NAME: 'fixture', GIT_AUTHOR_EMAIL: 'fixture@example.invalid', GIT_COMMITTER_NAME: 'fixture', GIT_COMMITTER_EMAIL: 'fixture@example.invalid' };
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'no-stop-'));
afterAll(() => fs.rmSync(tmp, { recursive: true, force: true }));
const diff = (file: string, was: string[], now: string[]) =>
[`diff --git a/${file} b/${file}`, `--- a/${file}`, `+++ b/${file}`, '@@ -1,3 +1,3 @@', ' context', ...was.map(l => '-' + l), ...now.map(l => '+' + l), ' context', ''].join('\n');
const TIMEOUT = diff('test/worker.test.ts', [" test('drains', async () => { await drain(); }, 5_000);"], [" test('drains', async () => { await drain(); }, 30_000);"]);
const check = (cls: string, d?: string, prior: NoStopRecord[] = []) => checkNoStop({ cls, reason: 'r', evidence: 'run 41715705702', prior, diff: d, root: ROOT });
describe('lib/no-stop: what qualifies', () => {
test('scoped-fix-check: a docs-only or timeout-only round-2 fix qualifies once; code, skill prose or a second one stops', () => {
expect(check('scoped-fix-check', diff('docs/review-policy-notes.md', ['old wording'], ['new wording']))).toMatchObject({ ok: true, detail: expect.stringContaining('docs-only') });
expect(check('scoped-fix-check', TIMEOUT)).toMatchObject({ ok: true, detail: expect.stringContaining('timeout-only') });
expect(check('scoped-fix-check', diff('lib/worker.ts', ['return a;'], ['return b;']))).toMatchObject({ ok: false, reason: expect.stringContaining('neither docs-only') });
expect(check('scoped-fix-check', diff('ship/SKILL.md.tmpl', ['old'], ['new']))).toMatchObject({ ok: false });
expect(check('scoped-fix-check', TIMEOUT, [{ class: 'scoped-fix-check', reason: 'x', evidence: 'y' }])).toMatchObject({ ok: false, reason: expect.stringContaining('a second goes to the owner') });
});
test('test-timeout-fix: only a raised timeout in a test file qualifies', () => {
expect(check('test-timeout-fix', TIMEOUT)).toMatchObject({ ok: true });
expect(check('test-timeout-fix', diff('test/worker.test.ts', [" test('drains', async () => { await drain(); }, 30_000);"], [" test('drains', async () => { await drain(); }, 5_000);"]))).toMatchObject({ ok: false, reason: expect.stringContaining('does not raise') });
expect(check('test-timeout-fix', diff('test/worker.test.ts', ['expect(n).toBe(3);'], ['expect(n).toBe(4);']))).toMatchObject({ ok: false, reason: expect.stringContaining('more than a timeout') });
expect(check('test-timeout-fix', diff('lib/worker.ts', ['const timeoutMs = 5000;'], ['const timeoutMs = 9000;']))).toMatchObject({ ok: false, reason: expect.stringContaining('not a test file') });
expect(check('test-timeout-fix', diff('test/worker.test.ts', [], [' // a new line, timeout 30_000']))).toMatchObject({ ok: false });
});
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 });
});
test('stamp-only-carry: a version-only VERSION or package.json move qualifies; a dependency or a product file stops', () => {
const stamp = diff('package.json', [' "version": "1.94.41.0",'], [' "version": "1.94.42.0",']) + diff('VERSION', ['1.94.41.0'], ['1.94.42.0']);
expect(check('stamp-only-carry', stamp)).toMatchObject({ ok: true, detail: 'version stamp only in package.json, VERSION' });
expect(notStampOnly(parseDiff(diff('package.json', [' "zod": "3.0.0",'], [' "zod": "4.0.0",'])))).toContain('changes more than the version');
expect(check('stamp-only-carry', diff('package.json', [' "dev": "bun run a",'], [' "dev": "bun run b",']))).toMatchObject({ ok: false, reason: expect.stringContaining('more than the version') });
expect(check('stamp-only-carry', stamp + diff('lib/worker.ts', ['a'], ['b']))).toMatchObject({ ok: false, reason: 'lib/worker.ts is not a stamp file' });
});
test('anything outside the five, a missing reason or evidence, or a hand-recorded re-gate still stops', () => {
expect(check('merge-anyway')).toMatchObject({ ok: false, reason: expect.stringContaining('not one of the five') });
expect(checkNoStop({ cls: 'pregate-flake-rerun', reason: ' ', evidence: 'run 1', prior: [] })).toMatchObject({ ok: false });
expect(check('autoplan-regate')).toMatchObject({ ok: false, reason: expect.stringContaining('gstack-autoplan decides') });
});
test('notEli5Only: only added lines inside ## ELI5 that name no number, spend, cap, arm, model or verdict carry', () => {
const PLAN = '# Plan\n\nRetry the webhook worker, cap 30 s.\n\n## ELI5\n\nRetries wait longer each time.\n';
const edit = (after: string) => notEli5Only(lineDiff(PLAN, after));
expect(edit(PLAN + 'They stop hammering the service.\n')).toBeNull();
expect(edit('# Plan\n\nRetry the webhook worker, cap 30 s.\n\n## ELI5\n\nRetries wait longer each time.\nNothing else changes.\n\n## Notes\n')).toContain('adds a heading');
expect(notEli5Only(lineDiff('# Plan\n\nDo it.\n', '# Plan\n\nDo it.\n\n## ELI5\n\nIt waits longer.\n'))).toBeNull();
expect(edit(PLAN.replace('Retry the webhook worker', 'Retry the webhook worker and the queue'))).toContain('removes or rewrites');
expect(edit(PLAN.replace('cap 30 s.\n', 'cap 30 s.\nAlso delete the users table.\n'))).toContain('outside ## ELI5');
expect(edit(PLAN + 'It runs (30 times) per case.\n')).toContain('names a number');
expect(edit(PLAN + 'Run it on **Opus** instead.\n')).toContain('names a number');
expect(edit(PLAN + 'The budget grows.\n')).toContain('names a number');
expect(edit(PLAN)).toBe('the edit adds no ELI5 line');
});
test('review pass 2 holes: a retry count, or a brace that closes any multi-line call, is not a timeout', () => {
expect(check('test-timeout-fix', diff('test/a.test.ts', [' retry({ fn }, 3);'], [' retry({ fn }, 10);']))).toMatchObject({ ok: false });
expect(check('test-timeout-fix', diff('test/a.test.ts', [' }, 3);'], [' }, 30);']))).toMatchObject({ ok: false });
});
});
describe('gstack-no-stop record: the ledger, the grant and the receipt', () => {
const work = path.join(tmp, 'repo');
const state = path.join(tmp, 'state');
const env = { ...process.env, ...ID, GSTACK_STATE_ROOT: state, GSTACK_APPROVALS_DIR: path.join(tmp, 'approvals') };
const git = (...args: string[]) => {
const r = spawnSync('git', args, { cwd: work, encoding: 'utf8', timeout: 30_000, env });
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
return r.stdout.trim();
};
const run = (...args: string[]) => spawnSync('bun', [...bunIsolationArgs(), BIN, ...args], { cwd: work, encoding: 'utf8', timeout: 60_000, env });
fs.mkdirSync(path.join(work, 'docs'), { recursive: true });
git('init', '-q', '-b', 'feat');
fs.writeFileSync(path.join(work, 'docs', 'guide.md'), 'old wording\n');
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);
expect(r.stdout).toMatch(/^GRANT: \S+$/m);
expect(r.stdout).toContain(`--range-base ${base}`);
const file = r.stdout.match(/^NO_STOP_FILE: (.+)$/m)![1]!;
const rows = JSON.parse(fs.readFileSync(file, 'utf8'));
expect(rows).toEqual([{ class: 'scoped-fix-check', reason: 'round-2 fix reworded docs/guide.md', evidence: expect.stringMatching(/^commit /) }]);
const approvals = spawnSync('bun', [...bunIsolationArgs(), path.join(ROOT, 'bin', 'gstack-approvals'), 'list', '--json'], { cwd: work, encoding: 'utf8', timeout: 60_000, env });
const grant = r.stdout.match(/^GRANT: (\S+)$/m)![1];
expect(JSON.parse(approvals.stdout).rows ?? JSON.parse(approvals.stdout)).toEqual(expect.arrayContaining([expect.objectContaining({ id: grant, outcome: 'approved', by: 'gstack no-stop default (K1)', scope: `review_loop_grant ${git('rev-parse', 'HEAD')}` })]));
const receipt = { schema_version: 1, pr: 1, head: 'e'.repeat(40), version: '1.0.0.0', base: 'main', gate: {}, no_stop: rows };
expect(validateReceipt(receipt as any).filter(e => /no_stop/.test(String(e)))).toEqual([]);
});
test('a second scoped fix-check and a code fix both stop for the owner (exit 3) and leave the ledger alone', () => {
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 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');
expect(code.status).toBe(3);
expect(code.stderr).toContain('NO_STOP: refused test-timeout-fix (not a test file: lib.ts)');
expect(run('list', '--json').stdout).toBe(before.stdout);
});
test('a checkout with an empty ledger still sees the branch\'s K1 grant in the approval record and stops', () => {
const file = JSON.parse(run('list', '--json').stdout).file as string;
const saved = fs.readFileSync(file, 'utf8');
fs.rmSync(file);
const r = run('record', '--class', 'scoped-fix-check', '--range-base', base, '--reason', 'again', '--evidence', 'commit x');
fs.writeFileSync(file, saved);
expect(r.status).toBe(3);
expect(r.stderr).toContain('the approval record already holds this branch\'s K1 grant');
});
test('a class outside the five stops, and the range-based classes need --range-base', () => {
expect(run('record', '--class', 'merge-anyway', '--reason', 'r', '--evidence', 'e').status).toBe(3);
expect(run('record', '--class', 'stamp-only-carry', '--reason', 'r', '--evidence', 'e').status).toBe(2);
});
});
describe('gstack-no-stop record --class stamp-only-carry --base, and import', () => {
const work = path.join(tmp, 'stamp');
const env = { ...process.env, ...ID, GSTACK_STATE_ROOT: path.join(tmp, 'state2'), GSTACK_APPROVALS_DIR: path.join(tmp, 'approvals2') };
const git = (...args: string[]) => {
const r = spawnSync('git', args, { cwd: work, encoding: 'utf8', timeout: 30_000, env });
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
return r.stdout.trim();
};
const write = (rel: string, text: string) => fs.writeFileSync(path.join(work, rel), text);
const run = (...args: string[]) => spawnSync('bun', [...bunIsolationArgs(), BIN, ...args], { cwd: work, encoding: 'utf8', timeout: 60_000, env });
fs.mkdirSync(work, { recursive: true });
git('init', '-q', '-b', 'main');
write('VERSION', '1.0.0.0\n'); write('app.ts', 'export const a = 1;\n'); write('other.ts', 'export const b = 1;\n');
git('add', '-A'); git('commit', '-qm', 'base');
git('checkout', '-q', '-b', 'feat');
write('app.ts', 'export const a = 2;\n'); write('VERSION', '1.0.1.0\n'); git('commit', '-qam', 'feature');
const receiptHead = git('rev-parse', 'HEAD');
git('checkout', '-q', 'main');
write('other.ts', 'export const b = 2;\n'); write('VERSION', '1.0.2.0\n'); git('commit', '-qam', 'main moves');
git('checkout', '-q', 'feat');
spawnSync('git', ['merge', '-q', 'main', '-m', 'merge main'], { cwd: work, encoding: 'utf8', timeout: 30_000, env });
write('VERSION', '1.0.3.0\n'); git('add', '-A'); git('commit', '-qm', 'merge main + stamp');
test('a base merge plus a version stamp qualifies against the receipt head merged into the base', () => {
const r = run('record', '--class', 'stamp-only-carry', '--range-base', receiptHead, '--base', 'main', '--reason', 'restamp after the base moved', '--evidence', `commit ${git('rev-parse', 'HEAD').slice(0, 12)}`);
expect(r.status, r.stderr).toBe(0);
expect(r.stdout).toMatch(/^NO_STOP: stamp-only-carry recorded \(version stamp only in VERSION\)$/m);
const plain = run('record', '--class', 'stamp-only-carry', '--range-base', receiptHead, '--reason', 'r', '--evidence', 'e');
expect(plain.status).toBe(3);
expect(plain.stderr).toContain('other.ts is not a stamp file');
});
test('a product edit inside the stamp commit still stops', () => {
write('app.ts', 'export const a = 3;\n'); write('VERSION', '1.0.4.0\n'); git('commit', '-qam', 'stamp plus a sneaky edit');
const r = run('record', '--class', 'stamp-only-carry', '--range-base', receiptHead, '--base', 'main', '--reason', 'r', '--evidence', 'e');
expect(r.status).toBe(3);
expect(r.stderr).toContain('app.ts is not a stamp file');
});
test('a rename into changelog.d with an edit is a deletion plus an add, so it still stops', () => {
write('notes.md', 'one\ntwo\nthree\nfour\nfive\nsix\n'); git('add', '-A'); git('commit', '-qm', 'notes');
const from = git('rev-parse', 'HEAD');
fs.mkdirSync(path.join(work, 'changelog.d'), { recursive: true });
git('mv', 'notes.md', 'changelog.d/notes.md'); write('changelog.d/notes.md', 'one\ntwo\nthree\nfour\nfive\nseven\n'); git('add', '-A'); git('commit', '-qm', 'move');
const r = run('record', '--class', 'stamp-only-carry', '--range-base', from, '--reason', 'r', '--evidence', 'e');
expect(r.status).toBe(3);
expect(r.stderr).toContain('notes.md is not a stamp file');
});
test('import adds an autoplan run\'s re-gate records once and skips every other class', () => {
const src = path.join(tmp, 'run-no-stop.json');
fs.writeFileSync(src, JSON.stringify([{ class: 'autoplan-regate', reason: 'plan edit, no spend', evidence: 'autoplan:run-1@gate_rev=2' }, { class: 'merge-anyway', reason: 'x', evidence: 'y' }, { class: 'stamp-only-carry', reason: 'x', evidence: 'y' }, { class: 'autoplan-regate', reason: 'x', evidence: 'hand-written' }]));
const first = run('import', '--from', src).stdout;
expect(first).toMatch(/^NO_STOP: imported 1 record\(s\)/m);
expect(first).toMatch(/^NO_STOP: skipped 3 record\(s\): import takes only autoplan-regate records/m);
expect(run('import', '--from', src).stdout).toMatch(/^NO_STOP: imported 0 record\(s\)/m);
expect(JSON.parse(run('list', '--json').stdout).records.map((r: NoStopRecord) => r.class)).toEqual(['stamp-only-carry', 'autoplan-regate']);
});
});
describe('the no-stop defaults in the /ship and /autoplan sources', () => {
const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
const ship = read('ship/sections/tests.md.tmpl');
const help = spawnSync('bun', [...bunIsolationArgs(), BIN, '--help'], { encoding: 'utf8', timeout: 60_000 }).stdout;
test.each(['scoped-fix-check', 'test-timeout-fix', 'pregate-flake-rerun', 'stamp-only-carry', 'autoplan-regate'])('the bin help and the policy say what to do on %s', cls => {
expect(help).toMatch(new RegExp('^ ' + cls + ' +\\S', 'm'));
expect(read('docs/review-policy.md')).toContain('| `' + cls + '` |');
});
test('a refused case and any other stop still stop; the receipt write carries the ledger', () => {
expect(ship).toContain('three other routine stops go through `gstack-no-stop record` (`--help`); `recorded` goes on,\n`refused` stops.');
expect(help).toContain('NO_STOP: refused (exit 3): the stop stands. Anything else still stops.');
expect(read('ship/sections/pr-body.md.tmpl')).toContain('${NS:+--no-stop "@$NS"}');
expect(read('ship/DRIVER.md.tmpl')).toContain('end the run, except `gstack-no-stop`');
expect(read('autoplan/DRIVER.md.tmpl')).toContain("K1's `autoplan-regate` no-stop");
});
});
@@ -0,0 +1,4 @@
{
"bytes": 349,
"why": "GSTA-258 L5 (K1): the no-stop pointer in Step 5, the --no-stop receipt flag in Step 19's write (NS set on its own line so a path with spaces survives), and the driver's gstack-no-stop exception"
}