v1.92.75.0 fix(ship): the receipt counts every blocker the readers found (GST-951) (#3845)

* fix(ship): the receipt counts every blocker the readers found (GST-951)

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

* fix(ship): match ledger blockers to rows by path:line; schema, prose and tests from review pass 1 (GST-951)

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

* fix(ship): pair each ledger blocker with one row finding; newest classification wins; ledgers_read (GST-951 Step 11 findings)

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

* fix(ship): pair ledger blockers by reviewer and per ledger; extensionless loci; pr-body names no targeted ledger (GST-951 round-2 findings, pending grant)

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

* docs: Step 14.5 audit (GST-951)

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 12:06:35 +00:00
1 parent f865cd9fc7
commit 82e9d7ec7a
25 files changed
+463 -41

No files matched your search

+5
View File
@@ -1,5 +1,10 @@
# Changelog
## [1.92.75.0] - 2026-11-02
- **The /ship receipt counts every blocker its readers found** (GST-951). `gstack-review-scope summary` read only the review-log rows, but /ship's readers report through the dispatch ledgers (`gstack-review-dispatch collect` and `judge`). So a blocker that only the native adversarial pass raised, a CRITICAL logged fixed without classify's blocking stamp, and findings logged before they were classified all left `blocking_found` at 0. That happened on #3841, #3838 and #3837. The summary now also reads each ledger's collected and judged CRITICAL findings, pairs each one with at most one row finding from the same reviewer at the same path:line so a blocker held in both places counts once and one classified below the floor does not, and counts an unclassified CRITICAL row as blocking. `unrecorded=N` names blockers no row gives a disposition, and `ledgers_read=N` says how many it read.
- **The summary refuses rather than undercount.** It now needs `--run <run dir>` (it finds every `review-ledger.jsonl` under it), `--ledger <f>`, or `--no-ledger` when no reader ran through `gstack-review-dispatch`. A missing choice, a run directory with no ledger, or an unreadable ledger, merged file or judged set, or a judged set changed since `judge`, is `REVIEW_SCOPE_LEDGER` with nothing written. /ship on Capy (`ship/DRIVER.md`) passes `--run`; the Claude Code flow passes `--no-ledger`.
## [1.92.74.0] - 2026-11-02
- **`gstack-pregate clear --run-url` needs a green run.** On a lane with no run profile, a run of the lane's own workflow that failed, was cancelled or timed out now refuses `PREGATE_RECEIPT_INVALID` (`run_not_success`), and one still queued or running refuses `run_in_progress`. The fix line says to rerun the workflow and clear with the green run. The check reads the run's newest attempt, so a job or attempt link is judged by the whole run, and the lane records the attempt it judged (`…/actions/runs/<id>/attempts/<n>`) instead of the link you passed. A green run still clears. docs/pregate.md now names `windows-smoke.yml/windows-smoke` as the free tier's Windows smoke lane, beside the narrower `free-tests.yml/cso-windows-launcher` and `windows-setup-e2e.yml/windows-setup` lanes. `test/pregate-clear-runs.test.ts` failed before the fix.
+1 -1
View File
@@ -1 +1 @@
1.92.74.0
1.92.75.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.92.74.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.92.75.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,
+1
View File
@@ -326,6 +326,7 @@ gstack_result_anchor() {
REGEN_DEPS_MISSING) printf '%s' 'regen-deps-missing' ;;
REVIEW_SCOPE_INPUT) printf '%s' 'review-scope-input' ;;
REVIEW_SCOPE_RANGE) printf '%s' 'review-scope-range' ;;
REVIEW_SCOPE_LEDGER) printf '%s' 'review-scope-ledger' ;;
RECEIPT_CARRY_REFUSED) printf '%s' 'receipt-carry-refused' ;;
PROSE_LINT_FOUND) printf '%s' 'prose-lint-found' ;;
REVIEW_LOOP_EXCEPTION_INVALID) printf '%s' 'review-loop-exception-invalid' ;;
+29 -5
View File
@@ -5,7 +5,7 @@
* gstack-review-scope classify --base <ref> --pass <n> [--range-base <sha>] [--policy frozen|diff-plus-blocking|all] [--findings <file>|-] [--json]
* gstack-review-scope defer --findings <classified.json> [--pr <n>] [--repo <owner/repo>] [--branch <name>] [--out <deferred.json>] [--dry-run] [--json]
* gstack-review-scope render --deferred <deferred.json> [--findings <classified.json>]
* gstack-review-scope summary --since <iso-timestamp> [--out <file>] [--json]
* gstack-review-scope summary --since <iso-timestamp> (--run <dir> | --ledger <f>... | --no-ledger) [--out <file>] [--json]
*
* `classify` reads a JSON array of findings (the Step 9.3 result) and stamps
* each with `locus` (diff | fix-range | outside), `kind` (defect | test-gap |
@@ -30,6 +30,7 @@ import {
parseHunks, renderDeferredIssue, renderDeferredSection, reviewLoopLine, scopeFindings, scopeLine, summarizeReviewLoop,
ALREADY_FILED_REASON, DEFERRED_MARKER, findingIdentity, findingWhere, REVIEW_SCOPE_POLICIES, type DeferredRecord, type ReviewScopePolicy, type ScopeFinding, type ScopedFinding,
} from '../lib/review-scope';
import { ledgerBlockers, type LedgerBlocker } from '../lib/review-ledger';
const HELP = `gstack-review-scope — finding scope for /ship's review loop (docs/ship-policy.md#review-scope)
@@ -42,8 +43,12 @@ const HELP = `gstack-review-scope — finding scope for /ship's review loop (doc
render --deferred <file> [--findings <classified.json>]
the PR-body sections: ## Deferred findings (references, never closing keywords)
and ## Review notes (scope=note findings)
summary --since <iso> [--out <file>] the invocation's passes from this branch's review log: REVIEW_LOOP: passes=…
blocking_found=… deferred=… noted=… (the ship receipt's --review-loop input)
summary --since <iso> (--run <dir> | --ledger <f>... | --no-ledger) [--out <file>]
the invocation's passes from this branch's review log: REVIEW_LOOP: passes=…
blocking_found=… deferred=… noted=… (the ship receipt's --review-loop input);
blocking_found also counts every CRITICAL the dispatch ledgers collected or judged
(--run finds each review-ledger.jsonl under the run directory); unrecorded=N names
blockers no row gives a disposition. An unreadable ledger: REVIEW_SCOPE_LEDGER
Defaults: --policy from the base branch's .gstack/ship-policy.json review_scope (frozen); an unresolvable base refuses
(POLICY_SOURCE_UNAVAILABLE) unless --policy is passed; --findings - reads stdin; --branch the current branch.
@@ -117,7 +122,7 @@ function readFindings(spec: string | undefined, what: string): ScopeFinding[] {
if (!list || !list.every((f: unknown) => f && typeof f === 'object' && !Array.isArray(f))) throw new Error(`${what}: expected a JSON array of finding objects`);
return list as ScopeFinding[];
}
function refuse(cmd: string, code: 'REVIEW_SCOPE_INPUT' | 'REVIEW_SCOPE_RANGE', detail: string): number {
function refuse(cmd: string, code: 'REVIEW_SCOPE_INPUT' | 'REVIEW_SCOPE_RANGE' | 'REVIEW_SCOPE_LEDGER', detail: string): number {
process.stderr.write(resultLineWithDocs(`gstack-review-scope ${cmd}`, code, detail) + '\n');
return EXIT.fail;
}
@@ -245,7 +250,26 @@ function cmdSummary(rest: string[], json: boolean): number {
if (!line.trim() || line === 'NO_REVIEWS') continue;
try { rows.push(JSON.parse(line)); } catch { /* malformed row: gstack-review-read already warned */ }
}
const summary = summarizeReviewLoop(rows, since);
const files: string[] = [];
for (let i = 0; i < rest.length; i++) {
if (rest[i] !== '--ledger') continue;
if (!rest[i + 1] || rest[i + 1]!.startsWith('--')) return refuse('summary', 'REVIEW_SCOPE_INPUT', '--ledger needs a file');
files.push(rest[++i]!);
}
const run = flag(rest, '--run');
if (run) {
let found: string[];
try { found = (fs.readdirSync(run, { recursive: true }) as string[]).filter(p => path.basename(p) === 'review-ledger.jsonl').sort().map(p => path.join(run, p)); } catch (e: any) { return refuse('summary', 'REVIEW_SCOPE_LEDGER', `--run ${run}: ${e.code ?? e.message}`); }
if (!found.length) return refuse('summary', 'REVIEW_SCOPE_LEDGER', `--run ${run} holds no review-ledger.jsonl: name the run directory the passes wrote, or each --ledger`);
files.push(...found);
}
const noLedger = rest.includes('--no-ledger');
if (noLedger && files.length) return refuse('summary', 'REVIEW_SCOPE_INPUT', '--no-ledger contradicts --run and --ledger');
// The ledgers hold every reader's findings, the voices' too, before any row classifies them; a summary without them can undercount.
if (!noLedger && !files.length) return refuse('summary', 'REVIEW_SCOPE_LEDGER', 'name the review\'s dispatch ledgers (--run <run dir> or --ledger <f>), or --no-ledger when no reader ran through gstack-review-dispatch');
let ledgers: LedgerBlocker[][];
try { ledgers = files.map(ledgerBlockers); } catch (e: any) { return refuse('summary', 'REVIEW_SCOPE_LEDGER', e.message); }
const summary = summarizeReviewLoop(rows, since, ledgers);
const line = reviewLoopLine(summary);
// A /ship invocation always runs Step 9 once; zero matching rows means the wrong --since or a review log written elsewhere, never a loop to record.
if (summary.passes === 0) return refuse('summary', 'REVIEW_SCOPE_INPUT', `no review-log row of this branch is a via:ship review at or after ${since}; check REVIEW_SINCE (${line})`);
+7 -4
View File
@@ -387,14 +387,17 @@ narrowed to the gate scan's tagged hunks.
## What the receipt records
`gstack-review-scope summary --since <REVIEW_SINCE>` reads this invocation's
review-log rows and writes the counters `gstack-ship-receipt write --review-loop`
`gstack-review-scope summary --since <REVIEW_SINCE> --run <run dir>` reads this invocation's
review-log rows and the dispatch ledgers under the run directory (`--ledger <f>` names one;
`--no-ledger` when no reader ran through `gstack-review-dispatch`) and writes the counters `gstack-ship-receipt write --review-loop`
puts in the receipt:
| Field | Meaning |
|---|---|
| `review_loop.passes` | full review passes this ship ran (1 or 2; up to 3 under `review_scope: all`) |
| `review_loop.blocking_found` | distinct blocking findings across the passes |
| `review_loop.blocking_found` | every blocker found (GST-951): the rows' blocking findings (classified, or an unclassified CRITICAL), plus each ledger CRITICAL (as collected or judged) that no row finding from the same reviewer at the same path:line accounts for (each row finding pairs with one per ledger), so an adversarial, outside-voice or not-yet-classified blocker counts and one whose newest classification is below the floor does not. An unreadable ledger, merged file or judged set, or a judged set changed since `judge`, is `REVIEW_SCOPE_LEDGER`, never a count |
| `review_loop.unrecorded` | blockers found that no row gives a disposition (fixed, deferred, noted, skipped, a gate answer); absent when 0 |
| `review_loop.ledgers_read` | dispatch ledgers the count read; absent under `--no-ledger` |
| `review_loop.deferred` | blocking findings in unchanged code filed as issues (a Step 11 late defer, logged as a `review-late-defer` row so it never displaces the newest `review` pass, counts here and is not a pass) |
| `review_loop.noted` | below-floor findings listed in the PR body |
| `review_loop.fixed` | findings fixed in this PR |
@@ -611,7 +614,7 @@ ones, and the arm lost blockers. `bin/gstack-review-dispatch` closes both ends:
- `ship/sections/review-army.md` Step 9: the plan's `REVIEW_DEPTH`, `GATE_SCAN` and `--depth-floor` on every run, the depth fields of the review-log row, passes, `RANGE_BASE`, classify, defer, render, the cap check before any edit, the Step 11 late-defer row, scratch files under `.gstack/tmp/`, the cap decisions; `ship/sections/adversarial.md` Step 11: the outside voice reads the same range and scopes queued fixes the same way.
- `lib/review-depth.ts`, `bin/gstack-review-plan`: the diff class, the depth (`depthFor`, `DEPTH_RULES`, `depthOfCommitInput`), each dispatch's `exclude` pathspecs and `DIFF_CMD`, the transition table, the numeric-claim scan and each role's effort tier (`test/review-depth.test.ts`, `test/review-plan-cli.test.ts`).
- `lib/review-collect.ts`: `collectCommit` (git objects only, from any checkout) and `collectLive` (the working tree), per-path lines and the base commit's registry (`test/review-collect.test.ts`).
- `lib/review-scope.ts`, `bin/gstack-review-scope`: `classify`, `defer`, `render`, `summary` (with `fix_checks`, `dispatches`, `est_input_tokens`); refusals are `REVIEW_SCOPE_INPUT` and `REVIEW_SCOPE_RANGE` ([troubleshooting.md](troubleshooting.md#review-scope-input)).
- `lib/review-scope.ts`, `bin/gstack-review-scope`: `classify`, `defer`, `render`, `summary` (with `fix_checks`, `dispatches`, `est_input_tokens`, `unrecorded`, `ledgers_read`); refusals are `REVIEW_SCOPE_INPUT`, `REVIEW_SCOPE_RANGE` and `REVIEW_SCOPE_LEDGER` ([troubleshooting.md](troubleshooting.md#review-scope-input)).
- `lib/ship-policy.ts`: the `review_scope` key and its `ship: review_scope …` consequence line.
- `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)).
+7 -6
View File
@@ -1268,11 +1268,11 @@ construction:
the run and names the finding (path, severity, problem) for the owner instead
of a third round; the owner may grant one scoped fix-check.
`gstack-review-scope summary --since <REVIEW_SINCE>` reads the branch's review-log
rows for this invocation and writes the counters the ship receipt carries
`gstack-review-scope summary --since <REVIEW_SINCE> --run <run dir>` reads the branch's review-log
rows for this invocation and its dispatch ledgers, and writes the counters the ship receipt carries
(`gstack-ship-receipt write … --review-loop @<file>`): `review_loop: { passes,
blocking_found, deferred, noted, fixed, scope }`, plus `fix_checks`, `dispatches`
and `est_input_tokens` when the run recorded them, printed on the receipt summary
blocking_found, deferred, noted, fixed, scope }`, plus `fix_checks`, `dispatches`,
`est_input_tokens`, `unrecorded` and `ledgers_read` when the run recorded them, printed on the receipt summary
line as `passes=<n> blocking_found=<k> deferred=<m>`, so a scorecard can track
passes per PR.
@@ -1286,8 +1286,9 @@ instead of `defer`. `review_scope: all` is the opt-back to the earlier loop:
every finding is `scope: fix` on every pass, reviewers read the whole diff each
time, and only the three-cycle fix cap stops the loop. `show-effective` prints the choice as a `ship: review_scope …`
line, and the codes a refusal ends with are
[`REVIEW_SCOPE_INPUT`](troubleshooting.md#review-scope-input) and
[`REVIEW_SCOPE_RANGE`](troubleshooting.md#review-scope-range).
[`REVIEW_SCOPE_INPUT`](troubleshooting.md#review-scope-input),
[`REVIEW_SCOPE_RANGE`](troubleshooting.md#review-scope-range) and
[`REVIEW_SCOPE_LEDGER`](troubleshooting.md#review-scope-ledger).
<a id="base-red"></a>
## `base_red`: pausing /ship on a red base
+18
View File
@@ -5660,6 +5660,24 @@ pass).
the invocation record, and start a full pass (`--pass 1`) when the branch was
rebased; /ship's own rule is to merge the base in, never rebase a reviewed head.
<a id="review-scope-ledger"></a>
### `REVIEW_SCOPE_LEDGER`
**Meaning.** `gstack-review-scope summary` could not count the review's
dispatch ledgers, so it wrote no `REVIEW_LOOP:` line rather than a count that
might be low (GST-951). Either no ledger was named (`--run`, `--ledger` or
`--no-ledger`), `--run` holds no `review-ledger.jsonl`, or a ledger, the
`merged.jsonl` it names or its judged set is unreadable, or the judged set
changed after `judge` checked it. The ledgers hold every reader's findings
(specialists, Red Team, both adversarial voices, targeted, fix-check) before any
review-log row classifies them, which is why the count reads them.
**Fix.** Pass `--run <run dir>`, the directory the review passes wrote (on
Capy, `ship/DRIVER.md`'s run directory), or each `--ledger <f>`. Pass
`--no-ledger` only when no reader ran through `gstack-review-dispatch`. When a
file moved, restore it; when a judged set changed, rerun `gstack-review-dispatch
judge` on it.
<a id="receipt-carry-refused"></a>
### `RECEIPT_CARRY_REFUSED`
+2 -1
View File
@@ -172,7 +172,8 @@ unpushed head is `QUEUE_RECEIPT_STALE` to the queue.
The receipt's `review_loop` object, written from `gstack-review-scope summary
--since <REVIEW_SINCE>`, is the loop's machine-readable outcome: `passes`
(Step 9 full passes this ship ran, 1 or 2; up to 3 under `review_scope: all`),
`blocking_found` (distinct blocking findings across them), `deferred`
`blocking_found` (every blocker the readers found, rows and dispatch ledgers
both, [review-policy.md](review-policy.md#what-the-receipt-records)), `deferred`
(blocking findings in unchanged code filed as issues, Step 11's late defers
included), `noted`, `fixed` and `scope`. The
summary line a parent reads prints it as `passes=<n> blocking_found=<k>
+2 -2
View File
@@ -106,7 +106,7 @@ export interface ShipReceipt {
// Related open PRs by other authors (Lane D): `supersedes` / `credit` only on the owner's confirmation.
issues?: { fixes: number[]; refs: number[]; flakes: Array<{ issue: number; run: string }>; related?: Array<{ pr: number; repo?: string; author: string; closes: number[]; supersedes?: boolean; credit?: boolean }> };
/** Harness lane E: the review loop's counters (gstack-review-scope summary) and the carry record when the receipt followed a head whose product diff was unchanged (gstack-ship-receipt carry). */
review_loop?: { passes: number; blocking_found: number; deferred: number; noted?: number; fixed?: number; scope?: string; fix_checks?: number; rounds?: number; dispatches?: Record<string, number>; est_input_tokens?: number };
review_loop?: { passes: number; blocking_found: number; deferred: number; noted?: number; fixed?: number; scope?: string; fix_checks?: number; rounds?: number; dispatches?: Record<string, number>; est_input_tokens?: number; unrecorded?: number; ledgers_read?: number };
carry?: { from: string; reason: string; product_patch_id: string; changed_paths: string[]; merge_commits: number; range_diff: string; covers: string[]; reruns: string[]; trees?: string[]; ancestry?: 'linear' | 'rewritten' };
/** The reviewed head's tree and its product patch-id against the base (gstack-ship-receipt write; carry rebinds both): what a carry across rewritten history compares. */
head_tree?: string; product_patch_id?: string;
@@ -238,7 +238,7 @@ export const ARTIFACT_SCHEMAS = {
thread: str, tree: { enum: ['same-modulo-stamps', 'changed', 'not-compared'] }, gate_reuse: str, policy: str, queue_mode: str, preregistration: str, history: str,
pregate: { ...str, description: 'pass|warn|requires-remote cleared … @<tree> from pregate.json, current for this head' }, requires_remote: strList,
approval: { type: 'object', description: 'approval classes: { source, status: checked|unavailable, classes: [{ class, scope, effects, citation: verified|unverified, approved_by, date, via, binding, evidence }], report_only }' },
review_loop: { type: 'object', required: ['passes', 'blocking_found', 'deferred'], properties: { passes: { type: 'integer', minimum: 0 }, blocking_found: { type: 'integer', minimum: 0 }, deferred: { type: 'integer', minimum: 0 }, noted: { type: 'integer', minimum: 0 }, fixed: { type: 'integer', minimum: 0 }, scope: str, fix_checks: { type: 'integer', minimum: 0 }, rounds: { type: 'integer', minimum: 0 }, dispatches: { type: 'object', description: 'reviewer dispatches by role' }, est_input_tokens: { type: 'integer', minimum: 0 } } },
review_loop: { type: 'object', required: ['passes', 'blocking_found', 'deferred'], properties: { passes: { type: 'integer', minimum: 0 }, blocking_found: { type: 'integer', minimum: 0 }, deferred: { type: 'integer', minimum: 0 }, noted: { type: 'integer', minimum: 0 }, fixed: { type: 'integer', minimum: 0 }, scope: str, fix_checks: { type: 'integer', minimum: 0 }, rounds: { type: 'integer', minimum: 0 }, dispatches: { type: 'object', description: 'reviewer dispatches by role' }, est_input_tokens: { type: 'integer', minimum: 0 }, unrecorded: { type: 'integer', minimum: 0 }, ledgers_read: { type: 'integer', minimum: 0 } } },
carry: { type: 'object', required: ['from', 'reason', 'product_patch_id'], properties: { from: str, reason: str, product_patch_id: str, changed_paths: strList, merge_commits: { type: 'integer', minimum: 0 }, range_diff: str, covers: strList, reruns: strList, trees: strList, ancestry: { enum: ['linear', 'rewritten'] } } },
head_tree: str, product_patch_id: str, gstack_version: str,
base_red: { type: 'object', required: ['cases', 'files', 'base', 'lane', 'expires_at'], properties: { cases: strList, files: strList, base: str, lane: str, expires_at: str } },
+5
View File
@@ -360,6 +360,11 @@ export const RELEASE_RESULT_CODES = {
summary: 'gstack-review-scope could not read the diff it scopes findings against: the base or the range base does not resolve, or the range base is not an ancestor of HEAD',
fix: 'fetch origin/<base>, check --range-base against the saved pass heads, and start a full pass (--pass 1) when history was rewritten',
},
REVIEW_SCOPE_LEDGER: {
anchor: 'review-scope-ledger',
summary: 'gstack-review-scope summary could not count the dispatch ledgers: none was named (no --run, --ledger or --no-ledger), --run holds no review-ledger.jsonl, or a ledger, its merged file or its judged set is unreadable or changed since judge',
fix: 'pass --run <run dir> (the directory the review passes wrote) or each --ledger <f>; --no-ledger only when no reader ran through gstack-review-dispatch; restore or re-collect a ledger file that moved',
},
RECEIPT_CARRY_REFUSED: {
anchor: 'receipt-carry-refused',
summary: 'gstack-ship-receipt carry could not carry the /ship receipt to HEAD: the PR body has no receipt, the working tree has uncommitted changes, the receipt head is not an ancestor of HEAD and the receipt records no tree to compare, or the product diff could not be compared',
+47
View File
@@ -236,6 +236,53 @@ export function recordJudgment(ledger: string, merged: MergedLine[], judged: Jud
return res;
}
/**
* The blocking findings a run's ledger holds (GST-951): every collected line whose label ranks CRITICAL, raised to its judged
* severity when the newest judge accepted a judged set that is unchanged since; lead judgment's additions count and a folded
* duplicate does not. Every reader counts the same way, whatever it is and whether or not Step 9.4 classified its findings.
* Each carries its locus (a JSON finding's path:line, else the first path:line its text names, an extensionless bin/ path
* included) so the summary can find its row.
* A ledger, merged file or judged set it cannot read, or a judged set changed since judge, throws: the count is never a guess.
*/
export interface LedgerBlocker { id: string; locus: string | null }
function lineLocus(raw: string): string | null {
try {
const o = JSON.parse(raw);
if (o && typeof o.path === 'string' && Number.isInteger(o.line)) return `${o.path.replace(/^\.\//, '')}:${o.line}`;
} catch { /* a prose finding */ }
return /(?:[\w.-]+\/)+[\w.-]+:\d+|[\w.-]+\.\w+:\d+/.exec(raw)?.[0]?.replace(/^\.\//, '') ?? null;
}
export function ledgerBlockers(ledger: string): LedgerBlocker[] {
const fail = (why: string): never => { throw new Error(`${ledger}: ${why}`); };
const read = (file: unknown, what: string): string => {
try { return fs.readFileSync(String(file), 'utf8'); } catch { return fail(`${what} ${String(file)} is unreadable`); }
};
const jsonLines = (text: string, what: string): any[] =>
text.split('\n').filter(l => l.trim()).map(l => { try { return JSON.parse(l); } catch { return fail(`${what} has a line that is not JSON`); } });
const events = jsonLines(read(ledger, 'ledger'), 'the ledger') as LedgerEvent[];
const ranks = new Map<string, number>();
const loci = new Map<string, string | null>();
for (const o of events.filter(e => e.event === 'output' && Array.isArray(e.ids) && e.ids.length)) {
const lines = jsonLines(read(o.merged, `merged file of ${String(o.reader)}`), String(o.merged)) as MergedLine[];
for (const id of o.ids as string[]) {
const line = lines.find(l => l.id === id) ?? fail(`collected id ${id} is missing from ${String(o.merged)}`);
ranks.set(id, rank(line.label) ?? 0);
loci.set(id, lineLocus(line.raw));
}
}
const verdict = [...events].reverse().find(e => e.event === 'judge');
if (verdict?.ok === true) {
const text = read(verdict.judged, 'judged set');
if (promptHash(text) !== verdict.judged_sha256) fail(`judged set ${String(verdict.judged)} changed since judge`);
for (const j of jsonLines(text, String(verdict.judged)) as JudgedLine[]) {
if (j.duplicate_of) { ranks.delete(j.id); continue; }
ranks.set(j.id, Math.max(ranks.get(j.id) ?? 0, rank(j.severity) ?? 0));
if (!loci.has(j.id)) loci.set(j.id, lineLocus(j.reason ?? ''));
}
}
return [...ranks].filter(([, r]) => r >= 4).map(([id]) => ({ id, locus: loci.get(id) ?? null }));
}
/**
* Whether a recorded output was a review: it has findings, or its file (unchanged since collect) carries the reader's clean marker, so
* a ledger written before collect recorded the field is judged by the same rule; with no readable file, collect's own review field.
+62 -4
View File
@@ -14,6 +14,7 @@
* Pure functions here; bin/gstack-review-scope runs git and gh.
*/
import { SPECIALISTS } from './review-depth';
import { rank, type LedgerBlocker } from './review-ledger';
import { parseSpecialistOutput } from './state-matrix';
export type Locus = 'diff' | 'fix-range' | 'outside';
@@ -306,6 +307,10 @@ export interface ReviewLoopSummary {
dispatches?: Record<string, number>;
/** Prompt plus diff bytes the dispatches read, / 4: a relative estimate, never a bill. */
est_input_tokens?: number;
/** Blocking findings no row gives a disposition (fixed, deferred, noted, skipped or a gate answer): found, outcome unrecorded. */
unrecorded?: number;
/** Dispatch ledgers whose collected and judged findings were counted (gstack-review-dispatch). */
ledgers_read?: number;
}
/** Review-log rows of one /ship invocation (skill review, via ship, at or after `since`) → the loop summary. */
@@ -348,12 +353,41 @@ function rowDispatches(row: Record<string, any>): Array<{ role: string; bytes: n
.map(([name]) => ({ role: name === 'red-team' ? 'red-team' : 'specialist', bytes: null }));
}
export function summarizeReviewLoop(rows: readonly Record<string, any>[], since: string): ReviewLoopSummary {
/** Row actions that record what happened to a finding. */
const DISPOSITIONS = ['fixed', 'auto-fixed', 'deferred', 'noted', 'skipped', 'kept', 'restored', 'open'];
/** A row finding that blocks: classified blocking, or an unclassified CRITICAL defect (logged before Step 9.4's classify). */
const rowBlocks = (f: Record<string, any>) => f.blocking === true || (f.blocking === undefined && f.advisory !== true && rank(f.severity) === 4);
/** The reviewer behind a row's raised_by or a ledger id: every specialist is one; the lead's additions and unknowns name none. */
function raiserOf(raiser: unknown): string | null {
const role = typeof raiser === 'string' ? raiser.split(/[/#]/)[0]!.trim() : '';
if (!role || role === 'unknown' || role === 'lead' || role === 'core') return null;
return ['qa', 'red-team', 'adversarial-native', 'outside-voice', 'fix-check', 'targeted'].includes(role) ? role : 'specialist';
}
/** A row finding's path:line, from its path and line or its fingerprint (`path:line:category`). */
function rowLocus(f: Record<string, any>): string | null {
if (typeof f.path === 'string' && Number.isInteger(f.line)) return `${f.path.replace(/^\.\//, '')}:${f.line}`;
const m = /^(.+?):(\d+):/.exec(typeof f.fingerprint === 'string' ? f.fingerprint : '');
return m ? `${m[1]!.replace(/^\.\//, '')}:${m[2]}` : null;
}
/**
* The loop summary. blocking_found counts every blocker found (GST-951). Each CRITICAL the dispatch ledgers hold
* (`ledgerBlockers`, one list per ledger) is paired with at most one row finding per ledger at the same path:line, raised
* by the same reviewer (or naming none; a fix-check re-reads every reviewer's fixes), that may be it (no severity yet, CRITICAL as logged or before a mapper lowered
* it, or blocking), so one finding re-raised by a later pass's ledger pairs again. A paired finding blocks unless its newest
* classification put it below the floor (blocking: false), and its row's action is its disposition. An unpaired ledger
* CRITICAL blocks and is unrecorded. A row finding blocks by its newest classification, else as an unclassified CRITICAL.
* A ledger CRITICAL with no locus, or a second one at a paired line, counts on its own. A different defect the same reviewer
* raises at the exact line of a finding a row classified below the floor pairs with that finding: path:line is the only
* identity rows and ledgers share until rows carry the ledger id.
*/
export function summarizeReviewLoop(rows: readonly Record<string, any>[], since: string, ledgers: readonly (readonly LedgerBlocker[])[] = []): ReviewLoopSummary {
const sinceMs = Date.parse(since);
const inRun = (r: Record<string, any>) => r && r.via === 'ship' && Number.isFinite(rowTime(r)) && (!Number.isFinite(sinceMs) || rowTime(r) >= sinceMs);
const loopRows = rows.filter(r => inRun(r) && (r.skill === 'review' || r.skill === LATE_DEFER_SKILL || r.skill === FIX_CHECK_SKILL));
const passes = loopRows.filter(r => r.skill === 'review');
const blocking = new Set<string>();
type Seen = { locus: string | null; raiser: string | null; classified: boolean; blocks: boolean; candidate: boolean; disposed: boolean };
const seen = new Map<string, Seen>();
const deferred = new Set<string>();
const noted = new Set<string>();
const fixed = new Set<string>();
@@ -363,13 +397,36 @@ export function summarizeReviewLoop(rows: readonly Record<string, any>[], since:
for (const f of Array.isArray(row.findings) ? row.findings : []) {
if (!f || typeof f !== 'object') continue;
const id = findingIdentity(f);
if (f.blocking === true) blocking.add(id);
const prior = seen.get(id);
const classified = typeof f.blocking === 'boolean';
seen.set(id, {
locus: rowLocus(f) ?? prior?.locus ?? null,
raiser: prior?.raiser ?? raiserOf(f.raised_by),
classified: classified || !!prior?.classified,
blocks: classified ? f.blocking : prior?.classified ? prior.blocks : !!prior?.blocks || rowBlocks(f),
candidate: !!prior?.candidate || f.severity === undefined || rank(f.severity) === 4 || rank(f.raw_severity) === 4 || f.blocking === true,
disposed: !!prior?.disposed || DISPOSITIONS.includes(f.action),
});
if (f.action === 'deferred') deferred.add(id);
if (f.action === 'noted') noted.add(id);
if (f.action === 'fixed' || f.action === 'auto-fixed') fixed.add(id);
}
}
const out: ReviewLoopSummary = { passes: passes.length, blocking_found: blocking.size, deferred: deferred.size, noted: noted.size, fixed: fixed.size, scope };
const paired = new Set<string>();
let ledgerOnly = 0;
const pairedIds = new Set<string>();
for (const [n, list] of ledgers.entries()) {
for (const b of list) {
const by = b.id.startsWith('fix-check') ? null : raiserOf(b.id);
const id = b.locus ? [...seen].find(([k, s]) => s.candidate && s.locus === b.locus && (!s.raiser || !by || s.raiser === by) && !paired.has(`${n}\0${k}`))?.[0] : undefined;
if (id) { paired.add(`${n}\0${id}`); pairedIds.add(id); } else ledgerOnly++;
}
}
const blockers = [...seen].filter(([k, s]) => s.blocks || (pairedIds.has(k) && !s.classified));
const unrecorded = ledgerOnly + blockers.filter(([, s]) => !s.disposed).length;
const out: ReviewLoopSummary = { passes: passes.length, blocking_found: blockers.length + ledgerOnly, deferred: deferred.size, noted: noted.size, fixed: fixed.size, scope };
if (unrecorded) out.unrecorded = unrecorded;
if (ledgers.length) out.ledgers_read = ledgers.length;
const fixChecks = loopRows.filter(r => r.skill === FIX_CHECK_SKILL).length;
if (fixChecks) out.fix_checks = fixChecks;
out.rounds = passes.length + fixChecks;
@@ -390,5 +447,6 @@ export function dispatchTotal(d: Record<string, number> | undefined): number | u
export function reviewLoopLine(s: ReviewLoopSummary): string {
const total = dispatchTotal(s.dispatches);
return `REVIEW_LOOP: passes=${s.passes} blocking_found=${s.blocking_found} deferred=${s.deferred} noted=${s.noted} fixed=${s.fixed} scope=${s.scope}`
+ (s.unrecorded ? ` unrecorded=${s.unrecorded}` : '') + (s.ledgers_read ? ` ledgers_read=${s.ledgers_read}` : '')
+ (s.fix_checks ? ` fix_checks=${s.fix_checks}` : '') + (total !== undefined ? ` dispatches=${total}` : '') + (s.est_input_tokens !== undefined ? ` est_input_tokens=${s.est_input_tokens}` : '');
}
+2
View File
@@ -328,6 +328,8 @@ export function parseReviewLoop(value: unknown): ReviewLoopReceipt | undefined {
if (n('fix_checks') !== undefined) out.fix_checks = n('fix_checks');
if (n('rounds') !== undefined) out.rounds = n('rounds');
if (n('est_input_tokens') !== undefined) out.est_input_tokens = n('est_input_tokens');
if (n('unrecorded')) out.unrecorded = n('unrecorded');
if (n('ledgers_read')) out.ledgers_read = n('ledgers_read');
if (v.dispatches && typeof v.dispatches === 'object' && !Array.isArray(v.dispatches)) {
const d = Object.fromEntries(Object.entries(v.dispatches as Record<string, unknown>)
.filter(([k, c]) => /^[a-z][a-z0-9-]{0,39}$/.test(k) && typeof c === 'number' && Number.isInteger(c) && c >= 0).slice(0, 12)) as Record<string, number>;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.92.74",
"version": "1.92.75",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+2 -2
View File
@@ -14,7 +14,7 @@ gates and the report. `STOP:` (exit 3) means nothing after it ran; `NEXT:` names
the next act. Read a section only when a phase or the list at the end sends you, never
up front. A STOP or decision this file does not cover: read `ship/SKILL.md` at that step, finish it
there, come back. Unattended, take a question's recommended option (log it); gate
dispositions and approvals stay pending and end the run. Keep a private invocation record (`--record`): write its six headings at Phase 1, then fill each as its phase ends: **Checks**
dispositions and approvals stay pending and end the run. Keep a private invocation record (`--record`): write its six headings at Phase 1, fill each as its phase ends: **Checks**
(Phases 2-4: lanes, pregate, evals, `Coverage:`, the plan checks, `Exploratory QA:`, `Scope Check:`), **Decisions** (any
phase: each answered or auto-taken question, verbatim), **Reviews** (Phases 3-5: the plan line,
start tokens, `Pre-Landing Review:`, `Gate edits:`, the reviewed tree), **Release** (Phases 5-7:
@@ -119,7 +119,7 @@ STALE or MISSING reruns once), `PREGATE:`; print them, the last `Gate edits:` an
## 8. Publish: Steps 18, 19, 19-C, 20, 21, 22
- Step 18: the open-PR lookup; a title file starting `v<NEW_VERSION> `. `UNATTENDED_SHIP:` with no open PR at HEAD takes Step 18-H.
- Step 19 body: the receipt block, then the sections `sections/pr-body.md` lists (Pre-Landing Review: `REVIEW_LOOP:`, `Gate edits:`, deferred, notes), led under `CAPY_PR: on` (Step 19-C) by What changed, Why, Blast radius and Approvals (`gstack-approvals render`). `gstack-prose-lint <top> --sections 'Why,Summary,Blast radius' --gate`; `gstack-issue-links`; `gstack-review-scope summary --since "$REVIEW_SINCE" && gstack-ship-receipt write --pr <n> --base <base> --gate @<f> --pregate <f> --issues @<f> --review-loop @<f> --docs @<f> --body <body>`; `gstack-post pr-body` and `pr-title` (a new PR via the host's PR tool; a draft while a gate finding is `open`). `SHIP_RECEIPT:` opens your report.
- Step 19 body: the receipt block, then the sections `sections/pr-body.md` lists (Pre-Landing Review: `REVIEW_LOOP:`, `Gate edits:`, deferred, notes), led under `CAPY_PR: on` (Step 19-C) by What changed, Why, Blast radius and Approvals (`gstack-approvals render`). `gstack-prose-lint <top> --sections 'Why,Summary,Blast radius' --gate`; `gstack-issue-links`; `gstack-review-scope summary --since "$REVIEW_SINCE" --run <dir> && gstack-ship-receipt write --pr <n> --base <base> --gate @<f> --pregate <f> --issues @<f> --review-loop @<f> --docs @<f> --body <body>`; `gstack-post pr-body` and `pr-title` (a new PR via the host's PR tool; draft while a gate finding is `open`). `SHIP_RECEIPT:` opens your report.
- Step 20: the metrics row via `gstack-review-log`.
- Step 22: `gstack-ship-budget close --reading R --credits C` (report `BUDGET_FINAL:`, `BUDGET_TIMELINE:` (tokens, credits), any `METER:` line), then `gstack-ship-receipt result --pr <n>`: `GSTACK_RESULT: skill=ship status=complete run=<dir>`. Under a merge queue, `gstack-ship-queue ready --pr <n> [--screen|--no-screen]` at the pushed head; paste its output.
+2 -2
View File
@@ -12,7 +12,7 @@ gates and the report. `STOP:` (exit 3) means nothing after it ran; `NEXT:` names
the next act. Read a section only when a phase or the list at the end sends you, never
up front. A STOP or decision this file does not cover: read `ship/SKILL.md` at that step, finish it
there, come back. Unattended, take a question's recommended option (log it); gate
dispositions and approvals stay pending and end the run. Keep a private invocation record (`--record`): write its six headings at Phase 1, then fill each as its phase ends: **Checks**
dispositions and approvals stay pending and end the run. Keep a private invocation record (`--record`): write its six headings at Phase 1, fill each as its phase ends: **Checks**
(Phases 2-4: lanes, pregate, evals, `Coverage:`, the plan checks, `Exploratory QA:`, `Scope Check:`), **Decisions** (any
phase: each answered or auto-taken question, verbatim), **Reviews** (Phases 3-5: the plan line,
start tokens, `Pre-Landing Review:`, `Gate edits:`, the reviewed tree), **Release** (Phases 5-7:
@@ -117,7 +117,7 @@ STALE or MISSING reruns once), `PREGATE:`; print them, the last `Gate edits:` an
## 8. Publish: Steps 18, 19, 19-C, 20, 21, 22
- Step 18: the open-PR lookup; a title file starting `v<NEW_VERSION> `. `UNATTENDED_SHIP:` with no open PR at HEAD takes Step 18-H.
- Step 19 body: the receipt block, then the sections `sections/pr-body.md` lists (Pre-Landing Review: `REVIEW_LOOP:`, `Gate edits:`, deferred, notes), led under `CAPY_PR: on` (Step 19-C) by What changed, Why, Blast radius and Approvals (`gstack-approvals render`). `gstack-prose-lint <top> --sections 'Why,Summary,Blast radius' --gate`; `gstack-issue-links`; `gstack-review-scope summary --since "$REVIEW_SINCE" && gstack-ship-receipt write --pr <n> --base <base> --gate @<f> --pregate <f> --issues @<f> --review-loop @<f> --docs @<f> --body <body>`; `gstack-post pr-body` and `pr-title` (a new PR via the host's PR tool; a draft while a gate finding is `open`). `SHIP_RECEIPT:` opens your report.
- Step 19 body: the receipt block, then the sections `sections/pr-body.md` lists (Pre-Landing Review: `REVIEW_LOOP:`, `Gate edits:`, deferred, notes), led under `CAPY_PR: on` (Step 19-C) by What changed, Why, Blast radius and Approvals (`gstack-approvals render`). `gstack-prose-lint <top> --sections 'Why,Summary,Blast radius' --gate`; `gstack-issue-links`; `gstack-review-scope summary --since "$REVIEW_SINCE" --run <dir> && gstack-ship-receipt write --pr <n> --base <base> --gate @<f> --pregate <f> --issues @<f> --review-loop @<f> --docs @<f> --body <body>`; `gstack-post pr-body` and `pr-title` (a new PR via the host's PR tool; draft while a gate finding is `open`). `SHIP_RECEIPT:` opens your report.
- Step 20: the metrics row via `gstack-review-log`.
- Step 22: `gstack-ship-budget close --reading R --credits C` (report `BUDGET_FINAL:`, `BUDGET_TIMELINE:` (tokens, credits), any `METER:` line), then `gstack-ship-receipt result --pr <n>`: `GSTACK_RESULT: skill=ship status=complete run=<dir>`. Under a merge queue, `gstack-ship-queue ready --pr <n> [--screen|--no-screen]` at the pushed head; paste its output.
+4 -2
View File
@@ -220,7 +220,7 @@ per-lane counts (`{"<lane>":{"pass":N,"fail":N,"skip":N}}`) to
```bash
rm -f .gstack/tmp/review-loop.json
~/.claude/skills/gstack/bin/gstack-review-scope summary --since "$REVIEW_SINCE" --out .gstack/tmp/review-loop.json &&
~/.claude/skills/gstack/bin/gstack-review-scope summary --since "$REVIEW_SINCE" --no-ledger --out .gstack/tmp/review-loop.json &&
~/.claude/skills/gstack/bin/gstack-ship-receipt write --pr <pr-number> --base <base> --gate @.gstack/tmp/ship-gate.json --pregate .gstack/tmp/pregate.json --issues @.gstack/tmp/issues.json --review-loop @.gstack/tmp/review-loop.json --docs @.gstack/tmp/docs-audit.json --body "$PR_BODY_FILE"
```
@@ -233,7 +233,9 @@ When the owner accepted a stop after a fix-check edit, add
and put its printed `Review-loop exception:` line in the body; never a prose line under `REVIEW_LOOP:`.
The summary reads this branch's review log for the invocation's passes (its
`REVIEW_LOOP:` line is the one the Pre-Landing Review section carries). The `&&`
`REVIEW_LOOP:` line is the one the Pre-Landing Review section carries). Here every
finding reaches a row through Step 9.4; a run whose readers were collected by
`gstack-review-dispatch` (ship/DRIVER.md) passes `--run <run dir>` instead. The `&&`
matters: a summary that refuses (no pass matched `REVIEW_SINCE`) must not let `write`
record a stale or empty loop file.
+4 -2
View File
@@ -203,7 +203,7 @@ per-lane counts (`{"<lane>":{"pass":N,"fail":N,"skip":N}}`) to
```bash
rm -f .gstack/tmp/review-loop.json
~/.claude/skills/gstack/bin/gstack-review-scope summary --since "$REVIEW_SINCE" --out .gstack/tmp/review-loop.json &&
~/.claude/skills/gstack/bin/gstack-review-scope summary --since "$REVIEW_SINCE" --no-ledger --out .gstack/tmp/review-loop.json &&
~/.claude/skills/gstack/bin/gstack-ship-receipt write --pr <pr-number> --base <base> --gate @.gstack/tmp/ship-gate.json --pregate .gstack/tmp/pregate.json --issues @.gstack/tmp/issues.json --review-loop @.gstack/tmp/review-loop.json --docs @.gstack/tmp/docs-audit.json --body "$PR_BODY_FILE"
```
@@ -216,7 +216,9 @@ When the owner accepted a stop after a fix-check edit, add
and put its printed `Review-loop exception:` line in the body; never a prose line under `REVIEW_LOOP:`.
The summary reads this branch's review log for the invocation's passes (its
`REVIEW_LOOP:` line is the one the Pre-Landing Review section carries). The `&&`
`REVIEW_LOOP:` line is the one the Pre-Landing Review section carries). Here every
finding reaches a row through Step 9.4; a run whose readers were collected by
`gstack-review-dispatch` (ship/DRIVER.md) passes `--run <run dir>` instead. The `&&`
matters: a summary that refuses (no pass matched `REVIEW_SINCE`) must not let `write`
record a stale or empty loop file.
+1 -1
View File
@@ -190,7 +190,7 @@ function reviewRows(repo: string, env: NodeJS.ProcessEnv) {
}
function loopSummary(repo: string, env: NodeJS.ProcessEnv, since: string) {
return JSON.parse(command(repo, env, path.join(ROOT, 'bin/gstack-review-scope'), ['summary', '--since', since, '--json']).stdout.trim());
return JSON.parse(command(repo, env, path.join(ROOT, 'bin/gstack-review-scope'), ['summary', '--no-ledger', '--since', since, '--json']).stdout.trim());
}
export async function shipFrozenScopeFailures(fixture: ReturnType<typeof createShipFrozenScopeFixture>, result: Pick<AgentSdkResult, 'exitReason'>) {
+249
View File
@@ -0,0 +1,249 @@
/**
* GST-951: the /ship receipt's review_loop counters (gstack-review-scope summary) missed real blockers three times on
* 2026-11-02, because summary read only the review-log rows while /ship's readers report through the dispatch ledgers
* (gstack-review-dispatch collect and judge). Each case below is a minimal reconstruction of that PR's rows and ledgers,
* built from its public PR body and receipt (the rows and ledgers themselves stayed on the lanes' machines): the rows
* reproduce the receipt's published counters on the old summary, and the ledgers hold what the PR body says the readers found.
* Ledgers are written with the real collect and judge, so the parser and judge rules apply as in a run.
*/
import { afterEach, describe, expect, setDefaultTimeout, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { collect, readJsonl, recordJudgment, type JudgedLine, type MergedLine } from '../lib/review-ledger';
import { parseReviewLoop } from '../lib/ship-receipt';
import { cleanup, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/restamp-fixture';
setDefaultTimeout(60_000);
const repos: FixtureRepo[] = [];
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
const SINCE = '2026-11-02T01:00:00Z';
const at = (min: number) => new Date(Date.parse(SINCE) + min * 60_000).toISOString();
const review = (min: number, pass: number, findings: object[]) => ({ skill: 'review', via: 'ship', timestamp: at(min), status: findings.length ? 'issues_found' : 'clean', review_scope: 'frozen', pass, findings });
const fixCheck = (min: number, findings: object[]) => ({ skill: 'review-fix-check', via: 'ship', timestamp: at(min), review_scope: 'frozen', findings });
const note = (fp: string, raised_by: string) => ({ fingerprint: fp, severity: 'INFORMATIONAL', action: 'noted', blocking: false, raised_by });
/** One pass directory: each reader's output collected into its ledger, then lead judgment (default: every label kept). */
function pass(dir: string, readers: Array<[string, string]>, judgeWith = (merged: MergedLine[]): JudgedLine[] => merged.map(m => ({ id: m.id, severity: m.label }))): string {
fs.mkdirSync(dir, { recursive: true });
const ledger = path.join(dir, 'review-ledger.jsonl');
const mergedFile = path.join(dir, 'merged.jsonl');
fs.writeFileSync(mergedFile, '');
for (const [reader, output] of readers) expect(collect(ledger, reader, output, mergedFile).review).toBe(true);
const merged = readJsonl<MergedLine>(mergedFile);
const judged = judgeWith(merged);
const judgedFile = path.join(dir, 'judged.jsonl');
fs.writeFileSync(judgedFile, judged.map(j => JSON.stringify(j) + '\n').join(''));
expect(recordJudgment(ledger, merged, judged, judgedFile, mergedFile).ok).toBe(true);
return ledger;
}
function branchWith(rows: object[]): FixtureRepo {
const repo = makeFixtureRepo({ prefix: 'gst951-' });
repos.push(repo);
repo.git('checkout', '-qb', 'feat');
const slug = runBin(repo, 'gstack-slug', ['--get', 'SLUG']).out.trim();
const logDir = path.join(repo.home, 'projects', slug);
fs.mkdirSync(logDir, { recursive: true });
fs.writeFileSync(path.join(logDir, 'feat-reviews.jsonl'), rows.map(r => JSON.stringify(r) + '\n').join(''));
return repo;
}
const summary = (repo: FixtureRepo, ...args: string[]) => runBin(repo, 'gstack-review-scope', ['summary', '--since', SINCE, '--json', ...args]);
const loop = (r: { status: number | null; out: string; err: string }) => { expect(r.err).not.toContain('(REVIEW_SCOPE_'); expect(r.status).toBe(EXIT.ok); return JSON.parse(r.out); };
const CLEAN = 'NO FINDINGS';
const OUTSIDE_CLEAN = 'No issues found in the diff.\nRecommendation: ship because no finding reaches the floor';
describe('GST-951: the receipt counts every blocker the readers found', () => {
// #3841 (reconstruction; GSTA-203 PR 8 trail rows 9 and 14): Step 11's native adversarial FIXABLE (CRITICAL) finding was
// fixed in round 2, but no row recorded it (pass 2's row held no findings), so summary printed blocking_found=0 fixed=0 and
// the lane hand-set blocking_found=1 fixed=1. Its two other findings are the noted pair.
const ADVERSARIAL_3841 = [
'1. lib/pregate-clear.ts:140 The receipt stores the raw run URL, not the attempt it judged. FIXABLE',
'2. lib/pregate.ts:88 The refusal text does not name the attempt. INVESTIGATE',
'3. docs/pregate.md:12 The docs do not say a rerun replaces the attempt. INVESTIGATE',
'Recommendation: fix 1 because a later attempt would be judged by the first',
].join('\n');
test('#3841 as /ship logs it: the queued adversarial blocker in pass 2\'s row counts as 1 found and 1 fixed', () => {
const repo = branchWith([
review(0, 1, []),
review(40, 2, [{ fingerprint: 'lib/pregate-clear.ts:140:security', action: 'fixed', raised_by: 'adversarial-native' },
note('lib/pregate.ts:88:docs', 'adversarial-native'), note('docs/pregate.md:12:docs', 'adversarial-native')]),
]);
const run = path.join(repo.root, 'run');
pass(path.join(run, 'p1'), [['specialist/testing', CLEAN], ['red-team', CLEAN]]);
pass(path.join(run, 'p1-voices'), [['outside-voice', OUTSIDE_CLEAN], ['adversarial-native', ADVERSARIAL_3841]]);
pass(path.join(run, 'p2'), [['specialist/testing', CLEAN], ['red-team', CLEAN]]);
expect(loop(summary(repo, '--no-ledger'))).toMatchObject({ passes: 2, blocking_found: 0, noted: 2, fixed: 1 });
const s = loop(summary(repo, '--run', run));
expect(s).toMatchObject({ passes: 2, blocking_found: 1, noted: 2, fixed: 1, ledgers_read: 3, rounds: 2 });
expect(s.unrecorded).toBeUndefined();
});
test('#3841 as the lane logged it: an adversarial-only blocker counts, and its missing fix record shows as unrecorded', () => {
const repo = branchWith([
review(0, 1, []),
review(40, 2, [note('lib/pregate.ts:88:docs', 'adversarial-native'), note('docs/pregate.md:12:docs', 'adversarial-native')]),
]);
const run = path.join(repo.root, 'run');
pass(path.join(run, 'p1'), [['specialist/testing', CLEAN], ['red-team', CLEAN]]);
pass(path.join(run, 'p1-voices'), [['outside-voice', OUTSIDE_CLEAN], ['adversarial-native', ADVERSARIAL_3841]]);
expect(loop(summary(repo, '--no-ledger'))).toMatchObject({ passes: 2, blocking_found: 0, noted: 2, fixed: 0 });
const s = loop(summary(repo, '--run', run));
expect(s).toMatchObject({ passes: 2, blocking_found: 1, noted: 2, fixed: 0, unrecorded: 1, ledgers_read: 2, rounds: 2 });
});
// #3838 (reconstruction): the rows reproduce its receipt (passes=2 blocking_found=0 noted=8 fixed=4 fix_checks=3). Pass 2's
// red-team CRITICAL was logged fixed without classify's blocking stamp; Step 11's native adversarial CRITICAL (CSS
// image-set loading an outside image) was fixed in 0f61362c1 and no row holds it.
test('#3838: CRITICALs fixed without a classified row count', () => {
const repo = branchWith([
review(0, 1, ['a', 'b', 'c'].map(x => ({ fingerprint: `lib/review-depth.ts:1${x}:style`, severity: 'INFORMATIONAL', action: 'fixed', blocking: false, raised_by: 'security' }))),
review(50, 2, [{ fingerprint: 'lib/review-depth.ts:212:security', severity: 'CRITICAL', action: 'fixed', raised_by: 'red-team' },
note('lib/review-depth.ts:220:style', 'red-team'), note('lib/review-depth.ts:230:style', 'red-team')]),
fixCheck(60, [note('lib/review-depth.ts:240:style', 'fix-check'), note('lib/review-depth.ts:241:style', 'fix-check')]),
fixCheck(90, [note('lib/review-depth.ts:250:style', 'fix-check')]),
fixCheck(120, [note('lib/review-depth.ts:260:perf', 'fix-check'), note('lib/review-depth.ts:261:perf', 'fix-check'), note('lib/review-depth.ts:262:perf', 'fix-check')]),
]);
const run = path.join(repo.root, 'run');
pass(path.join(run, 'p1'), [['specialist/security', '- [INFORMATIONAL] lib/review-depth.ts:1a a CSS escape\n- [INFORMATIONAL] lib/review-depth.ts:1b a zero-padded reference\n- [INFORMATIONAL] lib/review-depth.ts:1c a split handler line'], ['red-team', CLEAN]]);
pass(path.join(run, 'p2'), [['red-team', '[CRITICAL] lib/review-depth.ts:212 a stray quote hides an external href from the drawing check']]);
pass(path.join(run, 'p2-voices'), [['outside-voice', OUTSIDE_CLEAN], ['adversarial-native', 'lib/review-depth.ts:300 CSS image-set() loads an outside image. FIXABLE\nRecommendation: fix because the drawing loads a remote URL']]);
expect(loop(summary(repo, '--no-ledger'))).toMatchObject({ passes: 2, blocking_found: 1, noted: 8, fixed: 4, fix_checks: 3, rounds: 5 });
const s = loop(summary(repo, '--run', run));
expect(s).toMatchObject({ passes: 2, blocking_found: 2, deferred: 0, noted: 8, fixed: 4, fix_checks: 3, rounds: 5, unrecorded: 1, ledgers_read: 3 });
});
// #3837 (reconstruction): pass 1's row was logged before the outside voice's seven findings were classified, so they sit in the
// row with no severity, blocking stamp or action; the fix batch under the Captain's exception fixed them, and the receipt said
// blocking_found=0 fixed=0 noted=11 fix_checks=3.
test('#3837: blockers logged before classification count', () => {
const seven = Array.from({ length: 7 }, (_, i) => ({ fingerprint: `lib/inventory.ts:${10 + i}:logic`, raised_by: 'outside-voice' }));
const repo = branchWith([
review(0, 1, [...seven, ...Array.from({ length: 9 }, (_, i) => note(`docs/inventory.md:${i + 1}:docs`, 'specialist'))]),
review(60, 2, [note('lib/inventory.ts:90:docs', 'red-team'), note('lib/inventory.ts:91:docs', 'red-team')]),
fixCheck(80, []), fixCheck(100, []), fixCheck(120, []),
]);
const run = path.join(repo.root, 'run');
pass(path.join(run, 'p1'), [['specialist-batched/1', CLEAN], ['red-team', CLEAN]]);
pass(path.join(run, 'p1-voices'), [['adversarial-native', CLEAN], ['outside-voice', [
...seven.map((f, i) => `${i + 1}. ${f.fingerprint.split(':').slice(0, 2).join(':')} finding ${i + 1} Severity: High Blocks: yes`),
'Recommendation: fix 1-6 because a self-grandfathered row skips every plan rule',
].join('\n')]]);
expect(loop(summary(repo, '--no-ledger'))).toMatchObject({ passes: 2, blocking_found: 0, noted: 11, fixed: 0, fix_checks: 3, rounds: 5 });
const s = loop(summary(repo, '--run', run));
expect(s).toMatchObject({ passes: 2, blocking_found: 7, noted: 11, fixed: 0, unrecorded: 7, ledgers_read: 2, rounds: 5 });
const line = runBin(repo, 'gstack-review-scope', ['summary', '--since', SINCE, '--run', run]);
expect(line.out.trim()).toBe('REVIEW_LOOP: passes=2 blocking_found=7 deferred=0 noted=11 fixed=0 scope=frozen unrecorded=7 ledgers_read=2 fix_checks=3');
expect(parseReviewLoop(s)).toMatchObject({ blocking_found: 7, unrecorded: 7, ledgers_read: 2, rounds: 5 });
});
test('a blocker both sources hold counts once, and a disposed one is not unrecorded', () => {
const repo = branchWith([review(0, 1, [{ fingerprint: 'src/a.ts:3:logic', severity: 'CRITICAL', blocking: true, action: 'fixed', raised_by: 'red-team' }])]);
const run = path.join(repo.root, 'run');
pass(path.join(run, 'p1'), [['specialist/testing', CLEAN], ['red-team', '[CRITICAL] src/a.ts:3 the guard is inverted']]);
const s = loop(summary(repo, '--run', run));
expect(s).toMatchObject({ blocking_found: 1, fixed: 1, ledgers_read: 1 });
expect(s.unrecorded).toBeUndefined();
});
test('a CRITICAL a row classified below the floor is not a blocker; distinct blockers from one reviewer each count', () => {
const repo = branchWith([review(0, 1, [
{ fingerprint: 'src/a.ts:3:testing', path: 'src/a.ts', line: 3, severity: 'CRITICAL', kind: 'test-gap', blocking: false, action: 'noted', raised_by: 'testing' },
{ fingerprint: 'src/b.ts:9:security', path: 'src/b.ts', line: 9, severity: 'CRITICAL', blocking: true, action: 'fixed', raised_by: 'security' },
{ fingerprint: 'src/c.ts:1:logic', severity: 'CRITICAL', advisory: true, action: 'noted', raised_by: 'security' },
])]);
const run = path.join(repo.root, 'run');
pass(path.join(run, 'p1'), [['specialist/testing', '[CRITICAL] src/a.ts:3 the denied path has no test\n[CRITICAL] src/d.ts:7 the retry loop never ends']]);
const s = loop(summary(repo, '--run', run));
expect(s).toMatchObject({ blocking_found: 2, fixed: 1, noted: 2, unrecorded: 1, ledgers_read: 1 });
});
test('Step 11 review cases: a row finding pairs with one ledger CRITICAL; the newest classification and a mapped severity decide; a bare fixed row is a disposition', () => {
const row = (fp: string, f: object) => ({ fingerprint: fp, ...f });
const repo = branchWith([
review(0, 1, [
row('src/auth.ts:42:testing', { path: 'src/auth.ts', line: 42, severity: 'CRITICAL', kind: 'test-gap', blocking: false, action: 'noted', raised_by: 'testing' }),
row('src/m.ts:5:state-matrix', { path: 'src/m.ts', line: 5, severity: 'INFORMATIONAL', raw_severity: 'CRITICAL', kind: 'test-gap', blocking: false, action: 'noted', raised_by: 'state-machines' }),
row('src/f.ts:3:logic', { action: 'fixed', raised_by: 'outside-voice' }),
row('src/n.ts:8:logic', { severity: 'CRITICAL', raised_by: 'red-team' }),
row('src/g.ts:7:logic', { action: 'fixed', raised_by: 'core' }),
]),
review(30, 2, [row('src/n.ts:8:logic', { severity: 'CRITICAL', blocking: false, action: 'noted', raised_by: 'red-team' })]),
]);
const run = path.join(repo.root, 'run');
pass(path.join(run, 'p1'), [
['specialist/testing', '[CRITICAL] src/auth.ts:42 the denied path has no test\n[CRITICAL] src/m.ts:5 untested cell'],
['outside-voice', '1. src/auth.ts:42 an unauthenticated caller reaches the handler. Severity: High Blocks: yes\n2. src/f.ts:3 the guard is inverted. Blocks: yes\nRecommendation: fix 1 because it is an open handler'],
['red-team', '[CRITICAL] src/n.ts:8 looked wrong, classified below the floor later'],
], m => [...m.map(l => ({ id: l.id, severity: l.label })), { id: 'lead#1', severity: 'CRITICAL', reason: 'src/g.ts:7 the lead found an unchecked write' }]);
const s = loop(summary(repo, '--run', run));
expect(s).toMatchObject({ passes: 2, blocking_found: 3, fixed: 2, unrecorded: 1, ledgers_read: 1 });
});
test('round 2 cases: another reviewer at a below-floor line, an extensionless path, one finding in two ledgers, no locus', () => {
const repo = branchWith([
review(0, 1, [
{ fingerprint: 'src/auth.ts:42:testing', path: 'src/auth.ts', line: 42, severity: 'CRITICAL', blocking: false, action: 'noted', raised_by: 'testing' },
{ fingerprint: 'bin/gstack-review-scope:262:logic', path: 'bin/gstack-review-scope', line: 262, severity: 'CRITICAL', blocking: true, action: 'fixed', raised_by: 'red-team' },
{ fingerprint: 'x.ts:5:logic', path: 'x.ts', line: 5, severity: 'CRITICAL', blocking: true, action: 'fixed', raised_by: 'red-team' },
]),
fixCheck(30, []),
]);
const run = path.join(repo.root, 'run');
pass(path.join(run, 'p1'), [
['specialist/testing', '[CRITICAL] src/auth.ts:42 the denied path has no test'],
['outside-voice', '1. src/auth.ts:42 an unauthenticated caller reaches the handler. Blocks: yes\nRecommendation: fix 1 because it is open'],
['red-team', '[CRITICAL] bin/gstack-review-scope:262 the refusal is skipped\n[CRITICAL] x.ts:5 the guard is inverted'],
]);
pass(path.join(run, 'fix-check'), [['fix-check', '[CRITICAL] x.ts:5 the guard is still inverted\n[CRITICAL] the retry loop never ends']]);
const s = loop(summary(repo, '--run', run));
expect(s).toMatchObject({ blocking_found: 4, fixed: 2, noted: 1, unrecorded: 2, ledgers_read: 2 });
});
test('judgment decides: a folded duplicate counts once, a lead addition counts, a rejected judgment keeps the collected labels', () => {
const repo = branchWith([review(0, 1, [])]);
const run = path.join(repo.root, 'run');
pass(path.join(run, 'fold'), [['red-team', '[CRITICAL] src/a.ts:3 the guard is inverted\n[CRITICAL] src/a.ts:4 the guard is inverted again']],
m => [{ id: m[0]!.id, severity: 'CRITICAL' }, { id: m[1]!.id, severity: 'CRITICAL', duplicate_of: m[0]!.id }, { id: 'lead#1', severity: 'CRITICAL', reason: 'src/e.ts:2 the lead found an unchecked write' }]);
expect(loop(summary(repo, '--run', run))).toMatchObject({ blocking_found: 2, unrecorded: 2, ledgers_read: 1 });
const rejected = path.join(repo.root, 'rejected');
fs.mkdirSync(rejected);
const ledger = path.join(rejected, 'review-ledger.jsonl');
const mergedFile = path.join(rejected, 'merged.jsonl');
fs.writeFileSync(mergedFile, '');
collect(ledger, 'red-team', '[CRITICAL] src/a.ts:3 the guard is inverted', mergedFile);
const merged = readJsonl<MergedLine>(mergedFile);
const judgedFile = path.join(rejected, 'judged.jsonl');
fs.writeFileSync(judgedFile, JSON.stringify({ id: merged[0]!.id, severity: 'INFORMATIONAL' }) + '\n');
expect(recordJudgment(ledger, merged, [{ id: merged[0]!.id, severity: 'INFORMATIONAL' }], judgedFile, mergedFile).ok).toBe(false);
expect(loop(summary(repo, '--ledger', ledger))).toMatchObject({ blocking_found: 1, unrecorded: 1, ledgers_read: 1 });
});
test('summary refuses REVIEW_SCOPE_LEDGER instead of counting without a ledger it needs, and writes nothing', () => {
const repo = branchWith([review(0, 1, [])]);
const run = path.join(repo.root, 'run');
const ledger = pass(path.join(run, 'p1'), [['red-team', '[CRITICAL] src/a.ts:3 the guard is inverted']]);
const out = path.join(repo.root, 'loop.json');
const refused = (r: { status: number | null; err: string }, why: string) => { expect(r.status).toBe(EXIT.fail); expect(r.err).toContain('(REVIEW_SCOPE_LEDGER;'); expect(r.err).toContain(why); expect(fs.existsSync(out)).toBe(false); };
refused(summary(repo, '--out', out), '--no-ledger when no reader ran through gstack-review-dispatch');
fs.mkdirSync(path.join(repo.root, 'empty'));
refused(summary(repo, '--run', path.join(repo.root, 'empty'), '--out', out), 'holds no review-ledger.jsonl');
refused(summary(repo, '--ledger', path.join(run, 'nope.jsonl'), '--out', out), 'is unreadable');
const judged = path.join(run, 'p1', 'judged.jsonl');
const kept = fs.readFileSync(judged, 'utf8');
fs.writeFileSync(judged, kept.replace('CRITICAL', 'INFORMATIONAL'));
refused(summary(repo, '--ledger', ledger, '--out', out), 'changed since judge');
fs.writeFileSync(judged, kept);
fs.rmSync(path.join(run, 'p1', 'merged.jsonl'));
refused(summary(repo, '--run', run, '--out', out), 'is unreadable');
refused(summary(repo, '--run', path.join(repo.root, 'nonesuch'), '--out', out), 'ENOENT');
const bare = summary(repo, '--out', out, '--ledger');
expect(bare.err).toContain('(REVIEW_SCOPE_INPUT;');
expect(bare.err).toContain('--ledger needs a file');
const both = summary(repo, '--run', run, '--no-ledger');
expect(both.status).toBe(EXIT.fail);
expect(both.err).toContain('(REVIEW_SCOPE_INPUT;');
});
});
+1 -1
View File
@@ -50,7 +50,7 @@ test('summary reads the review log through gstack-review-read (#3541)', () => {
];
fs.writeFileSync(path.join(state, 'projects', SLUG, 'feat-reviews.jsonl'), rows.map(r => JSON.stringify(r)).join('\n') + '\n');
const out = path.join(tmp, 'review-loop.json');
const r = runScriptSync(path.join(ROOT, 'bin', 'gstack-review-scope'), ['summary', '--since', '2026-10-26T09:00:00Z', '--out', out, '--json'], {
const r = runScriptSync(path.join(ROOT, 'bin', 'gstack-review-scope'), ['summary', '--no-ledger', '--since', '2026-10-26T09:00:00Z', '--out', out, '--json'], {
cwd: repo, env, encoding: 'utf8', timeout: 120_000,
});
expect(r.error).toBeUndefined();
+5 -5
View File
@@ -423,30 +423,30 @@ describe('gstack-review-scope (bin)', () => {
fs.writeFileSync(path.join(logDir, `${branch}-reviews.jsonl`), rows.map(r => JSON.stringify(r)).join('\n') + '\n{not json\n');
const expected = { passes: 2, blocking_found: 2, deferred: 1, noted: 1, fixed: 1, scope: 'frozen', rounds: 2 };
const out = path.join(repo.root, 'review-loop.json');
const r = runBin(repo, 'gstack-review-scope', ['summary', '--since', '2026-10-26T09:30:00Z', '--out', out, '--json']);
const r = runBin(repo, 'gstack-review-scope', ['summary', '--no-ledger', '--since', '2026-10-26T09:30:00Z', '--out', out, '--json']);
expect(r.status).toBe(EXIT.ok);
expect(r.err).toContain('REVIEW_LOOP: passes=2 blocking_found=2 deferred=1 noted=1 fixed=1 scope=frozen');
expect(JSON.parse(r.out)).toEqual(expected);
expect(JSON.parse(fs.readFileSync(out, 'utf8'))).toEqual(expected);
const plain = runBin(repo, 'gstack-review-scope', ['summary', '--since', '2026-10-26T08:00:00Z']);
const plain = runBin(repo, 'gstack-review-scope', ['summary', '--no-ledger', '--since', '2026-10-26T08:00:00Z']);
expect(plain.status).toBe(EXIT.ok);
expect(plain.out.trim()).toBe('REVIEW_LOOP: passes=3 blocking_found=3 deferred=1 noted=1 fixed=2 scope=frozen');
const noSince = runBin(repo, 'gstack-review-scope', ['summary', '--json']);
expect(noSince.status).toBe(EXIT.fail);
expect(noSince.err).toContain('(REVIEW_SCOPE_INPUT;');
expect(noSince.out).toBe('');
const badSince = runBin(repo, 'gstack-review-scope', ['summary', '--since', 'yesterday']);
const badSince = runBin(repo, 'gstack-review-scope', ['summary', '--no-ledger', '--since', 'yesterday']);
expect(badSince.status).toBe(EXIT.fail);
expect(badSince.err).toContain('(REVIEW_SCOPE_INPUT;');
// zero matching passes is a wrong --since or a log written elsewhere, never a loop to record: refused, nothing written
const bare = fixture();
const noneOut = path.join(bare.root, 'review-loop.json');
const none = runBin(bare, 'gstack-review-scope', ['summary', '--since', '2026-10-26T09:30:00Z', '--out', noneOut, '--json']);
const none = runBin(bare, 'gstack-review-scope', ['summary', '--no-ledger', '--since', '2026-10-26T09:30:00Z', '--out', noneOut, '--json']);
expect(none.status).toBe(EXIT.fail);
expect(none.err).toContain('(REVIEW_SCOPE_INPUT;');
expect(none.err).toContain('REVIEW_LOOP: passes=0');
expect(fs.existsSync(noneOut)).toBe(false);
const late = runBin(repo, 'gstack-review-scope', ['summary', '--since', '2026-10-27T00:00:00Z']);
const late = runBin(repo, 'gstack-review-scope', ['summary', '--no-ledger', '--since', '2026-10-27T00:00:00Z']);
expect(late.status).toBe(EXIT.fail);
});
});
+1 -1
View File
@@ -324,7 +324,7 @@ describe('the targeted role end to end on a fixture repository', () => {
fs.mkdirSync(logDir, { recursive: true });
const row = { skill: 'review', via: 'ship', timestamp: '2026-10-30T10:00:00Z', status: 'clean', review_scope: 'frozen', findings: [], dispatches: [{ role: 'specialist' }, { role: 'adversarial-native' }, { role: 'targeted' }] };
fs.writeFileSync(path.join(logDir, 'feat-reviews.jsonl'), JSON.stringify(row) + '\n');
const summary = runBin(repo, 'gstack-review-scope', ['summary', '--since', '2026-10-30T09:00:00Z', '--json']);
const summary = runBin(repo, 'gstack-review-scope', ['summary', '--no-ledger', '--since', '2026-10-30T09:00:00Z', '--json']);
expect(summary.status, summary.err).toBe(0);
expect(JSON.parse(summary.out).dispatches).toEqual({ specialist: 1, 'adversarial-native': 1, targeted: 1 });
@@ -0,0 +1,4 @@
{
"bytes": 177,
"why": "GST-951: summary names its ledgers (--run in DRIVER, --no-ledger in pr-body)"
}