From 627b739a175dd6fc8ef18df1611a3b15fabea803 Mon Sep 17 00:00:00 2001 From: garrytan Date: Tue, 3 Nov 2026 23:35:57 +0000 Subject: [PATCH] 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 /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> --- autoplan/DRIVER.md | 2 +- autoplan/DRIVER.md.tmpl | 2 +- bin/gstack-no-stop | 126 ++++++++++++ changelog.d/gsta258-l5-no-stop.md | 18 ++ docs/review-policy.md | 21 ++ docs/ship-policy.md | 9 +- docs/unattended.md | 6 +- lib/autoplan-carry.ts | 40 +++- lib/autoplan-revise.ts | 3 +- lib/no-stop.ts | 177 ++++++++++++++++ ship/DRIVER.md | 4 +- ship/DRIVER.md.tmpl | 4 +- ship/sections/pr-body.md | 2 +- ship/sections/pr-body.md.tmpl | 2 +- ship/sections/tests.md | 4 +- ship/sections/tests.md.tmpl | 4 +- test/autoplan-carry.test.ts | 16 +- test/no-stop.test.ts | 189 ++++++++++++++++++ test/size-ledger/ship/gsta258-l5-no-stop.json | 4 + 19 files changed, 606 insertions(+), 27 deletions(-) create mode 100755 bin/gstack-no-stop create mode 100644 changelog.d/gsta258-l5-no-stop.md create mode 100644 lib/no-stop.ts create mode 100644 test/no-stop.test.ts create mode 100644 test/size-ledger/ship/gsta258-l5-no-stop.json diff --git a/autoplan/DRIVER.md b/autoplan/DRIVER.md index a2aef2c50..e827f4dd6 100644 --- a/autoplan/DRIVER.md +++ b/autoplan/DRIVER.md @@ -38,7 +38,7 @@ line yourself; gstack never writes the plan. **Kinds (R65), first `next` only.** 5. `REOPENED: ` means loop `next` again (Eng last). `APPROVED:` means `export` (`DISPATCHES: est_input_tokens=`, 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: 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 ()` leaves it for the owner; `CARRY_CHECK:` means one reviewer from another model family reads the printed delta prompt, then `check --out --attempt --result --model ` (or `--unavailable ""`). +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 /no-stop.json`); `CARRY: no ()` leaves it for the owner; `CARRY_CHECK:` means one reviewer from another model family reads the printed delta prompt, then `check --out --attempt --result --model ` (or `--unavailable ""`). ### On Capy diff --git a/autoplan/DRIVER.md.tmpl b/autoplan/DRIVER.md.tmpl index 2831814f7..dd86218fc 100644 --- a/autoplan/DRIVER.md.tmpl +++ b/autoplan/DRIVER.md.tmpl @@ -35,7 +35,7 @@ line yourself; gstack never writes the plan. **Kinds (R65), first `next` only.** 5. `REOPENED: ` means loop `next` again (Eng last). `APPROVED:` means `export` (`DISPATCHES: est_input_tokens=`, 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: 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 ()` leaves it for the owner; `CARRY_CHECK:` means one reviewer from another model family reads the printed delta prompt, then `check --out --attempt --result --model ` (or `--unavailable ""`). +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 /no-stop.json`); `CARRY: no ()` leaves it for the owner; `CARRY_CHECK:` means one reviewer from another model family reads the printed delta prompt, then `check --out --attempt --result --model ` (or `--unavailable ""`). ### On Capy diff --git a/bin/gstack-no-stop b/bin/gstack-no-stop new file mode 100755 index 000000000..6d6dc4a94 --- /dev/null +++ b/bin/gstack-no-stop @@ -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 --reason --evidence [--range-base ] [--json] + * gstack-no-stop import --from /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 @`). A scoped-fix-check also records the standing grant in + * the approval record and prints GRANT: 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 --reason --evidence [--range-base ] [--json] + NO_STOP: 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/): take the carry's RECHECK: route instead of Steps 2-16 + autoplan-regate gstack-autoplan records it; import --from /no-stop.json adds it here + Step 19's receipt write adds --no-stop @ 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 (stamp-only-carry): compare HEAD with the receipt head merged into (base side on conflict), + so a base merge plus a stamp qualifies and a product conflict resolution does not + import --from [--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 \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 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))); diff --git a/changelog.d/gsta258-l5-no-stop.md b/changelog.d/gsta258-l5-no-stop.md new file mode 100644 index 000000000..bb34c1745 --- /dev/null +++ b/changelog.d/gsta258-l5-no-stop.md @@ -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 --reason --evidence `** (`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 @`. + +### 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 `/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. diff --git a/docs/review-policy.md b/docs/review-policy.md index 98ef7c7c5..eebf7a50c 100644 --- a/docs/review-policy.md +++ b/docs/review-policy.md @@ -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. + +### 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 `/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 @`. + ## 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`). diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 966f8e6d8..36e140983 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -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 `: 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 `: `{ accepted_by, date, via, reason, fix_check: { head, findings[, files] } }`. diff --git a/docs/unattended.md b/docs/unattended.md index 05bf1b13d..4d1fead17 100644 --- a/docs/unattended.md +++ b/docs/unattended.md @@ -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 +`/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 ()` (journal `carry_refused`) and the gate goes to the owner as before. `--no-carry-forward` on the first `next` turns it diff --git a/lib/autoplan-carry.ts b/lib/autoplan-carry.ts index b5f2ab5bd..d28a38b4d 100644 --- a/lib/autoplan-carry.ts +++ b/lib/autoplan-carry.ts @@ -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 (or --unavailable "" 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>([...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}`); } } diff --git a/lib/autoplan-revise.ts b/lib/autoplan-revise.ts index 0fd5a10a7..05be2e50b 100644 --- a/lib/autoplan-revise.ts +++ b/lib/autoplan-revise.ts @@ -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)`); diff --git a/lib/no-stop.ts b/lib/no-stop.ts new file mode 100644 index 000000000..4e8f95244 --- /dev/null +++ b/lib/no-stop.ts @@ -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 @` 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(); + 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>; + 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; +} diff --git a/ship/DRIVER.md b/ship/DRIVER.md index 7c17569c3..75d7b1b3b 100644 --- a/ship/DRIVER.md +++ b/ship/DRIVER.md @@ -11,10 +11,10 @@ the `GSTACK_LAUNCH` assignments your installer printed (`. ":{"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 --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 --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 ` (a conditional receipt the queue diff --git a/ship/sections/pr-body.md.tmpl b/ship/sections/pr-body.md.tmpl index 8bd8c3188..64680ed82 100644 --- a/ship/sections/pr-body.md.tmpl +++ b/ship/sections/pr-body.md.tmpl @@ -204,7 +204,7 @@ per-lane counts (`{"":{"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 --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 --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 ` (a conditional receipt the queue diff --git a/ship/sections/tests.md b/ship/sections/tests.md index 55262a6f2..d2443a6b0 100644 --- a/ship/sections/tests.md +++ b/ship/sections/tests.md @@ -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: