From c07eddb5afb0930434bd2fcbc1f83a9c75041086 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 3 Nov 2026 19:03:39 -0800 Subject: [PATCH] feat(ship,autoplan): five routine stops record a no_stop and keep going (GSTA-258 L5, K1) (#3976) * feat(ship,autoplan): five routine stops record a no_stop and keep going (GSTA-258 L5, K1) gstack-no-stop record checks scoped-fix-check, test-timeout-fix, pregate-flake-rerun and stamp-only-carry against lib/no-stop.ts and appends {class, reason, evidence} to the branch ledger the receipt write passes as --no-stop. A scoped fix-check records the standing grant for gstack-review-plan --grant. gstack-autoplan carries a plan edit after the owner answered when its changed words name no spend, cap, arm or verdict, and writes /no-stop.json. Anything that does not qualify, and every other stop, still stops. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(no-stop): review pass 1: drift stays refused, timeout values only, range-base bound to the last pass, word-by-word re-gate, hunk-aware diff parse Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Review pass 2 (below floor): import takes only autoplan-regate records with autoplan:@gate_rev=N evidence; record diffs with --no-renames so a rename is a delete plus an add; a scoped-fix-check also checks the approval record for the branch's K1 grant, so a fresh checkout with an empty ledger still stops; a trailing }, N) counts as a timeout only where it closes a test callback; the re-gate words add paid, eval and model names; Step 19 sets NS on its own line so a path with spaces survives. Also launches gstack-review-read through runScriptSync (free-tests #3197 tripwire, run 41746166882). Review-Finding: specialist-batched#1 reader=specialist-batched Review-Finding: specialist-batched#3 reader=specialist-batched Review-Finding: specialist-batched#4 reader=specialist-batched Review-Finding: red-team#1 reader=red-team Review-Finding: red-team#2 reader=red-team Review-Finding: red-team#3 reader=red-team Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: autoplan-regate carries only ELI5 additions; timeout and ref checks fail closed Step 11 voices (round 2): the autoplan-regate word filter failed open on markup, spelled-out counts and reworded verdicts. A plan-file edit now carries only when it adds lines inside ## ELI5 that hold no number and no spend, cap, arm, model or verdict word; any removed or reworded line goes to the owner, and the carry still exports the reviewed plan. A trailing }, N) counts as a timeout only on a line that opens the test call. --range-base and --base resolve to commits with --end-of-options before git sees them. Review-Finding: targeted#1 reader=targeted Review-Finding: outside-voice#1 reader=outside-voice Review-Finding: outside-voice#2 reader=outside-voice Review-Finding: outside-voice#3 reader=outside-voice Review-Finding: adversarial-native#1 reader=adversarial-native Review-Finding: adversarial-native#6 reader=adversarial-native Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: test-timeout-fix counts a trailing timeout only on the line that opens the test call Step 14.5 docs audit l5-docs-1. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): keep ship/DRIVER.md under its 12 KB cap after the main merge Main brought the driver to exactly 12288 bytes, so the K1 'except gstack-no-stop' clause put it 20 bytes over (quick-bun-default run on ca43af4). Trim two phrases; the pinned fallback sentence is unchanged. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- autoplan/DRIVER.md | 2 +- autoplan/DRIVER.md.tmpl | 2 +- bin/gstack-no-stop | 156 ++++++++++++ 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 | 46 +++- lib/autoplan-revise.ts | 3 +- lib/no-stop.ts | 182 ++++++++++++++ ship/DRIVER.md | 4 +- ship/DRIVER.md.tmpl | 4 +- ship/sections/pr-body.md | 3 +- ship/sections/pr-body.md.tmpl | 3 +- ship/sections/tests.md | 4 +- ship/sections/tests.md.tmpl | 4 +- test/autoplan-carry.test.ts | 33 ++- test/no-stop.test.ts | 237 ++++++++++++++++++ test/size-ledger/ship/gsta258-l5-no-stop.json | 4 + 19 files changed, 711 insertions(+), 30 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 47d27398d..5f99bf75f 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 only adds plain ELI5 lines 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 a5e67ebe2..65b063d39 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 only adds plain ELI5 lines 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..9477de839 --- /dev/null +++ b/bin/gstack-no-stop @@ -0,0 +1,156 @@ +#!/usr/bin/env -S bun --no-env-file --config=/dev/null +/** + * gstack-no-stop — record one of the five no-stop defaults and go on (GSTA-258 K1, docs/review-policy.md#no-stop). + * + * gstack-no-stop record --class --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 * as path from 'node:path'; +import { appendRows, buildRow, locate, readRecord } from '../lib/approval-record'; +import { EXIT, exitTableHelp } from '../lib/headless-artifacts'; +import { appendLedger, checkNoStop, ledgerPath, readLedger } from '../lib/no-stop'; +import { runScriptSync } from '../lib/script-invocation'; + +const HELP = `gstack-no-stop — record one of the five no-stop defaults (docs/review-policy.md#no-stop) + + record --class --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, which must be + the newest review-log pass row's commit, + the commit before the fix for test-timeout-fix, the receipt head for stamp-only-carry); required for those three + --base (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']; +const K1_BY = 'gstack no-stop default (K1)'; +const AUTOPLAN_EVIDENCE = /^autoplan:\S+@gate_rev=\d+$/; + +/** The commit the newest full or scoped pass reviewed (its review-log row's commit_full): round 2's PASS_HEAD. */ +function lastPassHead(): string | null { + const r = runScriptSync(path.join(import.meta.dir, 'gstack-review-read'), [], { cwd: process.cwd(), encoding: 'utf8', timeout: 60_000, maxBuffer: 64 * 1024 * 1024 }); + const rows = (r.stdout ?? '').split('---CONFIG---')[0]!.split('\n').flatMap(l => { try { return [JSON.parse(l)]; } catch { return []; } }); + const pass = rows.filter(x => x.skill === 'review' && typeof x.commit_full === 'string').at(-1); + return pass ? pass.commit_full : null; +} + +function grant(head: string, detail: string, ledger: string): string | { error: string } { + const branch = git(['rev-parse', '--abbrev-ref', 'HEAD']).stdout.trim(); + const loc = locate({ cwd: process.cwd(), env: process.env, branch }); + if ('code' in loc) return { error: loc.message }; + const row = buildRow({ + ask: `No-stop default: one scoped fix-check past R51's cap (review_loop_grant ${head})`, + words: `K1 no-stop default, docs/review-policy.md#no-stop: ${detail}`, + by: K1_BY, scope: `review_loop_grant ${head}`, outcome: 'approved', source: `no-stop:${ledger.split('/').pop()}`, + }, branch); + if ('code' in row) return { error: row.message }; + const added = appendRows(loc, [row]); + return 'code' in added ? { error: added.message } : row.id; +} + +function record(args: string[], json: boolean): number { + const cls = flag(args, '--class') ?? ''; + const commit = (ref: string | undefined) => { if (!ref) return undefined; const r = git(['rev-parse', '--verify', '--quiet', '--end-of-options', `${ref}^{commit}`]); return r.status === 0 ? r.stdout.trim() : null; }; + const rangeBase = commit(flag(args, '--range-base')); + const base = cls === 'stamp-only-carry' ? commit(flag(args, '--base')) : undefined; + if (rangeBase === null || base === null) { process.stderr.write(`gstack-no-stop record: --range-base and --base must name commits\n`); return EXIT.usage; } + if (!cls) { process.stderr.write(`gstack-no-stop record: --class is required\n${HELP}\n`); return EXIT.usage; } + if (NEEDS_RANGE.includes(cls) && !rangeBase) { process.stderr.write(`gstack-no-stop record: --class ${cls} needs --range-base \n`); return EXIT.usage; } + const file = ledgerPath(process.cwd()); + if (!file) { process.stderr.write('gstack-no-stop record: not inside a git checkout\n'); return EXIT.fail; } + if (cls === 'scoped-fix-check') { + const pass = lastPassHead(); + if (!pass || pass !== rangeBase) { + process.stderr.write(`NO_STOP: refused ${cls} (--range-base ${rangeBase} is not the commit the last review pass read (${pass ? pass.slice(0, 12) : 'no review-log row'}), so the checked range would not be the range the fix-check covers); stop for the owner\n`); + return EXIT.refused; + } + const loc = locate({ cwd: process.cwd(), env: process.env, branch: git(['rev-parse', '--abbrev-ref', 'HEAD']).stdout.trim() }); + if (!readLedger(file).some(r => r.class === cls) && !('code' in loc) && readRecord(loc).rows.some(r => r.by === K1_BY)) { + process.stderr.write(`NO_STOP: refused ${cls} (the approval record already holds this branch's K1 grant, from this or another checkout; a second goes to the owner); stop for the owner\n`); + return EXIT.refused; + } + } + let diff: string | undefined; + if (rangeBase) { + let from = rangeBase; + if (base) { + const merged = git(['merge-tree', '--write-tree', '-X', 'ours', base, rangeBase]); + if (merged.status !== 0) { process.stderr.write(`NO_STOP: refused ${cls} (git merge-tree ${base} ${rangeBase} failed: ${merged.stderr.trim() || 'conflict'}); stop for the owner\n`); return EXIT.refused; } + from = merged.stdout.split('\n')[0]!.trim(); + } + const d = git(['diff', '--no-color', '--no-ext-diff', '--no-renames', from, 'HEAD']); + if (d.status !== 0) { process.stderr.write(`gstack-no-stop record: git diff ${rangeBase} HEAD failed: ${d.stderr.trim()}\n`); return EXIT.fail; } + diff = d.stdout; + } + const v = checkNoStop({ cls, reason: flag(args, '--reason') ?? '', evidence: flag(args, '--evidence') ?? '', prior: readLedger(file), diff, root: process.cwd() }); + if (!v.ok) { + process.stderr.write(`NO_STOP: refused ${cls} (${v.reason}); stop for the owner\n`); + if (json) process.stdout.write(JSON.stringify({ ok: false, class: cls, reason: v.reason }) + '\n'); + return EXIT.refused; + } + const lines = [`NO_STOP: ${cls} recorded (${v.detail})`, `NO_STOP_FILE: ${file}`]; + let grantId: string | undefined; + if (cls === 'scoped-fix-check') { + const head = git(['rev-parse', 'HEAD']).stdout.trim(); + const g = grant(head, v.detail, file); + if (typeof g !== 'string') { process.stderr.write(`gstack-no-stop record: the grant was not recorded (${g.error}); stop for the owner\n`); return EXIT.fail; } + grantId = g; + lines.push(`GRANT: ${g}`, `NEXT: gstack-review-plan --grant ${g} --range-base ${rangeBase} (one scoped fix-check; a blocking finding there goes to the owner)`); + } + if (cls === 'test-timeout-fix') lines.push(`GATE_DISPOSITION: kept reason="no-stop test-timeout-fix: ${v.record.reason}"`); + appendLedger(file, v.record); + process.stdout.write(json ? JSON.stringify({ ok: true, record: v.record, detail: v.detail, file, ...(grantId ? { grant: grantId } : {}) }) + '\n' : lines.join('\n') + '\n'); + return EXIT.ok; +} + +function main(argv: string[]): number { + const [cmd, ...args] = argv; + const json = args.includes('--json'); + if (!cmd || cmd === '--help' || cmd === '-h') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; } + if (cmd === 'record') return record(args, json); + if (cmd === 'import') { + const src = flag(args, '--from'); + const file = ledgerPath(process.cwd()); + if (!src || !file) { process.stderr.write(`gstack-no-stop import: ${src ? 'not inside a git checkout' : '--from is required'}\n`); return EXIT.usage; } + const have = readLedger(file); + const rows = readLedger(src); + const eligible = rows.filter(r => r.class === 'autoplan-regate' && r.reason && AUTOPLAN_EVIDENCE.test(r.evidence ?? '')); + const fresh = eligible.filter(r => !have.some(h => JSON.stringify(h) === JSON.stringify(r))); + for (const r of fresh) appendLedger(file, r); + const skipped = rows.length - eligible.length; + process.stdout.write(json ? JSON.stringify({ file, imported: fresh.length, skipped }) + '\n' : [`NO_STOP: imported ${fresh.length} record(s) from ${src}`, ...(skipped ? [`NO_STOP: skipped ${skipped} record(s): import takes only autoplan-regate records with autoplan:@gate_rev=N evidence; record the other classes with gstack-no-stop record`] : []), `NO_STOP_FILE: ${file}`].join('\n') + '\n'); + return EXIT.ok; + } + if (cmd === 'list') { + const file = ledgerPath(process.cwd()); + const rows = readLedger(file); + process.stdout.write(json ? JSON.stringify({ file, records: rows }) + '\n' : [...rows.map(r => `NO_STOP: ${r.class} ${r.reason} (${r.evidence})`), `NO_STOP_FILE: ${file ?? 'none (not a git checkout)'}`].join('\n') + '\n'); + return EXIT.ok; + } + process.stderr.write(`gstack-no-stop: unknown command ${cmd}\n${HELP}\n`); + return EXIT.usage; +} + +process.exit(main(process.argv.slice(2))); diff --git a/changelog.d/gsta258-l5-no-stop.md b/changelog.d/gsta258-l5-no-stop.md new file mode 100644 index 000000000..d0600d63a --- /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. The range must start at the commit the last review pass read. It records the standing grant in the approval record and prints `GRANT:` for `gstack-review-plan --grant`. One per branch. + - `test-timeout-fix`: a fix whose every changed line is in a test file and only raises a timeout value. A line with an assertion or a comparison never qualifies. Its gate finding takes a printed `kept` reason instead of `open`. + - `pregate-flake-rerun`: one rerun of a failed pregate case at the same tree (rule 16). One per branch. + - `stamp-only-carry`: a refused carry where HEAD differs from the receipt head merged into the base only in a version stamp. /ship then takes the carry's `RECHECK:` route instead of running Steps 2–16 again. + - `autoplan-regate`: see below. +- **`gstack-no-stop import` and `list`** add an autoplan run's records to the branch and print the ledger. /ship's receipt write passes it as `--no-stop @`. + +### Changed +- **An autoplan plan edit made after the owner's answer carries their words when it only adds ELI5 lines.** Every added line must sit in the plan's `## ELI5` section and name no number, spend, budget, cap, limit, arm, model, judge or verdict. Such an edit carries as `reason=no-stop autoplan-regate: …` and is written to `/no-stop.json`, and the run still exports the reviewed plan. A removed or reworded line, a line added anywhere else, or an edit to the reviewed plan itself still goes to the owner. The run keeps a copy of each plan file it hashes, so the edit is diffed against what the owner answered. +- docs/review-policy.md#no-stop has the table. docs/ship-policy.md lists the receipt's `no_stop` field, and docs/unattended.md describes the carry-forward change. + +### Tests +- `test/no-stop.test.ts`: each class's qualifying and refused cases, the ledger, the grant row, the receipt's `no_stop` validation, stamp-only after a base merge, import, and the template wording. +- `test/autoplan-carry.test.ts`: an ELI5-only addition after the revise now carries and is recorded. A cap move still refuses. diff --git a/docs/review-policy.md b/docs/review-policy.md index 3df688516..7446e4c92 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. `--range-base` must be the commit the newest review-log pass row read. One per branch: the ledger keeps every record, so a later /ship on the branch stops for the owner. | It records the standing grant in the approval record (`by: gstack no-stop default (K1)`) and prints `GRANT:`. `gstack-review-plan --grant` plans the one scoped fix-check. A blocking finding there goes to the owner. | +| `test-timeout-fix` | Every changed line is in a test file and only raises a timeout value: the value of a timeout key (`timeout: 5_000`, `setDefaultTimeout(5_000)`, `--timeout=5000`) or bun's and jest's trailing per-test argument, only on the line that opens the test call itself (`test('…', () => {…}, 30_000);`, likewise `it(` and `describe(`). A multi-line test's closing `}, 30_000);` does not qualify and stops for the owner. A line with `expect`, `assert` or a comparison never qualifies. | The gate scan's relaxation finding takes the printed `GATE_DISPOSITION: kept` reason instead of `open`. | +| `pregate-flake-rerun` | One rerun of a failed pregate case at the same tree (CREW rule 16). One per branch. | Green counts as flaky. Red again is a real failure. | +| `stamp-only-carry` | A refused carry, where the receipt head merged into the base (base side on conflict) differs from HEAD only in a version stamp: `VERSION`, the `"version"` line of `package.json`, `CHANGELOG.md`, the agents digest or `changelog.d/` fragments. | Take the carry's `RECHECK:` route instead of running Steps 2–16 again. | +| `autoplan-regate` | An edit to the plan file made after the owner's answer, or after the revise that took their changes, while the reviewed plan itself is unchanged. The edit only adds lines, all inside the plan's `## ELI5` section (an added `## ELI5` heading opens one), and no added line holds a number or a word for spend, cost, budget, price, cap, credits, limit, max, arm, model, judge, verdict, threshold, bar, trial, sample, stop rule, paid or eval. A removed or reworded line, or a line added anywhere else, goes to the owner. The carry exports the reviewed plan, so the added explanation is never built. | `gstack-autoplan` carries the owner's words (`reason=no-stop autoplan-regate: …`) and writes `/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 @@ -792,3 +812,4 @@ ones, and the arm lost blockers. `bin/gstack-review-dispatch` closes both ends: - `review/checklist.md`: `prereg-with-results` under Gate Integrity and `test-keyboard-count-loop` under Pass 2; `bin/gstack-coauthor-trailers` for the contributor-credit trailers of a port. - `lib/ship-receipt-prose.ts`, `gstack-ship-receipt read --check-prose`: the body's quoted counters against the receipt (`RECEIPT_PROSE_MISMATCH`, [ship-policy.md](ship-policy.md#ship-receipt)). - `lib/ship-receipt.ts`, `lib/headless-artifacts.ts`: the `review_loop` receipt object, its schema and the summary line. +- `lib/no-stop.ts`, `bin/gstack-no-stop`, `lib/autoplan-carry.ts`: the [no-stop defaults](#no-stop), their ledger and the autoplan re-gate (`test/no-stop.test.ts`, `test/autoplan-carry.test.ts`). diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 1ba1ccf28..8ed65142f 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -1240,8 +1240,8 @@ written by the pr-body section of `/ship` after the body is composed. The ```` ```gstack-ship-receipt ```` block is the handoff record: identifiers first (`pr`, `head`, `version`, `base`), then the evidence (`gate`, `ci`, `head_tree`, `product_patch_id`, `gstack_version`, `review_depth`, `pregate`, -`issues`, `approval`, `docs`, `review_loop`, `review_loop_exception`, `carry`), -validated +`issues`, `approval`, `docs`, `review_loop`, `review_loop_exception`, `carry`, +`no_stop`), validated against the `ship-receipt` schema (`gstack-artifact schema ship-receipt`) and read back by `gstack-ship-receipt read`, `result`, `carry` and the queue. @@ -1302,6 +1302,11 @@ checked-out HEAD, when the receipt records `gstack_version` without `review_depth`, or when an owner exception no longer stands, each as [`RECEIPT_CARRY_REFUSED`](troubleshooting.md#receipt-carry-refused). +**`no_stop`.** The routine stops the run recorded and went on past, from +`write --no-stop `: 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..cc5e4ec46 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 only adds ELI5 lines that name +no number, spend, cap, arm or verdict (K1's `autoplan-regate` no-stop: the +reason says so, `NO_STOP:` names `/no-stop.json`, the reviewed plan is +exported, and any other edit refuses; [review-policy.md#no-stop](review-policy.md#no-stop)); no urgent or outside-plan finding open; and every new gate item one of the cases below. Anything else prints `CARRY: no ()` (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..ccdbffd4c 100644 --- a/lib/autoplan-carry.ts +++ b/lib/autoplan-carry.ts @@ -24,13 +24,14 @@ import * as path from 'node:path'; import { attemptEstimate } from './autoplan-budget'; import { lineDiff } from './autoplan-confirm'; import { answerGate } from './autoplan-gate'; -import { driftLine } from './autoplan-plan-text'; +import { driftLine, reviewHash } from './autoplan-plan-text'; import { BOUNDARY, RESULT_FORMAT, REVIEWER_TRAILER } from './autoplan-prompts'; import { parseReviewResult } from './autoplan-reconcile'; import { FILES, RunError, journal, now, readDecisions, readJournal, readState, writeDecisions, writeState, type RunState } from './autoplan-state'; import { appendTrailRow } from './decision-trail'; import { decisionId, parseReply, type GateItem, type GateList } from './gate-list'; import { readJsonl } from './headless-artifacts'; +import { appendLedger, notEli5Only } from './no-stop'; import { modelFamily } from './outside-voice-runner'; import { release, reserve, settle } from './spend-ledger'; @@ -57,7 +58,15 @@ export const atGateCopy = (out: string, rev: number) => path.join(out, `plan-at- /** What finishGate journals beside gate_written: the plan file's bytes and a copy of the reviewed plan the owner is asked about. */ export function gateEvidence(state: RunState, rev: number): { source_file_sha256?: string } { if (fs.existsSync(state.active_plan)) fs.copyFileSync(state.active_plan, atGateCopy(state.out, rev)); - return { source_file_sha256: fileSha(planFile(state)) }; + return { source_file_sha256: keepPlanFile(state.out, planFile(state)) }; +} + +/** A copy of the plan file's bytes named by their hash, so a later re-gate can diff the edit against what the owner answered (K1). */ +export const planFileCopy = (out: string, sha: string) => path.join(out, `plan-file.${sha.slice(0, 16)}.md`); +export function keepPlanFile(out: string, file: string): string | undefined { + const sha = fileSha(file); + if (sha) fs.copyFileSync(file, planFileCopy(out, sha)); + return sha; } /** The lines the latest carry decision printed, for the command that wrote this gate revision. */ @@ -96,7 +105,9 @@ function deltaSince(state: RunState, rev: number): { diff: string; sha: string } /** `p1`: an item-token answer's own p1 choice, replayed as given. */ interface Source { id: string; gate_rev: number; words: string; by?: string; p1?: string } -type Plan = { source: Source; doIt: GateItem[]; deltas: GateItem[] } | { reason: string; from?: number }; +/** noStop: K1's autoplan-regate (lib/no-stop.ts): a plan edit after the owner's answer that only adds plain ELI5 lines (notEli5Only). */ +interface NoStop { why: string } +type Plan = { source: Source; doIt: GateItem[]; deltas: GateItem[]; noStop?: NoStop } | { reason: string; from?: number }; /** Every condition but the delta check; returns why not, or what the carry takes. */ function assess(state: RunState, prev: GateList, list: GateList): Plan { @@ -118,10 +129,12 @@ function assess(state: RunState, prev: GateList, list: GateList): Plan { const journalRows = readJournal(state.out); const at = journalRows.map(r => r.event === 'gate_written' && r.gate_rev === from).lastIndexOf(true); let expected: string | undefined = at >= 0 ? journalRows[at]!.source_file_sha256 : undefined; - for (const r of journalRows.slice(at + 1)) if (r.event === 'plan_revised') expected = r.source_file_sha256; + const hashOf = (file: string) => { try { return reviewHash(fs.readFileSync(file, 'utf8')); } catch { return undefined; } }; + let reviewed: string | undefined = hashOf(atGateCopy(state.out, from)); + for (const r of journalRows.slice(at + 1)) if (r.event === 'plan_revised') { expected = r.source_file_sha256; reviewed = r.new_plan_sha256; } if (!expected) return { reason: 'no plan evidence recorded for the gate or the revise (a run written before carry-forward)', from }; - if (fileSha(planFile(state)) !== expected) return { reason: `the plan file ${path.basename(planFile(state))} changed after the owner's answer or the revise that took it`, from }; - if (driftLine(state, 'PLAN_SOURCE_CHANGED')) return { reason: 'the reviewed plan differs from the plan file', from }; + const changed = fileSha(planFile(state)) !== expected ? `the plan file ${path.basename(planFile(state))} changed after the owner's answer or the revise that took it` : undefined; + if ((!changed && driftLine(state, 'PLAN_SOURCE_CHANGED')) || (changed && (!reviewed || hashOf(state.active_plan) !== reviewed))) return { reason: 'the reviewed plan differs from the plan file', from }; const urgent = readJsonl(path.join(state.out, 'findings.jsonl')).rows.find(r => (r.urgent === true || r.outside_plan === true) && !r.settled_by); if (urgent) return { reason: `urgent finding open: ${String(urgent.title ?? urgent.id)}`, from }; const doIt: GateItem[] = []; const deltas: GateItem[] = []; @@ -132,7 +145,13 @@ function assess(state: RunState, prev: GateList, list: GateList): Plan { if (!tokens && isDoIt(words) && item.kind === 'auto' && !item.cap && !option?.changes_plan && !['scope', 'cost', 'direction'].includes(item.changes ?? '')) { doIt.push(item); continue; } return { reason: `new item ${item.id}: ${item.title}`, from }; } - return { source: { id: p1.id, gate_rev: from, words, by: p1.answered_by, ...(tokens ? { p1: p1.chosen } : {}) }, doIt, deltas }; + const source = { id: p1.id, gate_rev: from, words, by: p1.answered_by, ...(tokens ? { p1: p1.chosen } : {}) }; + if (!changed) return { source, doIt, deltas }; + const before = planFileCopy(state.out, expected); + if (!fs.existsSync(before) || !fs.existsSync(planFile(state))) return { reason: changed, from }; + const why = notEli5Only(lineDiff(fs.readFileSync(before, 'utf8'), fs.readFileSync(planFile(state), 'utf8'))); + if (why) return { reason: `${changed}; ${why}`, from }; + return { source, doIt, deltas, noStop: { why: `${changed}, adding only ELI5 lines (the reviewed plan is exported)` } }; } /** Called by finishGate once gate_rev N+1 is written (status gate_pending): carry, set up the delta check, or say why not. */ @@ -143,7 +162,7 @@ export function evaluateCarry(state: RunState, prev: GateList | undefined, list: const plan = assess(state, prev, list); if ('reason' in plan) return refuse(state, list.gate_rev, plan.from, plan.reason); if (plan.deltas.length) return dispatchCheck(state, prev, list, plan); - return carry(state, list, plan.source, plan.doIt, plan.deltas); + return carry(state, list, plan.source, plan.doIt, plan.deltas, undefined, plan.noStop); } catch (e: any) { if (readDecisions(state.out).some(r => r.gate_rev === list.gate_rev && r.answered_at && !r.carried_from)) throw e; return refuse(state, list.gate_rev, prev.gate_rev, `error: ${e instanceof RunError ? `${e.detail} (${e.code})` : e?.message ?? e}`); @@ -179,17 +198,20 @@ function dispatchCheck(state: RunState, prev: GateList, list: GateList, plan: Ex `NEXT: dispatch one reviewer on the prompt, then gstack-autoplan check --out ${state.out} --attempt ${attempt} --result ${result} --model (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 +268,7 @@ export function submitCarryCheck(o: CheckOptions): { state: RunState; lines: str const plan = assess(state, { ...list, gate_rev: c.from_gate_rev! } as GateList, list); if ('reason' in plan) { out = refuse(state, c.gate_rev, c.from_gate_rev, plan.reason); out.check = done; } else { - out = carry(state, list, plan.source, plan.doIt, plan.deltas, done); + out = carry(state, list, plan.source, plan.doIt, plan.deltas, done, plan.noStop); out.lines.unshift(`CARRY_CHECK: passed attempt=${check.attempt} model=${o.model} findings=${done.findings} highest=${max}`); } } 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..32ad3c7ab --- /dev/null +++ b/lib/no-stop.ts @@ -0,0 +1,182 @@ +/** + * The five no-stop defaults (GSTA-258 plan K1, docs/review-policy.md#no-stop): routine stops /ship and + * /autoplan record in the receipt's `no_stop` field and go on past instead of waiting for a Captain. + * Each class has one deterministic test here; anything that fails it, and any stop outside the five, + * still stops for the owner. + * + * - scoped-fix-check: one scoped fix-check past R51's two-round cap when the round-2 fix is docs-only + * (docs class, lib/review-depth.ts classifyPath) or timeout-only. One per branch. + * - test-timeout-fix: a fix whose every changed line, all in test files, only raises a timeout value. + * - pregate-flake-rerun: one rerun of a failed pregate case at the same tree (CREW rule 16). One per branch. + * - stamp-only-carry: a refused receipt carry whose changes since the receipt head are version stamps only. + * - autoplan-regate: a later autoplan gate revision whose plan-file edit only adds ELI5 lines that name no + * number, spend, cap, arm or verdict (lib/autoplan-carry.ts asks notEli5Only before it carries). + * + * The ledger is a JSON array of {class, reason, evidence} under the checkout's git dir, one file per + * branch, so `gstack-ship-receipt write --no-stop @` takes it as it stands. + */ +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { branchKey } from './approval-record'; +import { NO_STOP_CLASSES, type NoStopClass } from './headless-artifacts'; +import { classifyPath } from './review-depth'; + +export interface NoStopRecord { class: NoStopClass; reason: string; evidence: string } +export type NoStopVerdict = { ok: true; record: NoStopRecord; detail: string } | { ok: false; reason: string }; + +export interface FileDiff { path: string; pairs: Array<[string, string]>; added: string[]; removed: string[]; whole: boolean; inHunk?: boolean } + +/** A number that is a timeout's value: after a timeout key (`timeout: 5`, `timeoutMs = 5`, `setDefaultTimeout(5)`, `--timeout=5`) or bun's and jest's trailing per-test argument (`}, 30_000);`). */ +const TIMEOUT_VALUE = /(\b[a-z]*timeout\w*\s*(?::|=|\()\s*|--timeout[= ]|\}\s*,\s*)(\d[\d_]*)/gi; +const ASSERTION = /\b(?:expect|assert\w*)\b|\s[<>]=?\s/; +const VERSION = /\d+\.\d+\.\d+(?:\.\d+)?/g; +const TEST_PATH = /(?:^|\/)(?:test|tests|__tests__|spec)\/|\.(?:test|spec)\.[cm]?[jt]sx?$/; +const STAMP_PATHS = ['VERSION', 'package.json', 'CHANGELOG.md', 'agents-digest/gstack-AGENTS.md']; +/** Words that mean spend, a cap, an eval arm, a model or a verdict: an ELI5 addition naming one goes to the owner. */ +const REGATE_WORDS = /\$|\b(?:spend|spending|spent|costs?|budgets?|prices?|caps?|capped|credits?|limits?|max|maximum|arms?|models?|judges?|verdicts?|thresholds?|bars?|pass(?:es)? at|trials?|samples?|stop rules?|kill rules?|paid|evals?|opus|sonnet|haiku|gpt[\w.-]*|gemini|claude)\b|\bn\s*=/i; + +/** Unified diff (git diff) to files, each with its one-for-one changed line pairs; a run of unequal - and + counts is unpaired. */ +export function parseDiff(diff: string): FileDiff[] { + const files: FileDiff[] = []; + let cur: FileDiff | undefined; + let minus: string[] = []; let plus: string[] = []; + const flush = () => { + if (!cur) return; + if (minus.length === plus.length) minus.forEach((m, i) => cur!.pairs.push([m, plus[i]!])); + else { cur.removed.push(...minus); cur.added.push(...plus); } + minus = []; plus = []; + }; + for (const line of diff.split('\n')) { + if (line.startsWith('diff --git ')) { + flush(); + cur = { path: line.replace(/^diff --git a\/(.*) b\/.*$/, '$1'), pairs: [], added: [], removed: [], whole: false }; + files.push(cur); + } else if (!cur) continue; + else if (!cur.inHunk && line.startsWith('+++ ')) { if (line !== '+++ /dev/null') cur.path = line.slice(4).replace(/^b\//, ''); } + else if (!cur.inHunk && line.startsWith('--- ')) continue; + else if (!cur.inHunk && /^(?:new|deleted) file mode|^rename (?:from|to) |^Binary files /.test(line)) cur.whole = true; + else if (line.startsWith('@@')) { flush(); cur.inHunk = true; } + else if (line.startsWith('-')) { if (plus.length) flush(); minus.push(line.slice(1)); } + else if (line.startsWith('+')) plus.push(line.slice(1)); + else flush(); + } + flush(); + return files; +} + +const TEST_CALL = /^\s*(?:test|it|describe|bench|beforeAll|beforeEach|afterAll|afterEach)(?:\.\w+)*\(/; +/** A trailing `}, N)` is a timeout only where the same line opens the test call it closes; a multi-line callback's `}, N);` could close any call. */ +const isTimeout = (s: string, key: string, at: number, len: number) => !key.startsWith('}') || (TEST_CALL.test(s) && /\)\s*;?\s*$/.test(s.slice(at + len))); +const timeoutValues = (s: string) => [...s.matchAll(TIMEOUT_VALUE)].filter(m => isTimeout(s, m[1]!, m.index!, m[0].length)).map(m => Number(m[2]!.replace(/_/g, ''))); +const maskTimeouts = (s: string) => s.replace(TIMEOUT_VALUE, (_m, key: string, _n: string, at: number) => (isTimeout(s, key, at, _m.length) ? `${key}#` : _m)); + +/** Why the diff is not timeout-only, or null: every changed line pairs with one that differs only in larger timeout values (never an assertion). */ +export function notTimeoutOnly(files: FileDiff[]): string | null { + if (!files.length) return 'the fix changes nothing'; + for (const f of files) { + if (f.whole || f.added.length || f.removed.length || !f.pairs.length) return `${f.path} adds, removes or replaces whole lines`; + for (const [was, now] of f.pairs) { + if (ASSERTION.test(now) || maskTimeouts(was) !== maskTimeouts(now)) return `${f.path} changes more than a timeout value: ${now.trim().slice(0, 80)}`; + const a = timeoutValues(was); const b = timeoutValues(now); + if (a.length !== b.length || b.some((n, i) => n < a[i]!) || b.every((n, i) => n === a[i])) return `${f.path} does not raise a timeout: ${now.trim().slice(0, 80)}`; + } + } + return null; +} + +/** Why the paths are not docs-only, or null: each is docs class as /ship's review classifies it (agent-read and skill prose is not). */ +export function notDocsOnly(paths: string[], root: string): string | null { + if (!paths.length) return 'the fix changes nothing'; + const code = paths.filter(p => classifyPath(p, root) !== 'docs'); + return code.length ? `not docs: ${code.slice(0, 3).join(', ')}` : null; +} + +/** Why the diff is not a version stamp, or null: only stamp files change, and in VERSION or package.json only a version string moves. */ +export function notStampOnly(files: FileDiff[]): string | null { + if (!files.length) return 'nothing changed since the receipt head'; + for (const f of files) { + if (!STAMP_PATHS.includes(f.path) && !/^changelog\.d\/[^/]+\.md$/.test(f.path)) return `${f.path} is not a stamp file`; + if (f.path !== 'VERSION' && f.path !== 'package.json') continue; + if (f.whole || f.added.length || f.removed.length) return `${f.path} adds or removes lines`; + const bad = f.pairs.find(([was, now]) => was.replace(VERSION, 'V') !== now.replace(VERSION, 'V') || (f.path === 'package.json' && !/^\s*"version"\s*:/.test(now))); + if (bad) return `${f.path} changes more than the version: ${bad[1].trim().slice(0, 80)}`; + } + return null; +} + +const HEADING = /^#{1,6}\s+(.*)$/; +const ELI5 = /^eli5\b/i; + +/** + * Why a plan-file edit (lineDiff rows) is not an ELI5-only addition, or null. Every changed line must be added (none + * removed or rewritten), inside the plan's `## ELI5` section (an added `## ELI5` heading opens one), and hold no digit + * and no spend, cap, arm, model or verdict word. Anything else goes to the owner: the carry exports the reviewed + * plan, so the added explanation is never built. + */ +export function notEli5Only(lines: ReadonlyArray<{ op: string; text: string }>): string | null { + let section = ''; + let added = 0; + for (const l of lines) { + const heading = l.text.match(HEADING); + if (l.op === '-') return `it removes or rewrites a line: ${l.text.trim().slice(0, 80)}`; + if (l.op !== '+') { if (heading) section = heading[1]!.trim(); continue; } + if (!l.text.trim()) continue; + if (heading && !ELI5.test(heading[1]!.trim())) return `it adds a heading: ${l.text.trim().slice(0, 80)}`; + if (heading) { section = heading[1]!.trim(); continue; } + if (!ELI5.test(section)) return `it adds a line outside ## ELI5: ${l.text.trim().slice(0, 80)}`; + if (/\d/.test(l.text.replace(/\bELI5\b/gi, '')) || REGATE_WORDS.test(l.text)) return `it names a number, spend, a cap, an arm, a model or a verdict: ${l.text.trim().slice(0, 80)}`; + added++; + } + return added ? null : 'the edit adds no ELI5 line'; +} + +export interface NoStopInput { cls: string; reason: string; evidence: string; prior: readonly NoStopRecord[]; diff?: string; root?: string } + +/** The verdict for one stop: the record to add, or why it still stops. */ +export function checkNoStop(i: NoStopInput): NoStopVerdict { + if (!(NO_STOP_CLASSES as readonly string[]).includes(i.cls)) return { ok: false, reason: `${i.cls} is not one of the five no-stop classes (${NO_STOP_CLASSES.join(', ')})` }; + const cls = i.cls as NoStopClass; + if (!i.reason.trim() || !i.evidence.trim()) return { ok: false, reason: 'a no-stop record needs a reason and evidence that resolves (run id, commit or file)' }; + const record = { class: cls, reason: i.reason.trim(), evidence: i.evidence.trim() }; + if ((cls === 'scoped-fix-check' || cls === 'pregate-flake-rerun') && i.prior.some(r => r.class === cls)) return { ok: false, reason: `one ${cls} per branch is recorded already; a second goes to the owner` }; + if (cls === 'pregate-flake-rerun') return { ok: true, record, detail: 'one rerun at the same tree (rule 16): green is flaky, red again is real' }; + if (cls === 'autoplan-regate') return { ok: false, reason: 'gstack-autoplan decides re-gates at the gate (lib/autoplan-carry.ts), never by hand' }; + const files = parseDiff(i.diff ?? ''); + const where = files.map(f => f.path).join(', '); + if (cls === 'stamp-only-carry') { + const why = notStampOnly(files); + return why ? { ok: false, reason: why } : { ok: true, record, detail: `version stamp only in ${where}` }; + } + const timeout = notTimeoutOnly(files); + if (cls === 'test-timeout-fix') { + const outside = files.filter(f => !TEST_PATH.test(f.path)).map(f => f.path); + if (outside.length) return { ok: false, reason: `not a test file: ${outside.slice(0, 3).join(', ')}` }; + return timeout ? { ok: false, reason: timeout } : { ok: true, record, detail: `timeout raised in ${where}` }; + } + if (!timeout) return { ok: true, record, detail: `timeout-only fix in ${where}` }; + const docs = notDocsOnly(files.map(f => f.path), i.root ?? process.cwd()); + return docs ? { ok: false, reason: `the fix is neither docs-only (${docs}) nor timeout-only (${timeout})` } : { ok: true, record, detail: `docs-only fix in ${where}` }; +} + +/** The branch's ledger under the checkout's git dir, or null outside a git checkout. */ +export function ledgerPath(cwd: string): string | null { + const run = (args: string[]) => spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000 }); + const branch = run(['rev-parse', '--abbrev-ref', 'HEAD']); + const dir = run(['rev-parse', '--git-path', 'gstack']); + if (branch.status !== 0 || dir.status !== 0) return null; + return path.resolve(cwd, dir.stdout.trim(), `no-stop-${branchKey(branch.stdout.trim())}.json`); +} + +export function readLedger(file: string | null): NoStopRecord[] { + if (!file || !fs.existsSync(file)) return []; + const rows = JSON.parse(fs.readFileSync(file, 'utf8')); + return Array.isArray(rows) ? rows : []; +} + +export function appendLedger(file: string, record: NoStopRecord): NoStopRecord[] { + const rows = [...readLedger(file), record]; + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(rows, null, 2) + '\n'); + return rows; +} diff --git a/ship/DRIVER.md b/ship/DRIVER.md index 911d3b5cb..aa12f35e4 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 +NS=$(~/.claude/skills/gstack/bin/gstack-no-stop list | sed -n 's/^NO_STOP_FILE: //p'); [ -s "$NS" ] || NS= ~/.claude/skills/gstack/bin/gstack-review-scope summary --since "$REVIEW_SINCE" --no-ledger --out .gstack/tmp/review-loop.json && -~/.claude/skills/gstack/bin/gstack-ship-receipt write --pr --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:+--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..9b510a3f9 100644 --- a/ship/sections/pr-body.md.tmpl +++ b/ship/sections/pr-body.md.tmpl @@ -203,8 +203,9 @@ per-lane counts (`{"":{"pass":N,"fail":N,"skip":N}}`) to ```bash rm -f .gstack/tmp/review-loop.json +NS=$(~/.claude/skills/gstack/bin/gstack-no-stop list | sed -n 's/^NO_STOP_FILE: //p'); [ -s "$NS" ] || NS= ~/.claude/skills/gstack/bin/gstack-review-scope summary --since "$REVIEW_SINCE" --no-ledger --out .gstack/tmp/review-loop.json && -~/.claude/skills/gstack/bin/gstack-ship-receipt write --pr --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:+--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..32cf548d5 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: