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>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-11-03 23:35:57 +00:00
1 parent 2a368135de
commit 627b739a17
19 files changed
+606 -27

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 changes no spend, cap, arm or verdict 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 changes no spend, cap, arm or verdict 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
+126
View File
@@ -0,0 +1,126 @@
#!/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 { appendRows, buildRow, locate } from '../lib/approval-record';
import { EXIT, NO_STOP_CLASSES, exitTableHelp } from '../lib/headless-artifacts';
import { appendLedger, checkNoStop, ledgerPath, readLedger } from '../lib/no-stop';
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,
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'];
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: 'gstack no-stop default (K1)', 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 rangeBase = flag(args, '--range-base');
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; }
let diff: string | undefined;
if (rangeBase) {
const base = cls === 'stamp-only-carry' ? flag(args, '--base') : undefined;
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', 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 fresh = readLedger(src).filter(r => (NO_STOP_CLASSES as readonly string[]).includes(r.class) && r.reason && r.evidence && !have.some(h => JSON.stringify(h) === JSON.stringify(r)));
for (const r of fresh) appendLedger(file, r);
process.stdout.write(json ? JSON.stringify({ file, imported: fresh.length }) + '\n' : `NO_STOP: imported ${fresh.length} record(s) from ${src}\nNO_STOP_FILE: ${file}\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. It records the standing grant in the approval record and prints `GRANT:` for `gstack-review-plan --grant`.
- `test-timeout-fix`: a fix whose every changed line is in a test file and only raises a timeout. Its gate finding takes a printed `kept` reason instead of `open`.
- `pregate-flake-rerun`: one rerun of a failed pregate case at the same tree (rule 16).
- `stamp-only-carry`: a refused carry where HEAD differs from the receipt head merged into the base only in a version stamp. /ship then takes the carry's `RECHECK:` route instead of running Steps 2–16 again.
- `autoplan-regate`: see below.
- **`gstack-no-stop import` and `list`** add an autoplan run's records to the branch and print the ledger. /ship's receipt write passes it as `--no-stop @<file>`.
### Changed
- **An autoplan plan edit made after the owner's answer carries their words when it changes no spend, cap, arm or verdict.** A word only one side of the edit has counts as changed. So does a number on a line that names spend, a cap, an arm or a verdict. An edit with neither carries as `reason=no-stop autoplan-regate: …` and is written to `<run>/no-stop.json`. Moving "cap 30 s" to "cap 90 s" still goes to the owner. The run keeps a copy of each plan file it hashes, so the edit is diffed against what the owner answered.
- 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 edit 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. One per run. | It records the standing grant in the approval record (`by: gstack no-stop default (K1)`) and prints `GRANT:`. `gstack-review-plan --grant` plans the one scoped fix-check. A blocking finding there goes to the owner. |
| `test-timeout-fix` | Every changed line is in a test file and only raises a timeout: a `timeout` key or bun's and jest's trailing `}, 30_000);` argument. | The gate scan's relaxation finding takes the printed `GATE_DISPOSITION: kept` reason instead of `open`. |
| `pregate-flake-rerun` | One rerun of a failed pregate case at the same tree (CREW rule 16). One per run. | Green counts as flaky. Red again is a real failure. |
| `stamp-only-carry` | A refused carry, where the receipt head merged into the base (base side on conflict) differs from HEAD only in a version stamp: `VERSION`, the `"version"` line of `package.json`, `CHANGELOG.md`, the agents digest or `changelog.d/` fragments. | Take the carry's `RECHECK:` route instead of running Steps 2–16 again. |
| `autoplan-regate` | A plan edit made after the owner's answer, or after the revise that took their changes. The edit's changed words name no spend, cost, budget, cap, credits, arm, verdict, threshold, bar, trial, sample or stop rule, no dollar figure, and no number on a line that names one. | `gstack-autoplan` carries the owner's words (`reason=no-stop autoplan-regate: …`) and writes `<run>/no-stop.json`. `gstack-no-stop import` adds it to the branch's ledger. |
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
@@ -782,3 +802,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
@@ -1149,8 +1149,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.
@@ -1210,6 +1210,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 changes no spend, cap, arm or
verdict (K1's `autoplan-regate` no-stop: the reason says so, `NO_STOP:` names
`<run>/no-stop.json`, and an edit whose changed words name spend, a cap, an
arm, a verdict or a number beside one 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
+30 -10
View File
@@ -31,6 +31,7 @@ import { FILES, RunError, journal, now, readDecisions, readJournal, readState, w
import { appendTrailRow } from './decision-trail';
import { decisionId, parseReply, type GateItem, type GateList } from './gate-list';
import { readJsonl } from './headless-artifacts';
import { appendLedger, regateTouches } 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 changes no spend, cap, arm or verdict. */
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 {
@@ -120,8 +131,8 @@ function assess(state: RunState, prev: GateList, list: GateList): Plan {
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;
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`
: driftLine(state, 'PLAN_SOURCE_CHANGED') ? 'the reviewed plan differs from the plan file' : undefined;
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 +143,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 touched = regateTouches(lineDiff(fs.readFileSync(before, 'utf8'), fs.readFileSync(planFile(state), 'utf8')).filter(l => l.op !== ' ').map(l => `${l.op} ${l.text}`).join('\n'));
if (touched.length) return { reason: `${changed}; it changes spend, a cap, an arm or a verdict (${touched[0]!.slice(2, 82)})`, from };
return { source, doIt, deltas, noStop: { why: `${changed}, with no spend, cap, arm or verdict change` } };
}
/** 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 +160,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 +196,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 +266,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)`);
+177
View File
@@ -0,0 +1,177 @@
/**
* 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 ledger.
* - test-timeout-fix: a fix whose every changed line, all in test files, only raises a timeout value.
* - pregate-flake-rerun: one rerun of a failed pregate case at the same tree (CREW rule 16). One per ledger.
* - stamp-only-carry: a refused receipt carry whose changes since the receipt head are version stamps only.
* - autoplan-regate: a later autoplan gate revision whose plan delta names no spend, cap, arm or verdict
* (lib/autoplan-carry.ts asks regateTouches before it carries).
*
* 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 }
const NUMBER = /\d[\d_]*(?:\.\d+)?/g;
/** A timeout key, or bun's and jest's trailing per-test timeout argument (`}, 30_000);`). */
const TIMEOUT_KEY = /timeout|\}\s*,\s*\d[\d_]*\s*\)\s*;?\s*$/i;
const VERSION = /\d+\.\d+\.\d+(?:\.\d+)?/g;
const TEST_PATH = /(?:^|\/)(?:test|tests|__tests__|spec)\/|\.(?:test|spec)\.[cm]?[jt]sx?$/;
const STAMP_PATHS = ['VERSION', 'package.json', 'CHANGELOG.md', 'agents-digest/gstack-AGENTS.md'];
/** Plan-delta words that mean spend, a cap, an eval arm or a verdict changed: those re-gates go to the owner. */
export const REGATE_WORDS = /\$|\b(?:spend|spent|cost|costs|budget|price|cap|caps|capped|credits?|arms?|verdicts?|thresholds?|bars?|pass(?:es)? at|trials?|samples?|stop rules?|kill rule)\b|\bn\s*=/i;
/** 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 (line.startsWith('+++ ')) { if (line !== '+++ /dev/null') cur.path = line.slice(4).replace(/^b\//, ''); }
else if (line.startsWith('--- ')) continue;
else if (/^(?:new|deleted) file mode|^rename (?:from|to) |^Binary files /.test(line)) cur.whole = true;
else if (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 numbers = (s: string) => (s.match(NUMBER) ?? []).map(n => Number(n.replace(/_/g, '')));
/** Why the diff is not timeout-only, or null: every changed line pairs with one that differs only in larger numbers on a timeout key. */
export function notTimeoutOnly(files: FileDiff[]): string | null {
if (!files.length) return 'the fix changes nothing';
for (const f of files) {
if (f.whole || f.added.length || f.removed.length || !f.pairs.length) return `${f.path} adds, removes or replaces whole lines`;
for (const [was, now] of f.pairs) {
if (was.replace(NUMBER, '#') !== now.replace(NUMBER, '#') || !TIMEOUT_KEY.test(now)) return `${f.path} changes more than a timeout value: ${now.trim().slice(0, 80)}`;
const a = numbers(was); const b = numbers(now);
if (b.some((n, i) => n < a[i]!) || b.every((n, i) => n === a[i])) return `${f.path} does not raise a timeout: ${now.trim().slice(0, 80)}`;
}
}
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 QUANTITY = /^[$~]?-?\d[\d_,.]*(?:%|x|[a-z]{1,3})?$/i;
const tokens = (line: string) => line.split(/\s+/).map(t => t.replace(/[,.;:!?)]+$/, '')).filter(Boolean);
/**
* The plan-delta lines (lineDiff's "+ " and "- " rows) whose changed words name spend, a cap, an arm or a verdict:
* a word that only one side has is changed, and a changed number counts when its line names one of those. A word
* both sides keep (a reworded line that still says "cap 30 s") changes nothing. An autoplan re-gate with none is a no-stop.
*/
export function regateTouches(delta: string): string[] {
const lines = delta.split('\n').filter(l => /^[+-] /.test(l));
const bag = (sign: string) => {
const m = new Map<string, number>();
for (const l of lines) if (l[0] === sign) for (const t of tokens(l.slice(2))) m.set(t, (m.get(t) ?? 0) + 1);
return m;
};
const other = { '+': bag('-'), '-': bag('+') } as Record<string, Map<string, number>>;
return lines.filter(l => {
const keep = other[l[0]!]!;
return tokens(l.slice(2)).some(t => {
const n = keep.get(t) ?? 0;
if (n > 0) { keep.set(t, n - 1); return false; }
return REGATE_WORDS.test(t) || (QUANTITY.test(t) && REGATE_WORDS.test(l.slice(2)));
});
}).map(l => l.slice(0, 100));
}
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 run 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, printed 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 it
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 private invocation record (`--record`): write its six headings at Phase 1, fill each as its phase ends: **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, printed 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 it
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 private invocation record (`--record`): write its six headings at Phase 1, fill each as its phase ends: **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:
+1 -1
View File
@@ -221,7 +221,7 @@ per-lane counts (`{"<lane>":{"pass":N,"fail":N,"skip":N}}`) to
```bash
rm -f .gstack/tmp/review-loop.json
~/.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=$(~/.claude/skills/gstack/bin/gstack-no-stop list | sed -n 's/^NO_STOP_FILE: //p'); [ -s "$NS" ] && echo --no-stop @"$NS") --body "$PR_BODY_FILE"
```
A Step 5 red-base pause adds `--base-red <pause file>` (a conditional receipt the queue
+1 -1
View File
@@ -204,7 +204,7 @@ per-lane counts (`{"<lane>":{"pass":N,"fail":N,"skip":N}}`) to
```bash
rm -f .gstack/tmp/review-loop.json
~/.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=$(~/.claude/skills/gstack/bin/gstack-no-stop list | sed -n 's/^NO_STOP_FILE: //p'); [ -s "$NS" ] && echo --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, 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, 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.
+14 -2
View File
@@ -138,12 +138,24 @@ 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');
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, with no spend, cap, arm or verdict change$/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('with no spend, cap, arm or verdict change'), 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 changes spend, a cap, an arm or a verdict \(/m);
expect(fs.existsSync(path.join(edited.out, 'no-stop.json'))).toBe(false);
expect(readRunJson(edited.out).status).toBe('gate_pending');
});
+189
View File
@@ -0,0 +1,189 @@
/**
* 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 { checkNoStop, notStampOnly, parseDiff, regateTouches, 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', ['}, 30_000);'], ['}, 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('pregate-flake-rerun: one rerun per run; 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('regateTouches: a changed number next to a cap, a new arm or a dollar figure counts; kept words and other edits do not', () => {
expect(regateTouches('- base 500 ms, cap 30 s\n+ base 500 ms, cap 30 s, behind RETRY_BACKOFF=1')).toEqual([]);
expect(regateTouches('+ An ELI5 sentence added after the revise.')).toEqual([]);
expect(regateTouches('- cap 30 s\n+ cap 90 s')).toHaveLength(2);
expect(regateTouches('+ Add a no-gstack arm to the panel.')).toHaveLength(1);
expect(regateTouches('+ Budget up to $40 for the pilot.')).toHaveLength(1);
expect(regateTouches('+ Runs on capy-host machines.')).toEqual([]);
});
});
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');
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 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 run 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 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('import adds an autoplan run\'s records once and skips anything outside the five', () => {
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' }]));
expect(run('import', '--from', src).stdout).toMatch(/^NO_STOP: imported 1 record\(s\)/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('echo --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": 329,
"why": "GSTA-258 L5 (K1): the no-stop pointer in Step 5, the --no-stop receipt flag in Step 19's write, and the driver's gstack-no-stop exception"
}