diff --git a/CHANGELOG.md b/CHANGELOG.md index a1e1d10ae..9375dc182 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.93.9.0] - 2026-11-02 + +**`gstack-ship-receipt read --check-prose` no longer reads the required Review-loop exception line as counter claims (GST-977).** `write --review-loop-exception` requires the PR body to carry the whole `Review-loop exception: …` line word for word, but the prose check read that line as prose. On gstack-evals #139 the owner's reason "outside 1 and 2 skip detector" was read as a `2 skip` claim against the receipt's 70 skips, so no body could pass both checks. + +### Fixed +- **`lib/ship-receipt-prose.ts`:** the line that matches the receipt's own exception exactly (the same test `write` applies) is skipped, like the rendered `## Approvals` block. A copy of that exact line inside a code fence is skipped too, as `write` accepts one. The same words inside a longer line, or a line for a different exception, are still checked. + +### Tests +- `test/ship-receipt-prose.test.ts`: the #139 exception text passes `--check-prose` beside a matching `70 skip` claim, and a wrong number elsewhere still fails. On main the same body failed with `gate.unit.skip body=2 receipt=70`. + ## [1.93.8.0] - 2026-11-02 **/ship now runs Step 11's voices before any gate-class stop, and a recorded owner grant runs its fix-check.** On a gate-class change, a blocker fixed in review round 2 used to stop the run for the owner before Step 11 ran. If the owner then chose "ship with open notes", gate code shipped with no adversarial, outside or targeted review (C6-36 after-opus). `gstack-review-plan` now answers `NEXT: voices` for any gate-class stop while Step 11 has not run. /ship runs Step 11 as it does on `done`, asks the plan again with `--adversarial-ran`, and the stop then stands. The round cap still limits fix rounds; it no longer skips the required voices. Second, the owner's grant of one scoped fix-check after round 2 had no way in: the plan took no grant input, and the pass program printed `PASS: none next=stop`. Now `gstack-review-plan --grant ` checks that the branch's `gstack-approvals` record holds that id as an approved grant naming `review_loop_grant` and this run's stop head (exit 3 for any other row, a grant recorded for an earlier /ship on the branch included), and plans the one fix-check in place of the stop after a round-2 full pass. The pass program then dispatches that fix-check like any other. A fix-check never earns another, so the grant cannot repeat. After `NEXT: voices`, a blocking finding the voices queue goes back to the plan as `--blocking-open` instead of starting a new review pass. diff --git a/VERSION b/VERSION index d4a1cffc9..7ab5f1da9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.93.8.0 +1.93.9.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 5b748bf34..f55349cf4 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.93.8.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.93.9.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 4adac84b1..fb1aefa2f 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -1227,7 +1227,12 @@ block `gstack-approvals` renders (its heading, the Date | Asked | Exact words table and the `Source: gstack-approvals record` line) quotes the owner's words, not this PR's numbers, so it is skipped: "one Step 11 pass" in that table is not a `pass` claim. Text you write under the heading after that block, and -a `## Approvals` example inside a code fence, are still checked. Data PRs (evals, +a `## Approvals` example inside a code fence, are still checked. The +`Review-loop exception: …` line the receipt's own exception requires is +skipped the same way (GST-977): it quotes the owner's reason, so "outside 1 and +2 skip detector" is not a `2 skip` claim. Only a line that matches the rendered +line exactly is skipped, inside a code fence too, as the receipt check accepts +a fenced copy; the same words inside a longer line are still checked. Data PRs (evals, measurements, sweeps) run it before READY so the body's numbers are the run's. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 1c0e96302..473a6572a 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -5903,7 +5903,8 @@ body that names a counter the receipt records (`passes=3` against against `gate.passed: 41`) with a different value. The receipt is written from the run; the prose was typed, so a reader of the body would learn a number the run did not produce. The `PROSE:` line names each field with both values. The -`## Approvals` table `gstack-approvals` renders is never read as a claim. +`## Approvals` table `gstack-approvals` renders, and the `Review-loop exception: …` +line the receipt's exception requires, are never read as claims. **Fix.** Correct the body to the receipt's number. When the receipt is the stale one (the run was repeated), rerun `/ship` so the receipt is rewritten diff --git a/lib/ship-receipt-prose.ts b/lib/ship-receipt-prose.ts index 18e76a8e2..a99ee1784 100644 --- a/lib/ship-receipt-prose.ts +++ b/lib/ship-receipt-prose.ts @@ -7,6 +7,7 @@ */ import { APPROVALS_FOOTER_END, APPROVALS_TABLE_HEADER } from './approval-record'; import { RECEIPT_FENCE } from './ship-receipt'; +import { parseReviewLoopException, reviewLoopExceptionLine } from './ship-receipt-depth'; export interface ProseClaim { field: string; prose: number; receipt: number } export interface ProseCheck { checked: number; mismatches: ProseClaim[] } @@ -65,9 +66,24 @@ function withoutApprovalsBlocks(body: string): string { return out.join(''); } +/** + * The body without the `Review-loop exception: …` line the receipt's own exception requires (GST-977): write and the + * receipt check want it word for word, and it quotes the owner's reason (`outside 1 and 2 skip detector`), not this + * PR's counters. Only a line whose trimmed text is that rendered line, the test write applies, is left out (a fenced + * copy too, as write accepts one); the same words inside a longer line stay prose. A marker line takes its place so + * the text on either side never joins into a claim. + */ +function withoutExceptionLine(body: string, exception: unknown): string { + if (exception === undefined) return body; + const parsed = parseReviewLoopException(exception); + if (!parsed.ok) return body; + const line = reviewLoopExceptionLine(parsed.exception); + return body.split(/(?<=\n)/).map(l => (l.trim() === line ? '(review-loop exception line)\n' : l)).join(''); +} + /** * Every `=`, `: ` and ` ` in the prose (the body - * without its receipt block and its rendered Approvals block), where `` is a counter's leaf key with `_` + * without its receipt block, its rendered Approvals block and its review-loop exception line), where `` is a counter's leaf key with `_` * matched as `_`, space or `-` and not part of a longer word (`3 pass-through` * is not a `pass` claim), compared with the receipt. A leaf key shared by * several counters (`pass` under every gate lane, `passed` under gate and ci) @@ -77,7 +93,7 @@ function withoutApprovalsBlocks(body: string): string { * ` ` claim about the next key (#3513). */ export function checkReceiptProse(receipt: Record, body: string): ProseCheck { - const prose = withoutApprovalsBlocks(body.replace(BLOCK_RE, '')); + const prose = withoutExceptionLine(withoutApprovalsBlocks(body.replace(BLOCK_RE, '')), receipt.review_loop_exception); const byLeaf = new Map>(); for (const [field, value] of receiptCounters(receipt)) { const leaf = field.split('.').at(-1)!; diff --git a/package.json b/package.json index faec72bc0..60880d218 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.93.8", + "version": "1.93.9", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/ship-receipt-prose.test.ts b/test/ship-receipt-prose.test.ts index e5ff3a02e..2166c42df 100644 --- a/test/ship-receipt-prose.test.ts +++ b/test/ship-receipt-prose.test.ts @@ -118,6 +118,30 @@ describe('the rendered Approvals block is not prose', () => { }); }); +describe('the Review-loop exception line (GST-977, gstack-evals #139)', () => { + const excepted = { + ...receipt, + review_loop: { ...receipt.review_loop, fix_checks: 1 }, + gate: { status: 'green', run: '139', unit: { pass: 1181, skip: 70 } }, + review_loop_exception: { accepted_by: 'Garry', date: '2026-11-01', via: 'GEVA-2', reason: 'outside 1 and 2 skip detector', fix_check: { head: 'b'.repeat(40), findings: 0 } }, + }; + const line = 'Review-loop exception: accepted by Garry 2026-11-01 via GEVA-2: outside 1 and 2 skip detector (fix-check at bbbbbbbbbbbb, 0 findings)'; + + // Value: protects=a body that carries the exception line write requires can also pass --check-prose; fails_when=the + // owner's reason is read as counter claims ("2 skip" against 70); seam=lib/ship-receipt-prose.ts checkReceiptProse + test('the exact line the receipt\'s exception requires is not prose; the rest of the body still is', () => { + expect(checkReceiptProse(excepted, `${line}\n\nGate: 70 skip.\n`)).toEqual({ checked: 1, mismatches: [] }); + expect(checkReceiptProse(excepted, ` ${line}\r\nGate: 69 skip.`).mismatches).toEqual([{ field: 'gate.unit.skip', prose: 69, receipt: 70 }]); + }); + + test('the same words anywhere else, or a line for a different exception, stay prose', () => { + const twoSkip = [{ field: 'gate.unit.skip', prose: 2, receipt: 70 }]; + expect(checkReceiptProse(excepted, `Note: ${line}`).mismatches).toEqual(twoSkip); + expect(checkReceiptProse(excepted, line.replace('GEVA-2', 'GEVA-3')).mismatches).toEqual(twoSkip); + expect(checkReceiptProse({ ...excepted, review_loop_exception: undefined }, line).mismatches).toEqual(twoSkip); + }); +}); + describe('gstack-ship-receipt read --check-prose', () => { const run = (body: string) => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'receipt-prose-'));