v1.93.19.0 fix(receipt): --check-prose skips the Documentation exception line and closed code fences (GST-983, GST-984) (#3884)

* fix(receipt): --check-prose skips the Documentation exception line and closed code fences (GST-983, GST-984)

write --docs with an owner exception requires the PR body to carry the Documentation exception line word for word, and read --check-prose read the owner reason in it as counter claims, the deadlock GST-977 fixed for the Review-loop exception line. Pasted test output and quoted code in fenced blocks were read as receipt counts too (gstack-evals #142). The prose check now skips the exact docs exception line and every closed code fence; a fence that never closes stays prose. Failing-first: the three new cases fail on main d45c3501e.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(receipt): inline triple backticks open no fence, and nothing after an unclosed fence is skipped (GST-984 review)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-02 21:17:24 +00:00
1 parent 8af121fd4c
commit cb72cc13b1
8 files changed
+123 -26

No files matched your search

+11
View File
@@ -1,5 +1,16 @@
# Changelog # Changelog
## [1.93.19.0] - 2026-11-02
**`gstack-ship-receipt read --check-prose` no longer reads the Documentation exception line or code fences as counter claims (GST-983, GST-984).** `write --docs` with an owner exception requires the PR body to carry the whole `Documentation exception: …` line word for word, but the prose check read the owner's reason in it as prose, so a reason like "sections 1 and 2 skip the audit" was a `2 skip` claim and no body could pass both checks. That is the deadlock GST-977 fixed for the Review-loop exception line. Separately, on gstack-evals #142, test output pasted in a fenced block ("113 passed, 0 failed") and a quoted code comment ("the first 35 fail") were read as receipt counts and refused.
### Fixed
- **`lib/ship-receipt-prose.ts`:** the line that matches the receipt's own docs exception exactly is skipped, like the Review-loop exception line. The same words anywhere else, or a line for a different exception, are still checked.
- **`lib/ship-receipt-prose.ts`:** a closed code fence (backticks or tildes, closed by a run of its own character at least as long as the opener) is skipped. Counts outside the fence are still checked. A line that starts with inline code like ```` ```npm test``` ```` opens no fence, and a fence that never closes stays prose along with the rest of the body, so an unclosed opener cannot hide the claims after it.
### Tests
- `test/ship-receipt-prose.test.ts`: the docs exception line passes `--check-prose` beside a matching `70 skip` claim; the #142 fenced evidence passes while a wrong count outside the fence still fails; an unclosed fence stays prose; inline triple backticks and a shorter fence inside an unclosed one hide no claim. On main the first three cases failed.
## [1.93.18.0] - 2026-11-02 ## [1.93.18.0] - 2026-11-02
**A green Free Tests run at the pregate tree now clears gstack's remote gate, so a slow Ubicloud VM no longer blocks a PR whose CI already passed (GST-1024).** docs/pregate.md described `gstack-pregate clear --remote pregate.json/remote_gate --run <id|url> --allow-repo-commands`, but gstack's own `.gstack/pregate.json` declared no `remote_gate_runs`, so the route always refused. On #3869, every check passed at 3f2bcf3 (Free Tests run 41484634592), while two remote gate runs at the same tree failed on bun timeouts in files the PR doesn't touch. **A green Free Tests run at the pregate tree now clears gstack's remote gate, so a slow Ubicloud VM no longer blocks a PR whose CI already passed (GST-1024).** docs/pregate.md described `gstack-pregate clear --remote pregate.json/remote_gate --run <id|url> --allow-repo-commands`, but gstack's own `.gstack/pregate.json` declared no `remote_gate_runs`, so the route always refused. On #3869, every check passed at 3f2bcf3 (Free Tests run 41484634592), while two remote gate runs at the same tree failed on bun timeouts in files the PR doesn't touch.
+1 -1
View File
@@ -1 +1 @@
1.93.18.0 1.93.19.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.93.18.0 — regenerate/re-copy after upgrading gstack # gstack digest v1.93.19.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows, for agent hosts without a full skill install. The full skills add workflows,
+11 -7
View File
@@ -1233,13 +1233,17 @@ both values; prose that quotes no counter is not a claim. The `## Approvals`
block `gstack-approvals` renders (its heading, the Date | Asked | Exact words block `gstack-approvals` renders (its heading, the Date | Asked | Exact words
table and the `Source: gstack-approvals record` line) quotes the owner's 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 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 is not a `pass` claim. Text you write under the heading after that block is
a `## Approvals` example inside a code fence, are still checked. The still checked. The `Review-loop exception: …` line (GST-977) and the
`Review-loop exception: …` line the receipt's own exception requires is `Documentation exception: …` line (GST-983) the receipt's own exceptions
skipped the same way (GST-977): it quotes the owner's reason, so "outside 1 and require are skipped the same way: they quote the owner's reason, so "outside 1
2 skip detector" is not a `2 skip` claim. Only a line that matches the rendered and 2 skip detector" is not a `2 skip` claim. Only a line that matches the
line exactly is skipped, inside a code fence too, as the receipt check accepts rendered line exactly is skipped; the same words inside a longer line are still
a fenced copy; the same words inside a longer line are still checked. Data PRs (evals, checked. A closed code fence is skipped too (GST-984): pasted test output
("113 pass, 0 fail") and quoted code are evidence, not this PR's counters, so
put a count you mean as a claim outside the fence. A line like ```` ```npm test``` ran ````
opens no fence, and a fence that never closes stays prose, along with the rest
of the body. Data PRs (evals,
measurements, sweeps) run it before READY so the body's numbers are the run's. measurements, sweeps) run it before READY so the body's numbers are the run's.
<a id="review-scope"></a> <a id="review-scope"></a>
+3 -2
View File
@@ -5921,8 +5921,9 @@ body that names a counter the receipt records (`passes=3` against
against `gate.passed: 41`) with a different value. The receipt is written from 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 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 run did not produce. The `PROSE:` line names each field with both values. The
`## Approvals` table `gstack-approvals` renders, and the `Review-loop exception: …` `## Approvals` table `gstack-approvals` renders, the `Review-loop exception: …`
line the receipt's exception requires, are never read as claims. and `Documentation exception: …` lines the receipt's exceptions require, and
the inside of a closed code fence are never read as claims.
**Fix.** Correct the body to the receipt's number. When the receipt is the **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 stale one (the run was repeated), rerun `/ship` so the receipt is rewritten
+44 -14
View File
@@ -8,6 +8,7 @@
import { APPROVALS_FOOTER_END, APPROVALS_TABLE_HEADER } from './approval-record'; import { APPROVALS_FOOTER_END, APPROVALS_TABLE_HEADER } from './approval-record';
import { RECEIPT_FENCE } from './ship-receipt'; import { RECEIPT_FENCE } from './ship-receipt';
import { parseReviewLoopException, reviewLoopExceptionLine } from './ship-receipt-depth'; import { parseReviewLoopException, reviewLoopExceptionLine } from './ship-receipt-depth';
import { docsExceptionLine, parseReceiptDocs } from './ship-receipt-docs';
export interface ProseClaim { field: string; prose: number; receipt: number } export interface ProseClaim { field: string; prose: number; receipt: number }
export interface ProseCheck { checked: number; mismatches: ProseClaim[] } export interface ProseCheck { checked: number; mismatches: ProseClaim[] }
@@ -29,6 +30,32 @@ export function receiptCounters(receipt: Record<string, unknown>): Map<string, n
const BLOCK_RE = new RegExp('```' + RECEIPT_FENCE + '[ \\t]*\\r?\\n[\\s\\S]*?\\r?\\n```', 'g'); const BLOCK_RE = new RegExp('```' + RECEIPT_FENCE + '[ \\t]*\\r?\\n[\\s\\S]*?\\r?\\n```', 'g');
const ESCAPE = /[.*+?^${}()|[\]\\]/g; const ESCAPE = /[.*+?^${}()|[\]\\]/g;
const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/; const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/;
const fenceClose = (fence: string) => new RegExp(`^ {0,3}${fence[0] === '`' ? '`' : '~'}{${fence.length},}\\s*$`);
/**
* The body without its closed code fences (GST-984): pasted test output (`113 pass, 0 fail`) and quoted code
* (`the first 35 failing`) are evidence, not this PR's counters. The fence rules are withoutApprovalsBlocks' (a fence
* closes only on a run of its own character at least as long as the opener with nothing after it). A backtick run with
* another backtick later on its line (```npm test``` inline code) opens no fence, as in CommonMark. A fence that never
* closes stays prose, and so does the rest of the body, so an unclosed opener cannot hide the claims after it or let a
* shorter fence inside it hide them. A marker line takes each fence's place so the text on either side never joins
* into a claim.
*/
function withoutFences(body: string): string {
const lines = body.split(/(?<=\n)/);
const out: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!.replace(/\r?\n$/, '');
const open = FENCE_OPEN.exec(line);
if (!open || (open[1]![0] === '`' && line.slice(open[0].length).includes('`'))) { out.push(lines[i]!); continue; }
const close = fenceClose(open[1]!);
const end = lines.findIndex((l, j) => j > i && close.test(l.replace(/\r?\n$/, '')));
if (end < 0) return out.join('') + lines.slice(i).join('');
out.push('(code fence)\n');
i = end;
}
return out.join('');
}
/** /**
* The body without the `## Approvals` blocks gstack-approvals renders (lib/approval-record.ts renderRecord): the * The body without the `## Approvals` blocks gstack-approvals renders (lib/approval-record.ts renderRecord): the
@@ -55,7 +82,7 @@ function withoutApprovalsBlocks(body: string): string {
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
const line = bare(i); const line = bare(i);
if (fence !== null) { if (fence !== null) {
if (new RegExp(`^ {0,3}${fence[0] === '`' ? '`' : '~'}{${fence.length},}\\s*$`).test(line)) fence = null; if (fenceClose(fence).test(line)) fence = null;
} else if (FENCE_OPEN.test(line)) fence = FENCE_OPEN.exec(line)![1]!; } else if (FENCE_OPEN.test(line)) fence = FENCE_OPEN.exec(line)![1]!;
else if (/^ {0,3}## Approvals\s*$/.test(line)) { else if (/^ {0,3}## Approvals\s*$/.test(line)) {
const end = blockEnd(i); const end = blockEnd(i);
@@ -67,23 +94,26 @@ function withoutApprovalsBlocks(body: string): string {
} }
/** /**
* The body without the `Review-loop exception: …` line the receipt's own exception requires (GST-977): write and the * The body without the exception lines the receipt's own exceptions require: the `Review-loop exception: …` line
* receipt check want it word for word, and it quotes the owner's reason (`outside 1 and 2 skip detector`), not this * (GST-977) and the `Documentation exception: …` line (GST-983). write and the receipt check want each word for word,
* PR's counters. Only a line whose trimmed text is that rendered line, the test write applies, is left out (a fenced * and each quotes the owner's reason (`outside 1 and 2 skip detector`), not this PR's counters. Only a line whose
* copy too, as write accepts one); the same words inside a longer line stay prose. A marker line takes its place so * trimmed text is a rendered line, the test write applies, is left out; the same words inside a longer line stay
* the text on either side never joins into a claim. * 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 { function withoutExceptionLines(body: string, receipt: Record<string, unknown>): string {
if (exception === undefined) return body; const lines = new Set<string>();
const parsed = parseReviewLoopException(exception); const loop = receipt.review_loop_exception === undefined ? undefined : parseReviewLoopException(receipt.review_loop_exception);
if (!parsed.ok) return body; if (loop?.ok) lines.add(reviewLoopExceptionLine(loop.exception));
const line = reviewLoopExceptionLine(parsed.exception); const docs = receipt.docs === undefined ? undefined : parseReceiptDocs(receipt.docs);
return body.split(/(?<=\n)/).map(l => (l.trim() === line ? '(review-loop exception line)\n' : l)).join(''); const docsLine = docs?.ok ? docsExceptionLine(docs.docs) : null;
if (docsLine) lines.add(docsLine);
if (!lines.size) return body;
return body.split(/(?<=\n)/).map(l => (lines.has(l.trim()) ? '(exception line)\n' : l)).join('');
} }
/** /**
* Every `<name>=<n>`, `<name>: <n>` and `<n> <name>` in the prose (the body * Every `<name>=<n>`, `<name>: <n>` and `<n> <name>` in the prose (the body
* without its receipt block, its rendered Approvals block and its review-loop exception line), where `<name>` is a counter's leaf key with `_` * without its receipt block, its code fences, its rendered Approvals block and its exception lines), where `<name>` is a counter's leaf key with `_`
* matched as `_`, space or `-` and not part of a longer word (`3 pass-through` * 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 * 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) * several counters (`pass` under every gate lane, `passed` under gate and ci)
@@ -93,7 +123,7 @@ function withoutExceptionLine(body: string, exception: unknown): string {
* `<n> <name>` claim about the next key (#3513). * `<n> <name>` claim about the next key (#3513).
*/ */
export function checkReceiptProse(receipt: Record<string, unknown>, body: string): ProseCheck { export function checkReceiptProse(receipt: Record<string, unknown>, body: string): ProseCheck {
const prose = withoutExceptionLine(withoutApprovalsBlocks(body.replace(BLOCK_RE, '')), receipt.review_loop_exception); const prose = withoutExceptionLines(withoutApprovalsBlocks(withoutFences(body.replace(BLOCK_RE, ''))), receipt);
const byLeaf = new Map<string, Array<{ field: string; value: number }>>(); const byLeaf = new Map<string, Array<{ field: string; value: number }>>();
for (const [field, value] of receiptCounters(receipt)) { for (const [field, value] of receiptCounters(receipt)) {
const leaf = field.split('.').at(-1)!; const leaf = field.split('.').at(-1)!;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "gstack", "name": "gstack",
"version": "1.93.18", "version": "1.93.19",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
+51
View File
@@ -142,6 +142,57 @@ describe('the Review-loop exception line (GST-977, gstack-evals #139)', () => {
}); });
}); });
describe('the Documentation exception line (GST-983)', () => {
const excepted = {
...receipt,
gate: { status: 'green', run: '983', unit: { pass: 1181, skip: 70 } },
docs: { status: 'blocked', scope: 'docs/', exception: { accepted_by: 'Garry', date: '2026-11-02', via: 'GSTA-36', reason: 'sections 1 and 2 skip the audit' } },
};
const line = 'Documentation exception: accepted by Garry 2026-11-02 via GSTA-36: sections 1 and 2 skip the audit';
// Value: protects=a body that carries the docs 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 docs exception requires is not prose; the rest of the body still is', () => {
expect(checkReceiptProse(excepted, `## Documentation\n\n${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, a line for a different exception, or a receipt with no 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('GSTA-36', 'GSTA-37')).mismatches).toEqual(twoSkip);
expect(checkReceiptProse({ ...excepted, docs: { status: 'current', scope: 'docs/' } }, line).mismatches).toEqual(twoSkip);
});
});
describe('code fences are not prose (GST-984, gstack-evals #142)', () => {
const lanes = { ...receipt, gate: { status: 'green', run: '142', passed: 1181, failed: 0, unit: { pass: 1181, fail: 0 } } };
const evidence = '```text\n- // one none trial per band task (the first 35 fail) and one told trial per pilot task.\nr3-drop, r3, r3-sol: 113 passed, 0 failed with the real list\nunit: 113 pass, 0 fail\n```\n';
// Value: protects=pasted test output and quoted code in a fence never count as this PR's counters; fails_when=the
// fenced "113 passed" or "35 fail" is read against the receipt; seam=lib/ship-receipt-prose.ts checkReceiptProse
test('numbers inside a closed fence are skipped; counts outside it are still checked', () => {
expect(checkReceiptProse(lanes, `Gate: 1181 passed.\n\n${evidence}\nUnit: 1181 pass.\n`)).toEqual({ checked: 2, mismatches: [] });
expect(checkReceiptProse(lanes, `${evidence}\nUnit: 113 pass.\n`).mismatches).toEqual([{ field: 'gate.unit.pass', prose: 113, receipt: 1181 }]);
expect(checkReceiptProse(lanes, evidence.replace(/```/g, '~~~~').replace(/~~~~\n$/, '~~~~~\n'))).toEqual({ checked: 0, mismatches: [] });
});
test('a fence closes only on its own run, an unclosed fence stays prose, and the fence still separates the text around it', () => {
const short = '````\n113 pass\n```\nstill fenced 113 pass\n````\n';
expect(checkReceiptProse(lanes, short)).toEqual({ checked: 0, mismatches: [] });
expect(checkReceiptProse(lanes, 'Intro\n```text\n113 pass\n').mismatches).toEqual([{ field: 'gate.unit.pass', prose: 113, receipt: 1181 }]);
expect(checkReceiptProse(lanes, 'Shipped 3\n```\nx\n```\npass rates are below.\n')).toEqual({ checked: 0, mismatches: [] });
});
// Value: protects=a line that only looks like a fence opener, or a shorter fence inside an unclosed one, cannot hide a
// claim GitHub shows as prose; fails_when=either shape skips the "113 pass" after it; seam=lib/ship-receipt-prose.ts checkReceiptProse
test('inline triple backticks open no fence, and nothing after an unclosed fence is skipped', () => {
const wrong = [{ field: 'gate.unit.pass', prose: 113, receipt: 1181 }];
expect(checkReceiptProse(lanes, '```npm test``` ran.\nUnit: 113 pass.\n\n```text\nlog\n```\n').mismatches).toEqual(wrong);
expect(checkReceiptProse(lanes, '````\n```\nUnit: 113 pass.\n```\n').mismatches).toEqual(wrong);
});
});
describe('gstack-ship-receipt read --check-prose', () => { describe('gstack-ship-receipt read --check-prose', () => {
const run = (body: string) => { const run = (body: string) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'receipt-prose-')); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'receipt-prose-'));