From 89f203300498596cfcee37017f9966af9b0ebc3a Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 31 Oct 2026 02:07:30 -0700 Subject: [PATCH] v1.91.358.0 review: targeted read-only pass on money, gate, auth and secret code (GST-257 part 1, report-only) (#3671) * review: targeted read-only pass on money, gate, auth and secret diffs (GST-257 part 1, report-only) lib/review-targeted.ts classifies a diff's added and deleted lines into money, gate, auth and secret (token-bounded, docs and tests excluded, fails closed to all four). gstack-review-plan prints TARGETED and dispatches one read-only targeted reader at full effort when a class hits; review/specialists/targeted.md is its brief. The receipt records review_targeted bound to product_patch_id, a carry keeps it, and gstack-ship-queue ready / train build print a report-only REVIEW_TARGETED line that refuses nothing. No-loss evidence: the dispatch-superset test in test/review-targeted.test.ts runs gstack-review-plan on 18 fixture diffs and requires every reviewer main dispatched at 8be7f21, unchanged, plus only the targeted reader. Brief identity: gstack-review-dispatch render on this tree and origin/main 2356792 gives 13 of 13 existing reviewer briefs byte-identical (handoffs/GSTA-160/gst257-briefcmp/SUMMARY.txt). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (targeted patch id covers tests, record binds the diff it read, role list pin) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: targeted reader reads the merge base on every pass; dirty-tree record test Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: targeted render takes --base and any spelling of the merge base; Step 11 says the merge base; docs gate-path list Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: type the fail-closed class list (typecheck:test ratchet) 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> --- CHANGELOG.md | 17 + VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-review-dispatch | 50 ++- bin/gstack-review-plan | 22 +- bin/gstack-ship-receipt | 15 +- docs/review-policy.md | 58 ++- lib/headless-artifacts.ts | 3 + lib/review-brief.ts | 12 +- lib/review-collect.ts | 14 +- lib/review-depth.ts | 12 +- lib/review-scope.ts | 8 +- lib/review-targeted.ts | 132 +++++++ lib/ship-budget.ts | 2 +- lib/ship-queue-train.ts | 1 + lib/ship-queue.ts | 1 + lib/ship-receipt-depth.ts | 70 +++- lib/ship-receipt.ts | 2 +- package.json | 2 +- review/specialists/targeted.md | 38 ++ ship/sections/adversarial.md | 3 +- ship/sections/adversarial.md.tmpl | 3 +- test/review-packets-removed.test.ts | 6 +- test/review-targeted.test.ts | 350 ++++++++++++++++++ test/ship-budget-chain.test.ts | 2 +- .../ship/gst-257-targeted-review.json | 4 + 26 files changed, 777 insertions(+), 54 deletions(-) create mode 100644 lib/review-targeted.ts create mode 100644 review/specialists/targeted.md create mode 100644 test/review-targeted.test.ts create mode 100644 test/size-ledger/ship/gst-257-targeted-review.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f5a0f58b..420de094d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [1.91.358.0] - 2026-10-31 + +**Diffs that touch money, gate, auth or secret code now get one more reviewer, a targeted reader with a short brief for that kind of code, and the /ship receipt records whether it ran (GST-257, part 1 of 2).** The merge queue reports the result and refuses nothing yet; enforcement comes in a later release. + +### Added +- **`lib/review-targeted.ts` names the classes a diff touches.** It reads added and deleted lines, so removing a spend cap, a check or a redaction counts. Money is a cost, spend, budget, ledger, billing, price, fee or invoice path, an identifier ending in `_usd` or `Usd`, any `spend_cap` spelling, or a call to a paid model provider. Gate is a hunk the gate scan tagged. Auth is `SCOPE_AUTH` on an auth, session, jwt, permission or role path. Secret is a secrets, credentials, keychain or token path, or a line that reads a key-named environment variable or a credential store. Matching is token-bounded, paths review depth counts as docs or tests never count (agent prose such as skill sections and docs/review-policy.md counts as code there, so it counts here too), and an input the classifier cannot read gives all four classes. +- **`gstack-review-plan` prints `TARGETED: classes=… rules=… brief=… targeted_patch_id=…`** and, when a class hit, `DISPATCH: targeted targeted effort=full` at every depth, light included. `--targeted-ran ` skips the reader only when the whole committed diff, tests included, is the one it already read. +- **`review/specialists/targeted.md` is the reader's brief:** one block per class, the blocking floor in one sentence, and the checklist's Forced-CRITICAL rule on uncapped spend in the money block. The reader is read-only, and `NO FINDINGS` is a result. +- **`gstack-review-dispatch render --role targeted --classes …`** renders the brief with only the blocks that hit; `verify --targeted-out ` writes the record (done only when the reader's output was collected and the committed diff it was rendered on is unchanged) `gstack-ship-receipt write --review-targeted @` stores as `review_targeted`, bound to the receipt's `product_patch_id`. A carry keeps it while the product patch is unchanged. +- **`gstack-ship-queue ready` and `train build` print `REVIEW_TARGETED: required=… receipt=… verdict=ok|missing`.** Nothing is refused on it in this release. + +### Precision +- Over the last 100 non-release commits on main (8663c3d2..8be7f213), money hit 21, gate 18, auth 3 and secret 8. The gate class first used the gate paths, which hit 43 of 100, so it was narrowed to the gate scan's tagged hunks; those paths already get full review depth. + +### Tests +- `test/review-targeted.test.ts` covers each class with deleted lines, token boundaries, the docs and tests exclusions, failing closed, the dispatch at light, standard and full depth, `--targeted-ran`, the path from `render` through the receipt, the review-loop summary and the queue line. Its dispatch-superset check runs `gstack-review-plan` on 18 fixture diffs and requires every reviewer main dispatched at 8be7f21, unchanged, plus only the targeted reader. `test/review-packets-removed.test.ts` repins `lib/review-depth.ts` and `ship/sections/adversarial.md` and pins the new brief to that check. + ## [1.91.357.0] - 2026-10-31 **pregate `literals` no longer flags a version-history table that the branch extended.** A test that keeps one row per past version of a constant (`[45, ""]`, then `[46, ""]`) used to fail `literals` on every bump, because the old row still holds the old value. Truther turned the check down to a warning over it, which also hid real stale tests. diff --git a/VERSION b/VERSION index 70c93f08c..48555d1c4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.357.0 +1.91.358.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 4373ea210..66d32839e 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.357.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.358.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/bin/gstack-review-dispatch b/bin/gstack-review-dispatch index 9e4ea74ce..89a4b3dfe 100755 --- a/bin/gstack-review-dispatch +++ b/bin/gstack-review-dispatch @@ -7,7 +7,7 @@ * gstack-review-dispatch sent --ledger --role [--name ] --sent the prompt actually passed; mismatch fails * gstack-review-dispatch collect --ledger --role [--name ] --output --merged * gstack-review-dispatch judge --ledger --merged --judged no drop, no downgrade - * gstack-review-dispatch verify --ledger replay every hash and the judgment + * gstack-review-dispatch verify --ledger [--targeted-out ] replay every hash and the judgment * * Logic: lib/review-brief.ts (rendering from the sections) and lib/review-ledger.ts (ledger, collect, judge, verify). */ @@ -16,7 +16,9 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { EXIT, exitTableHelp } from '../lib/headless-artifacts'; import { BRIEF_ROLES, BriefError, briefFile, promptHash, renderBrief, sectionTexts, type BriefRole, type Slots } from '../lib/review-brief'; -import { collect, readJsonl, readerKey, recordJudgment, recordSent, appendEvent, verify, type JudgedLine, type MergedLine } from '../lib/review-ledger'; +import { collect, readJsonl, readLedger, readerKey, recordJudgment, recordSent, appendEvent, verify, type JudgedLine, type MergedLine, type VerifyRow } from '../lib/review-ledger'; +import { TARGETED_RULES, parseClassList, targetedBriefSha, targetedPatchId } from '../lib/review-targeted'; +import { resolveBaseBranch } from '../lib/ship-policy'; const HELP = `gstack-review-dispatch — verbatim reviewer briefs and loss-free collection for /ship @@ -26,6 +28,8 @@ const HELP = `gstack-review-dispatch — verbatim reviewer briefs and loss-free --learnings-file --checklist (default /review/specialists/.md; red-team.md for red-team) --checklists (specialist-batched: the plan's checklists= names, or paths) --findings-file --specialists (red-team) --fix-list --hunks --tests [--callers ] (fix-check) + --classes (targeted: the plan's TARGETED classes; the brief is review/specialists/targeted.md; + --diff-base must be the merge base with --base, default origin/HEAD's branch, on every pass) writes /[-].prompt.md, records it in the ledger (default /review-ledger.jsonl) and prints BRIEF: reader= sha256= bytes= file=. Pass that file's content as the reader's prompt, unchanged. sent --ledger --role [--name ] --sent @@ -36,9 +40,10 @@ const HELP = `gstack-review-dispatch — verbatim reviewer briefs and loss-free judged lines {"id","severity","reason"?,"duplicate_of"?}: every merged id kept at its severity or above (a duplicate folds into one at least as severe); added and promoted findings need a reason. JUDGMENT: ok …, or JUDGMENT_REJECTED: dropped=… downgraded=… (exit 1) - verify --ledger + verify --ledger [--targeted-out ] BRIEF, OUTPUT and JUDGE rows from the ledger alone, then VERIFY: clean (exit 0), VERIFY: dirty (exit 1) or - VERIFY: no brief records (exit 3: this run's ledger kept no prompts) + VERIFY: no brief records (exit 3: this run's ledger kept no prompts). A ledger with a targeted brief also prints + TARGETED_RECORD: and --targeted-out writes it as the JSON gstack-ship-receipt write --review-targeted reads ${exitTableHelp()}`; const args = process.argv.slice(2); @@ -55,8 +60,15 @@ function render(): number { if (!role || !BRIEF_ROLES.includes(role) || !out) { process.stderr.write(`gstack-review-dispatch render: needs --role <${BRIEF_ROLES.join('|')}> and --out \n`); return EXIT.usage; } const name = flag('--name'); const diffBase = flag('--diff-base'); - const checklist = flag('--checklist') ?? (role === 'red-team' ? path.join(ROOT, 'review/specialists/red-team.md') : role === 'specialist' && name ? path.join(ROOT, 'review/specialists', `${name}.md`) : undefined); - if (checklist && (role === 'specialist' || role === 'red-team') && !fs.existsSync(checklist)) throw new BriefError(`checklist not found: ${checklist}`); + const checklist = flag('--checklist') ?? (role === 'red-team' ? path.join(ROOT, 'review/specialists/red-team.md') : role === 'targeted' ? path.join(ROOT, 'review/specialists/targeted.md') : role === 'specialist' && name ? path.join(ROOT, 'review/specialists', `${name}.md`) : undefined); + if (checklist && (role === 'specialist' || role === 'red-team' || role === 'targeted') && !fs.existsSync(checklist)) throw new BriefError(`checklist not found: ${checklist}`); + const brief = role === 'targeted' ? readFile(checklist, 'targeted brief') : undefined; + if (role === 'targeted') { + const base = resolveBaseBranch(process.cwd(), flag('--base')); + const mb = [`origin/${base}`, base].map(ref => spawnSync('git', ['merge-base', ref, 'HEAD'], { encoding: 'utf8', timeout: 30_000 })).find(r => r.status === 0)?.stdout.trim(); + const full = diffBase ? spawnSync('git', ['rev-parse', '--verify', '--quiet', `${diffBase}^{commit}`], { encoding: 'utf8', timeout: 30_000 }).stdout?.trim() : undefined; + if (!full || full !== mb) throw new BriefError(`the targeted reader reads the whole diff on every pass: --diff-base must be the merge base with ${base} (${mb ?? 'unresolved'}), got ${diffBase ?? 'none'}`); + } const checklists = flag('--checklists')?.split(',').map(s => s.trim()).filter(Boolean).map(c => (c.includes('/') ? c : path.join(ROOT, 'review/specialists', `${c}.md`))); for (const c of checklists ?? []) if (!fs.existsSync(c)) throw new BriefError(`checklist not found: ${c}`); let diff: string | undefined; @@ -69,6 +81,7 @@ function render(): number { name, diff_base: diffBase, exclude: flag('--exclude'), stack: flag('--stack'), test_fw: flag('--test-fw'), learnings: readFile(flag('--learnings-file'), 'learnings'), checklist, checklists: checklists?.join(','), findings: readFile(flag('--findings-file'), 'findings'), specialists: flag('--specialists'), diff, + classes: flag('--classes'), brief, fix_list: readFile(flag('--fix-list'), 'fix list'), hunks: readFile(flag('--hunks'), 'hunks'), tests: readFile(flag('--tests'), 'tests'), callers: readFile(flag('--callers'), 'callers'), }; const text = renderBrief(role, slots, sectionTexts(ROOT)); @@ -77,11 +90,29 @@ function render(): number { fs.writeFileSync(file, text, { mode: 0o600 }); const ledger = flag('--ledger') ?? path.join(out, 'review-ledger.jsonl'); const sha = promptHash(text); - appendEvent(ledger, { event: 'rendered', reader: readerKey(role, name), role, name: name ?? null, file, sha256: sha, bytes: Buffer.byteLength(text) }); + const targeted = brief === undefined ? {} : { classes: parseClassList(flag('--classes')!), rules: TARGETED_RULES, brief_sha: targetedBriefSha(checklist), diff_base: diffBase ?? null, patch_id: diffBase ? targetedPatchId(process.cwd(), diffBase) : 'none' }; + appendEvent(ledger, { event: 'rendered', reader: readerKey(role, name), role, name: name ?? null, file, sha256: sha, bytes: Buffer.byteLength(text), ...targeted }); process.stdout.write(`BRIEF: reader=${readerKey(role, name)} sha256=${sha} bytes=${Buffer.byteLength(text)} file=${file}\n`); return EXIT.ok; } +/** + * The receipt's review_targeted record from the newest targeted dispatch: done only when its brief was sent verbatim, its output + * collected, and the committed diff it was rendered on is still the one at verify (targetedPatchId; uncommitted never proves it). + */ +function targetedRecord(ledger: string, rows: VerifyRow[]): { classes: string[]; rules: string; brief_sha: string; status: 'done' | 'failed'; findings: number; raised_by: string } | null { + const events = readLedger(ledger); + const render = events.filter(e => e.event === 'rendered' && e.reader === 'targeted').at(-1); + if (!render) return null; + const label = rows.filter(r => r.kind === 'BRIEF' && r.reader.split('@')[0] === 'targeted').at(-1)?.reader; + const brief = rows.filter(r => r.kind === 'BRIEF' && r.reader === label).at(-1); + const output = rows.filter(r => r.kind === 'OUTPUT' && r.reader === label).at(-1); + const read = typeof render.patch_id === 'string' && !['uncommitted', 'none'].includes(render.patch_id) && typeof render.diff_base === 'string' && targetedPatchId(process.cwd(), render.diff_base) === render.patch_id; + const done = brief?.status === 'MATCH' && output?.status === 'MATCH' && read; + const ids = events.filter(e => e.event === 'output' && e.reader === 'targeted').at(-1)?.ids; + return { classes: (render.classes as string[]) ?? [], rules: String(render.rules), brief_sha: String(render.brief_sha), status: done ? 'done' : 'failed', findings: done && Array.isArray(ids) ? ids.length : 0, raised_by: 'targeted' }; +} + function main(): number { const cmd = args[0]; if (!cmd || cmd === '--help' || cmd === '-h') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; } @@ -119,6 +150,11 @@ function main(): number { const v = verify(ledger); for (const r of v.rows) process.stdout.write(`${r.kind} ${r.reader} ${r.status} ${r.detail}\n`); if (!v.briefs) { process.stdout.write('VERIFY: no brief records (this run\'s ledger kept no prompts; dispatched-vs-rendered cannot be checked)\n'); return EXIT.refused; } + const record = targetedRecord(ledger, v.rows); + if (record) { + process.stdout.write(`TARGETED_RECORD: classes=${record.classes.join(',')} status=${record.status} findings=${record.findings} brief=${record.brief_sha.slice(0, 12)} rules=${record.rules}\n`); + if (flag('--targeted-out')) fs.writeFileSync(flag('--targeted-out')!, JSON.stringify(record) + '\n'); + } process.stdout.write(`VERIFY: ${v.clean ? 'clean' : 'dirty'} briefs=${v.briefs} outputs=${v.rows.filter(r => r.kind === 'OUTPUT').length} voided=${v.voided}\n`); return v.clean ? EXIT.ok : EXIT.fail; } diff --git a/bin/gstack-review-plan b/bin/gstack-review-plan index 5826f66f1..818d3c6a8 100755 --- a/bin/gstack-review-plan +++ b/bin/gstack-review-plan @@ -4,14 +4,16 @@ * * gstack-review-plan --base [--pass ] [--last none|full|fix-check] [--fixed-blocking] [--fixed-below] * [--blocking-open] [--touched-gate] [--critical-found] [--adversarial-ran] [--gate-tagged ] [--gated a,b] [--force a,b|all] - * [--reviewed-numbers ] [--depth-floor standard|full] [--range-base ] [--pass1-depth light|standard|full] [--no-tiers] [--json] + * [--reviewed-numbers ] [--depth-floor standard|full] [--range-base ] [--pass1-depth light|standard|full] [--no-tiers] + * [--targeted-ran ] [--json] * gstack-review-plan --fix-check-prompt --fix-list --hunks --tests [--callers ] * * Reads the diff against the merge base with (committed, tracked uncommitted * edits and untracked files, through lib/review-collect.ts collectLive), runs * gstack-diff-scope and, unless --gate-tagged is given, gstack-gate-diff; prints * REVIEW_CLASS:, REVIEW_DEPTH: (depth, runtime lines, the rules hash, reasons and - * result codes), GATE_SCAN:, FIX_RANGE: (with --range-base), REVIEW_PLAN:, one DEPTH_ERROR: per input that failed + * result codes), GATE_SCAN:, TARGETED: (the money, gate, auth and secret classes, lib/review-targeted.ts), + * FIX_RANGE: (with --range-base), REVIEW_PLAN:, one DEPTH_ERROR: per input that failed * the depth closed, one DISPATCH: plus DIFF_CMD: (and DIFF_EXCLUDE: none) per * reviewer and one SKIP: per reviewer left out. A plan that fails closed to full * exits 0; a diff git cannot list exits 1. Logic: lib/review-depth.ts. @@ -23,6 +25,7 @@ import { EXIT, exitTableHelp } from '../lib/headless-artifacts'; import { runScriptSync } from '../lib/script-invocation'; import { collectCommit, collectLive } from '../lib/review-collect'; import { classifyDiff, diffCommand, planReview, reviewDepthLine, reviewPlanLine, scanNumbers, SCOPED_RANGE_LINES, type Depth, type History } from '../lib/review-depth'; +import { classList, targetedBriefSha, targetedClasses, targetedPatchId } from '../lib/review-targeted'; import { renderFixCheckPrompt } from '../scripts/resolvers/fix-check'; const HELP = `gstack-review-plan — the review depth of the next /ship step (docs/review-policy.md#review-depth) @@ -49,9 +52,12 @@ const HELP = `gstack-review-plan — the review depth of the next /ship step (do --no-tiers specialists, audits and watchers at the host's default effort too (as gstack-config review_effort_tiers off does) --json the plan as JSON (depth, runtime_lines, rules, errors with input and repair, gate_scan, per-dispatch exclude) --pass1-depth pass 1's REVIEW_DEPTH depth: full, or absent, keeps pass 2 full + --targeted-ran the TARGETED line's targeted_patch_id (the whole committed diff, tests included) when the targeted + reader last read it: the same patch skips it; any other id, none, or uncommitted edits dispatch it again --fix-check-prompt print the fix-check reviewer's prompt from --fix-list, --hunks, --tests and --callers files -Depth: light (runtime lines <= 50), standard (above 50), full (gate class or any DEPTH_ERROR). NEXT: full, scoped +Depth: light (runtime lines <= 50), standard (above 50), full (gate class or any DEPTH_ERROR). TARGETED: the money, +gate, auth and secret classes the diff touches; any class dispatches the targeted reader at every depth. NEXT: full, scoped (a full pass on the range: fix-check, Red Team, native adversarial), fix-check, stop or done. A full plan from an error exits 0; exit 1 means no plan could be built (git could not list the diff). ${exitTableHelp()}`; @@ -131,18 +137,22 @@ function main(): number { : { base: rangeBase, runtime_lines: range.runtimeLines, ...(range.class === 'gate' ? { touched_gate: true as const } : {}) }; } } + const targeted = targetedClasses({ ...collected, scope, gateTagged }); + const patch = collected.mergeBase ? targetedPatchId(process.cwd(), collected.mergeBase) : 'none'; + const brief = targetedBriefSha(); const plan = planReview({ - ...collected, scope, gateTagged, numbers: scanNumbers(added), force, gated: list('--gated'), floor, tiers: auditEffort !== 'host', + ...collected, scope, gateTagged, targeted: { classes: targeted.classes, ran: !['uncommitted', 'none'].includes(patch) && flag('--targeted-ran') === patch }, numbers: scanNumbers(added), force, gated: list('--gated'), floor, tiers: auditEffort !== 'host', criticalFound: has('--critical-found'), adversarialRan: has('--adversarial-ran'), reviewedNumbers: flag('--reviewed-numbers'), history: { fullPasses: pass, last, fixedBlocking: has('--fixed-blocking'), fixedBelow: has('--fixed-below') && last !== 'fix-check', blockingOpen: has('--blocking-open'), touchedGate: has('--touched-gate') || !!fixRange?.touched_gate, pass1Depth, rangeRuntimeLines: fixRange?.runtime_lines }, }); const gateScan = { tagged: gateTagged, head }; if (has('--json')) { const { runtimeLines, stepDepth, depthReasons, ...rest } = plan; - process.stdout.write(JSON.stringify({ ...rest, step_depth: stepDepth, runtime_lines: runtimeLines, depth_reasons: depthReasons, depth_floor: floor ?? null, gate_scan: gateScan, fix_range: fixRange ?? null, registry: { sha: registry.sha, outputs: registry.outputs }, per_path: perPath, diff_lines: diffLines, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly, consumed: collected.consumed }) + '\n'); + process.stdout.write(JSON.stringify({ ...rest, targeted: { ...targeted, brief, targeted_patch_id: patch }, step_depth: stepDepth, runtime_lines: runtimeLines, depth_reasons: depthReasons, depth_floor: floor ?? null, gate_scan: gateScan, fix_range: fixRange ?? null, registry: { sha: registry.sha, outputs: registry.outputs }, per_path: perPath, diff_lines: diffLines, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly, consumed: collected.consumed }) + '\n'); return EXIT.ok; } - const out = [`REVIEW_CLASS: ${plan.class} (${plan.reasons.slice(0, 3).join('; ') || 'no signals'})`, reviewDepthLine(plan), `GATE_SCAN: ${gateTagged === null ? 'unavailable' : `tagged=${gateTagged}`} head=${head}`]; + const out = [`REVIEW_CLASS: ${plan.class} (${plan.reasons.slice(0, 3).join('; ') || 'no signals'})`, reviewDepthLine(plan), `GATE_SCAN: ${gateTagged === null ? 'unavailable' : `tagged=${gateTagged}`} head=${head}`, + `TARGETED: classes=${classList(targeted.classes)} rules=${targeted.rules} brief=${brief} targeted_patch_id=${patch}${targeted.reasons.length ? ` reasons=${targeted.reasons.join('; ')}` : ''}`]; if (fixRange) out.push(`FIX_RANGE: ${fixRange.runtime_lines === null ? `unread (${fixRange.error})` : `runtime_lines=${fixRange.runtime_lines}`} base=${fixRange.base}`); out.push(reviewPlanLine(plan, pass + (plan.next.step === 'full' || plan.next.step === 'scoped' ? 1 : 0)), `NEXT: ${plan.next.step} (${plan.next.reason})`); for (const e of plan.errors) out.push(`DEPTH_ERROR: ${e.code} input=${e.input} detail=${e.detail}; fix: ${e.repair}${e.result ? ` (${e.result})` : ''}`); diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index 59c64628e..f7b30d005 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -44,7 +44,7 @@ import { receiptIssuesFrom, receiptSummaryLine, renderCensus, renderReceiptBlock, validateReceipt, type CensusPr, } from '../lib/ship-receipt'; import { checkDocsCaps, docsCapLine, docsExceptionLine, parseReceiptDocs } from '../lib/ship-receipt-docs'; -import { deeper, depthBelow, exceptionProblems, parseReviewLoopException, receiptDepthAtHead, reviewLoopExceptionLine } from '../lib/ship-receipt-depth'; +import { deeper, depthBelow, exceptionProblems, parseReviewLoopException, parseReviewTargeted, receiptDepthAtHead, reviewLoopExceptionLine } from '../lib/ship-receipt-depth'; import { checkReceiptProse, proseLine } from '../lib/ship-receipt-prose'; import { resolveStateRoot } from '../lib/state-root'; import { isRegistryUnavailable, loadRegistryFromBase } from '../lib/regen'; @@ -89,6 +89,9 @@ write flags: --pr --head --base --version --gated-tree --predecessor --gate ({classes, rules, brief_sha, status, findings, raised_by}: gstack-review-dispatch verify + --targeted-out); recorded as review_targeted bound to the receipt's product_patch_id. Without it the receipt has no + review_targeted, which the queue reports as legacy. --approval-run (repeatable; resolves plan-sha citations) (W2: a class declared in origin/'s .gstack/approval-classes.json with hits needs a citation that resolves and binds to its effects: APPROVAL_CITATION_MISSING) --out --body . Defaults: head=HEAD, base=origin/HEAD's branch, version=the @@ -207,6 +210,12 @@ function cmdWrite(rest: string[], json: boolean): number { if (bodyFile && !bodyText.split(/\r?\n/).some(l => l.trim() === line)) return refuse(`${bodyFile} does not carry the exception line verbatim: ${JSON.stringify(line)}`); } const reviewDepth = receiptDepthAtHead(repoRoot, base, path.dirname(Bun.fileURLToPath(import.meta.url)), headSha ?? head); + const product = reviewedProduct(repoRoot, base, head, pregateFile); + let targeted: ReturnType | undefined; + if (flag(rest, '--review-targeted') !== undefined) { + try { targeted = parseReviewTargeted(jsonArg(flag(rest, '--review-targeted')), product.product_patch_id ?? 'none'); } catch (e: any) { targeted = { ok: false, message: `--review-targeted: ${e.message}` }; } + } + if (targeted && !targeted.ok) { process.stderr.write(resultLine('gstack-ship-receipt write', 'RECEIPT_INVALID', `${targeted.message}; nothing written`) + '\n'); return EXIT.fail; } const approval = approvalForReceipt({ repoRoot, base, head, citationText: [bodyText, ...flags(rest, '--approval')].join('\n'), runDirs: flags(rest, '--approval-run') }); if (approval.kind === 'invalid') { for (const line of approval.lines) process.stderr.write(resultLine('gstack-ship-receipt write', 'APPROVAL_CLASSES_INVALID', line) + '\n'); @@ -236,8 +245,8 @@ function cmdWrite(rest: string[], json: boolean): number { session_kind: sessionKind, artifacts_consumed: flag(rest, '--artifacts-consumed'), run: flag(rest, '--run'), thread: flag(rest, '--thread'), policy: flag(rest, '--policy') ?? tree?.policy, queue_mode: flag(rest, '--queue-mode'), preregistration: flag(rest, '--preregistration'), history: flag(rest, '--history'), ...pregate, issues: issuesFile ? receiptIssuesFrom(issuesFile) : undefined, approval: approval.kind === 'ok' ? approval.approval : undefined, - review_loop: reviewLoop, ...(docs?.ok ? { docs: docs.docs } : {}), ...reviewedProduct(repoRoot, base, head, pregateFile), - gstack_version: runningGstackVersion(), review_depth: reviewDepth, review_loop_exception: loopException?.ok ? loopException.exception : undefined, + review_loop: reviewLoop, ...(docs?.ok ? { docs: docs.docs } : {}), ...product, + gstack_version: runningGstackVersion(), review_depth: reviewDepth, review_loop_exception: loopException?.ok ? loopException.exception : undefined, review_targeted: targeted?.ok ? targeted.targeted : undefined, }; } catch (e: any) { process.stderr.write(`gstack-ship-receipt write: ${e.message}\n`); return EXIT.usage; } if (input.pr !== undefined && !Number.isInteger(input.pr)) { process.stderr.write('gstack-ship-receipt write: --pr must be an integer\n'); return EXIT.usage; } diff --git a/docs/review-policy.md b/docs/review-policy.md index 182d17bc5..71f5a70e7 100644 --- a/docs/review-policy.md +++ b/docs/review-policy.md @@ -230,7 +230,7 @@ and lockfiles stay runtime. To adopt it, commit an entry like this on the base b **Gate paths for the machinery itself.** An edit to `lib/review-depth.ts`, `lib/review-collect.ts`, `bin/gstack-review-plan`, `lib/review-scope.ts`, -`lib/ship-queue*.ts`, `lib/regen.ts`, `.gstack/generated.json`, +`lib/review-targeted.ts`, `review/specialists/targeted.md`, `lib/ship-queue*.ts`, `lib/regen.ts`, `.gstack/generated.json`, `ship/sections/review-army.md.tmpl`, `ship/sections/adversarial.md.tmpl` or `scripts/resolvers/review-army.ts` is gate class: changing how much review runs gets the most review. `rules=` (`DEPTH_RULES`) hashes the deciding constants and patterns, @@ -285,6 +285,59 @@ the specialists, audits and watchers at the host default too part 4a). A binary image no code names is no longer a `binary_code_path` error that forces full depth; one that code names still is. +## Targeted pass on money, gate, auth and secret code + +Some code is costly to get wrong: code that spends money, code that decides +whether a check passes, code that decides who gets in, and code that handles +secrets. When a diff touches one of these, `gstack-review-plan` dispatches one +more reader, `targeted`, at every depth, light included. It reads the whole +diff, fixtures and tests included, with the blocks of +`review/specialists/targeted.md` for the classes that hit, and it runs in Step +11 beside the native adversarial pass and the outside voice (GST-257). + +`lib/review-targeted.ts` decides the classes from added and deleted lines, so +removing a cap, a check or a redaction counts. Docs and tests paths never count, +and matching is token-bounded: `costume` never reads as cost. + +| Class | Signal | +|---|---| +| money | a path segment naming cost, spend, budget, ledger, billing, price, fee or invoice; or a changed line with an identifier ending in `_usd` or `Usd`, any `spend_cap` spelling, or a call to a paid provider `lib/pricing.ts` prices | +| gate | a hunk the gate scan tagged: a relaxed threshold, a skipped test, a loosened check | +| auth | `SCOPE_AUTH` from `gstack-diff-scope`, on a code path with an auth, session, jwt, permission or role token in its name | +| secret | a secrets, credentials, keychain or token path, or a changed line that reads a key-named environment variable (`OPENAI_API_KEY`, `GH_TOKEN`) or a credential store | + +An input the classifier cannot read (the diff, `gstack-diff-scope`, the gate +scan) fails closed to all four classes. `TARGETED_RULES` hashes the patterns. +The classifier and the brief are gate paths, so an edit that weakens them gets +full review. + +The plan prints `TARGETED: classes= rules= +brief= targeted_patch_id=` and, when a class hit, +`DISPATCH: targeted targeted effort=full`. The targeted reader always reads the whole diff: `render --role targeted` +refuses a `--diff-base` other than the merge base, so on pass 2 and later it +gets the merge base, not `RANGE_BASE`. After a fix, pass +`--targeted-ran ` from the line the reader ran on: the same +committed patch skips the reader, and a changed one, uncommitted edits or +`none` dispatch it again. `targeted_patch_id` is the patch-id of the whole +committed diff, tests included, so a test-only fix that adds a gate hunk +dispatches it again; it is not the receipt's `product_patch_id`. + +The brief states the blocking floor in one sentence, and its money block cites +the checklist's Forced-CRITICAL rule on uncapped spend, so a money finding can +block without new floor words. The reader is read-only, and `NO FINDINGS` is a +result. `gstack-review-dispatch render --role targeted --classes ` +renders its brief; `verify --targeted-out ` writes the record that +`gstack-ship-receipt write --review-targeted @` puts in the receipt. +`gstack-ship-queue ready` and `train build` print +`REVIEW_TARGETED: required= receipt= +verdict=ok|missing` and refuse nothing yet; enforcement comes in a later +release. + +Over the last 100 non-release commits on main (8663c3d2..8be7f213), money hit +21, gate 18, auth 3 and secret 8. The gate class first used the gate paths, +which hit 43 of 100; those paths already get full depth, so the gate class was +narrowed to the gate scan's tagged hunks. + ## What the receipt records `gstack-review-scope summary --since ` reads this invocation's @@ -300,8 +353,9 @@ puts in the receipt: | `review_loop.fixed` | findings fixed in this PR | | `review_loop.scope` | the `review_scope` the loop ran under | | `review_loop.fix_checks` | fix-checks this ship ran (never passes) | -| `review_loop.dispatches` | reviewer dispatches by role: `specialist`, `red-team`, `adversarial-native`, `outside-voice`, `fix-check` | +| `review_loop.dispatches` | reviewer dispatches by role: `specialist`, `red-team`, `adversarial-native`, `outside-voice`, `fix-check`, `targeted` | | `review_loop.est_input_tokens` | prompt plus diff bytes the dispatches read, divided by 4: a relative estimate for comparing runs, not a bill | +| `review_targeted` | the [targeted pass](#targeted-pass-on-money-gate-auth-and-secret-code): the `classes` it covered, `rules`, `brief_sha`, the receipt's own `product_patch_id`, `status` (`done` only with its output collected and the committed diff it was rendered on still the one at verify, else `failed`; absent when write got no record, which the queue reports as `legacy`), `findings` and `raised_by`; a carry keeps it while the product patch is unchanged | Each review row may carry `dispatches: [{role, effort, prompt_bytes, diff_bytes, status}]`, and each finding `raised_by` (the reviewer that raised it, diff --git a/lib/headless-artifacts.ts b/lib/headless-artifacts.ts index ab11fbbca..4192aaac6 100644 --- a/lib/headless-artifacts.ts +++ b/lib/headless-artifacts.ts @@ -111,6 +111,8 @@ export interface ShipReceipt { review_depth?: { depth: 'light' | 'standard' | 'full'; class: 'docs' | 'tests' | 'code' | 'gate'; runtime_lines: number; rules: string; base: string; registry: string | null; gate_scan: { tagged: number | null; head: string }; reasons: string[] }; /** The owner's acceptance of a stop after a fix-check edit under frozen scope (#3559); the PR body repeats it word for word. */ review_loop_exception?: { accepted_by: string; date: string; via: string; reason: string; fix_check: { head: string; findings: number; files?: string[] } }; + /** The targeted pass on money, gate, auth and secret code (lib/review-targeted.ts), bound to the receipt's product_patch_id; `done` only with its output collected. */ + review_targeted?: { classes: string[]; rules: string; brief_sha: string; product_patch_id: string; status: 'done' | 'failed' | 'not-run'; findings: number; raised_by: string }; } export const RESULT_STATUSES: readonly ResultStatus[] = ['complete', 'gate_pending', 'amend_pending', 'incomplete', 'interrupted', 'refused']; @@ -234,6 +236,7 @@ export const ARTIFACT_SCHEMAS = { 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, review_depth: { type: 'object', required: ['depth', 'class', 'runtime_lines', 'rules', 'base', 'gate_scan'], properties: { depth: { enum: ['light', 'standard', 'full'] }, class: { enum: ['docs', 'tests', 'code', 'gate'] }, runtime_lines: { type: 'integer', minimum: 0 }, rules: str, base: str, registry: nullableStr, gate_scan: { type: 'object', required: ['tagged', 'head'], properties: { tagged: { type: ['integer', 'null'], minimum: 0 }, head: str } }, reasons: strList } }, + review_targeted: { type: 'object', required: ['classes', 'rules', 'brief_sha', 'product_patch_id', 'status', 'findings', 'raised_by'], properties: { classes: { type: 'array', items: { enum: ['money', 'gate', 'auth', 'secret'] } }, rules: str, brief_sha: str, product_patch_id: str, status: { enum: ['done', 'failed', 'not-run'] }, findings: { type: 'integer', minimum: 0 }, raised_by: str } }, review_loop_exception: { type: 'object', required: ['accepted_by', 'date', 'via', 'reason', 'fix_check'], properties: { accepted_by: str, date: str, via: str, reason: str, fix_check: { type: 'object', required: ['head', 'findings'], properties: { head: str, findings: { type: 'integer', minimum: 0 }, files: strList } } } }, issues: { type: 'object', properties: { fixes: { type: 'array', items: { type: 'integer' } }, refs: { type: 'array', items: { type: 'integer' } }, flakes: { type: 'array', items: { type: 'object', required: ['issue', 'run'], properties: { issue: { type: 'integer' }, run: str } } }, related: { type: 'array', items: { type: 'object', required: ['pr', 'author', 'closes'], properties: { pr: { type: 'integer' }, repo: str, author: str, closes: { type: 'array', items: { type: 'integer' } }, supersedes: { type: 'boolean' }, credit: { type: 'boolean' } } } } } }, }, diff --git a/lib/review-brief.ts b/lib/review-brief.ts index 65e207403..81516ed96 100644 --- a/lib/review-brief.ts +++ b/lib/review-brief.ts @@ -12,13 +12,15 @@ * adversarial-native diff_base * outside-voice diff_base, diff (the branch diff, which the bin reads from git) * fix-check fix_list, hunks, tests, callers (scripts/resolvers/fix-check.ts) + * targeted classes, brief (review/specialists/targeted.md's text), checklist (its path), diff_base, stack, test_fw, learnings */ import { createHash } from 'node:crypto'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { renderFixCheckPrompt } from '../scripts/resolvers/fix-check'; +import { parseClassList, targetedBrief } from './review-targeted'; -export const BRIEF_ROLES = ['specialist', 'specialist-batched', 'red-team', 'adversarial-native', 'outside-voice', 'fix-check'] as const; +export const BRIEF_ROLES = ['specialist', 'specialist-batched', 'red-team', 'adversarial-native', 'outside-voice', 'fix-check', 'targeted'] as const; export type BriefRole = typeof BRIEF_ROLES[number]; export type Slots = Record; @@ -119,6 +121,14 @@ function renderRole(role: BriefRole, slots: Slots, t: SectionTexts): string { const body = fill(t.outside, [['DIFF_BASE=$(git merge-base origin/ HEAD)', `DIFF_BASE=${diffBase}`]], role); return [body, '', `## Branch diff (git diff ${diffBase})`, FENCE + 'diff', (slots.diff ?? '').trim(), FENCE].join('\n'); } + case 'targeted': { + const classes = parseClassList(need(slots, 'classes')); + if (!classes?.length) throw new BriefError(`slot classes must list money, gate, auth or secret, got ${JSON.stringify(slots.classes)}`); + let brief: string; + try { brief = targetedBrief(need(slots, 'brief'), classes); } catch (e) { throw new BriefError((e as Error).message); } + const body = fill(t.specialist, [['Read the checklist at {checklist path}', `Read the targeted brief above (${need(slots, 'checklist')}: ${classes.join(', ')})`]], role); + return `${brief}\n\n${common(body, '')}`; + } case 'fix-check': return renderFixCheckPrompt({ fixList: need(slots, 'fix_list'), touchedHunks: need(slots, 'hunks'), testResults: need(slots, 'tests'), callers: slots.callers ?? '' }); } diff --git a/lib/review-collect.ts b/lib/review-collect.ts index 1cfc02384..7fbfdae12 100644 --- a/lib/review-collect.ts +++ b/lib/review-collect.ts @@ -22,6 +22,8 @@ export interface Collected extends ClassInput { stampOnly: string[]; /** Added lines per path, for scanNumbers. */ added: Record; + /** Deleted lines per path, for the targeted classes (lib/review-targeted.ts). */ + removed: Record; diffLines: number; mergeBase: string | null; untracked: string[]; @@ -52,11 +54,11 @@ export function loadRegistryAt(root: string, sha: string): RegistryInfo { } /** Numstat, deletions and the -U0 patch for `range` (`[mergeBase, head]` or `[mergeBase]` for the working tree). */ -function readDiff(root: string, range: string[], errors: DepthError[]): Pick { +function readDiff(root: string, range: string[], errors: DepthError[]): Pick { const numstat = git(root, ['diff', '--numstat', '-z', '--no-renames', ...range]); const status = git(root, ['diff', '--name-status', '-z', '--no-renames', '--diff-filter=D', ...range]); const patch = git(root, ['diff', '-U0', '--no-color', '--no-ext-diff', '--no-renames', ...range]); - const empty = { perPath: [], deleted: [], stampOnly: [], added: {} }; + const empty = { perPath: [], deleted: [], stampOnly: [], added: {}, removed: {} }; for (const [r, what] of [[numstat, '--numstat'], [status, '--name-status'], [patch, '-U0']] as const) { if (!r.ok) { errors.push({ code: 'numstat_failed', detail: oneLine(r.err || 'git diff failed'), input: `git diff ${what} ${range.join(' ')}`, repair: 'check that both commits exist locally (git fetch origin ) and rerun' }); return empty; } } @@ -69,6 +71,7 @@ function readDiff(root: string, range: string[], errors: DepthError[]): Pick (s === 'D' && statusParts[k + 1] ? [statusParts[k + 1]!] : [])).filter(p => !scratch(p)); const added: Record = {}; + const removed: Record = {}; const changed: Record = {}; let cur = ''; let minus = ''; @@ -83,10 +86,11 @@ function readDiff(root: string, range: string[], errors: DepthError[]): Pick e.path).filter(p => p.endsWith('package.json') && !!changed[p]?.length && changed[p]!.every(l => /^[+-]\s*"version"\s*:\s*"[^"]*"\s*,?\s*$/.test(l))); - return { perPath, deleted, stampOnly, added }; + return { perPath, deleted, stampOnly, added, removed }; } /** More data candidates than this keep every data file code: the consumer scan is not run. */ @@ -154,7 +158,7 @@ export function collectCommit(root: string, baseSha: string, headSha: string): C const mb = git(root, ['merge-base', baseSha, headSha]); if (!mb.ok) { errors.push({ code: 'numstat_failed', detail: oneLine(mb.err || 'no merge base'), input: `git merge-base ${baseSha} ${headSha}`, repair: 'check that both commits exist locally (git fetch origin ) and rerun' }); - return result({ perPath: [], deleted: [], stampOnly: [], added: {} }, { root: [], registry, errors, mergeBase: null, untracked: [], consumed: 'all' }); + return result({ perPath: [], deleted: [], stampOnly: [], added: {}, removed: {} }, { root: [], registry, errors, mergeBase: null, untracked: [], consumed: 'all' }); } const mergeBase = mb.out.trim(); const diff = readDiff(root, [mergeBase, headSha], errors); @@ -172,7 +176,7 @@ export function collectLive(root: string, base: string): Collected { const mb = git(root, ['merge-base', ref, 'HEAD']); if (!mb.ok) { errors.push({ code: 'numstat_failed', detail: oneLine(mb.err || `no merge base with ${ref}`), input: `git merge-base ${ref} HEAD`, repair: `fetch the base first (git fetch origin ${base})` }); - return result({ perPath: [], deleted: [], stampOnly: [], added: {} }, { root, registry, errors, mergeBase: null, untracked: [], consumed: 'all' }); + return result({ perPath: [], deleted: [], stampOnly: [], added: {}, removed: {} }, { root, registry, errors, mergeBase: null, untracked: [], consumed: 'all' }); } const mergeBase = mb.out.trim(); const diff = readDiff(root, [mergeBase], errors); diff --git a/lib/review-depth.ts b/lib/review-depth.ts index a7af650d3..2cdbd8fd9 100644 --- a/lib/review-depth.ts +++ b/lib/review-depth.ts @@ -18,7 +18,7 @@ export type DiffClass = 'docs' | 'tests' | 'code' | 'gate'; export type PathClass = 'docs' | 'tests' | 'code'; export type Depth = 'light' | 'standard' | 'full'; export type Effort = 'light' | 'medium' | 'full' | 'host'; -export type Role = 'specialist' | 'red-team' | 'adversarial-native' | 'outside-voice' | 'fix-check'; +export type Role = 'specialist' | 'red-team' | 'adversarial-native' | 'outside-voice' | 'fix-check' | 'targeted'; export type Step = 'full' | 'scoped' | 'fix-check' | 'done' | 'stop'; export const SPECIALISTS = ['testing', 'maintainability', 'security', 'performance', 'data-migration', 'api-contract', 'design', 'simplification', 'state-machines'] as const; @@ -38,7 +38,7 @@ export const BATCH_SCOPED: readonly Specialist[] = ['data-migration', 'api-contr * them; the Red Team, the native adversarial pass and the fix-check at full (the lane's reasoning); the outside voice at * its own provider's. Light's batched specialist carries the Red Team prompt, so it stays full. */ -export const ROLE_EFFORT = { specialist: 'medium', 'red-team': 'full', 'adversarial-native': 'full', 'fix-check': 'full', 'outside-voice': 'host' } as const satisfies Record; +export const ROLE_EFFORT = { specialist: 'medium', 'red-team': 'full', 'adversarial-native': 'full', 'fix-check': 'full', 'outside-voice': 'host', targeted: 'full' } as const satisfies Record; /** Why light with runtime above 0 keeps the native adversarial pass as its own dispatch. */ export const LIGHT_ADVERSARIAL_REASON = 'backtest inconclusive (GSTA-147 D5)'; const CLASS_RANK: Record = { docs: 0, tests: 1, code: 2, gate: 3 }; @@ -53,7 +53,7 @@ const RELEASE_METADATA = [/^CHANGELOG\.md$/, /^VERSION$/, /^agents-digest\//]; /** Gate paths, the review-depth machinery included: an edit to how much review runs gets the most review. */ const GATE_PATH = [ /(^|\/)\.github\/workflows\//, /(^|\/)(secrets?|credentials?|keychain|token[s]?)([._/-]|$)/i, /state-root/, /(^|\/)(pregate|gate|ship-receipt|ship-policy)[^/]*\.(ts|sh|json)$/, /(^|\/)bin\/gstack-(pregate|gate|ship-receipt|verify-gate|ci-gate)$/, - /^lib\/review-(depth|collect|scope)\.ts$/, /^bin\/gstack-review-plan$/, /^lib\/ship-queue[^/]*\.ts$/, /^lib\/regen\.ts$/, /^\.gstack\/generated\.json$/, /^ship\/sections\/(review-army|adversarial)\.md\.tmpl$/, /^scripts\/resolvers\/review-army\.ts$/, + /^lib\/review-(depth|collect|scope|targeted)\.ts$/, /^review\/specialists\/targeted\.md$/, /^bin\/gstack-review-plan$/, /^lib\/ship-queue[^/]*\.ts$/, /^lib\/regen\.ts$/, /^\.gstack\/generated\.json$/, /^ship\/sections\/(review-army|adversarial)\.md\.tmpl$/, /^scripts\/resolvers\/review-army\.ts$/, ]; /** Data files: JSON, CSV, TSV and raster images. YAML, TOML and SVG are never data here: deploy configs, API specs and executable SVG stay code. */ @@ -291,6 +291,8 @@ export interface PlanInput extends ClassInput { floor?: Depth; /** false under --no-tiers or review_effort_tiers off: specialists run at the host's default effort. */ tiers?: boolean; + /** The targeted classes (lib/review-targeted.ts) and whether the targeted reader already read this product patch. */ + targeted?: { classes: readonly string[]; ran?: boolean }; } /** One reviewer. `exclude` holds git pathspec argv items the reviewer's diff leaves out; `checklists` names what the batched specialist reads. */ export interface Dispatch { role: Role; name: string; effort: Effort; exclude: string[]; checklists?: string[]; reason?: string } @@ -432,6 +434,10 @@ export function planReview(i: PlanInput): ReviewPlan { add('fix-check', 'fix-check', 'full'); if (numbersChanged) add('outside-voice', 'outside-voice (numbers changed)', 'host'); } + if (i.targeted?.classes.length && ['full', 'scoped', 'fix-check'].includes(next.step)) { + if (i.targeted.ran) skipped.push('targeted (already read this product patch)'); + else add('targeted', 'targeted', ROLE_EFFORT.targeted); + } return { ...depth, stepDepth, depthReasons, reasons: c.reasons, next, numbers: i.numbers, dispatches, skipped }; } diff --git a/lib/review-scope.ts b/lib/review-scope.ts index b514dd557..45c4c0165 100644 --- a/lib/review-scope.ts +++ b/lib/review-scope.ts @@ -35,10 +35,10 @@ export interface ScopeFinding { export interface ScopedFinding extends ScopeFinding { locus: Locus; kind: Kind; blocking: boolean; scope: ScopeAction; raised_by?: string } /** Who can raise a finding: the core checklist, QA probes, a specialist by name and the review-depth roles; anything else is `unknown`. */ -const RAISERS: readonly string[] = ['core', 'qa', 'red-team', 'adversarial-native', 'outside-voice', 'fix-check', ...SPECIALISTS]; +const RAISERS: readonly string[] = ['core', 'qa', 'red-team', 'adversarial-native', 'outside-voice', 'fix-check', 'targeted', ...SPECIALISTS]; const RAISER_ALIASES: Record = { adversarial: 'adversarial-native' }; -/** Reviewers that read after pass 1 finished (Step 11's voices, a fix-check): only a blocking finding of theirs is ever `fix`. */ -const AFTER_PASS_RAISERS: readonly string[] = ['adversarial-native', 'outside-voice', 'fix-check']; +/** Reviewers that read after pass 1 finished (Step 11's voices and the targeted reader, a fix-check): only a blocking finding of theirs is ever `fix`. */ +const AFTER_PASS_RAISERS: readonly string[] = ['adversarial-native', 'outside-voice', 'fix-check', 'targeted']; /** The reviewer that raised a finding, for attributing passes later (GSTA-152 D6): its `raised_by` when known, else its `specialist` tag, else `unknown`. */ export function raisedBy(finding: ScopeFinding): string { @@ -297,7 +297,7 @@ export interface ReviewLoopSummary { passes: number; blocking_found: number; deferred: number; noted: number; fixed: number; scope: ReviewScopePolicy | 'unknown'; /** One-reviewer re-checks of a fix (docs/review-policy.md#transitions); never passes. */ fix_checks?: number; - /** Reviewer dispatches of this invocation by role (specialist, red-team, adversarial-native, outside-voice, fix-check). */ + /** Reviewer dispatches of this invocation by role (specialist, red-team, adversarial-native, outside-voice, fix-check, targeted). */ dispatches?: Record; /** Prompt plus diff bytes the dispatches read, / 4: a relative estimate, never a bill. */ est_input_tokens?: number; diff --git a/lib/review-targeted.ts b/lib/review-targeted.ts new file mode 100644 index 000000000..ef037c683 --- /dev/null +++ b/lib/review-targeted.ts @@ -0,0 +1,132 @@ +/** + * review-targeted — the targeted review pass on money, gate, auth and secret code + * (docs/review-policy.md#targeted-pass-on-money-gate-auth-and-secret-code; GST-257). + * A pure function from a collected diff (lib/review-collect.ts: changed paths with + * their added and deleted lines), gstack-diff-scope's flags and the gate scan to the + * classes the diff touches. bin/gstack-review-plan prints them and dispatches one + * `targeted` reader with the matching blocks of review/specialists/targeted.md; + * gstack-ship-receipt records the pass and gstack-ship-queue reports it. + * + * Matching reads added and deleted lines, so removing a cap, a check or a redaction + * counts. Path and identifier signals are token-bounded: `costume` never names cost. + * Docs and tests paths never trigger. Any input the classifier cannot read fails + * closed to all four classes, as a depth error fails the depth closed to full. + * + * The gate class is the gate scan's tagged hunks (a relaxed threshold, a skipped test, a + * loosened check), not GATE_PATH: over the last 100 non-release commits on main, GATE_PATH + * alone hit 43 (the precision gate allows 25), and those paths already get full depth. + */ +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { classifyPath } from './review-depth'; + +export const TARGETED_CLASSES = ['money', 'gate', 'auth', 'secret'] as const; +export type TargetedClass = (typeof TARGETED_CLASSES)[number]; + +const MONEY_PATH_TOKENS = ['cost', 'costs', 'spend', 'spending', 'budget', 'budgets', 'ledger', 'billing', 'price', 'prices', 'pricing', 'fee', 'fees', 'invoice', 'invoices']; +/** Identifiers ending in _usd, Usd or cap_usd, any spend_cap spelling, and paid-provider call sites (the providers lib/pricing.ts prices). */ +const MONEY_LINE = [ + /\b[A-Za-z0-9_]*_usd\b/i, /\b[a-z][A-Za-z0-9]*Usd\b/, /\bspend[_-]?cap\b/i, + /\bapi\.(?:anthropic|openai|voyageai)\.com\b/, /\bgenerativelanguage\.googleapis\.com\b/, /\bopenrouter\.ai\b/, + /\bnew\s+(?:Anthropic|OpenAI|AnthropicBedrock|AnthropicVertex)\s*\(/, /\.(?:messages|chat\.completions|responses|embeddings)\.create\s*\(/, +]; +const AUTH_PATH_TOKENS = ['auth', 'authn', 'authz', 'oauth', 'oauth2', 'jwt', 'session', 'sessions', 'permission', 'permissions', 'role', 'roles']; +const AUTH_PATH_PREFIXES = ['authenticat', 'authoriz']; +const SECRET_PATH = /(^|\/)(secrets?|credentials?|keychain|token[s]?)([._/-]|$)/i; +/** An environment name whose underscore-separated segments include a key word: OPENAI_API_KEY, GH_TOKEN; never MAX_TOKENS. */ +const KEY_NAME = String.raw`(?:[A-Z0-9]+_)*(?:API_?KEY|SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE_KEY|ACCESS_KEY|CREDENTIALS?)(?:_[A-Z0-9]+)*\b`; +const SECRET_LINE = [ + new RegExp(String.raw`\b(?:process\.env|Bun\.env|import\.meta\.env)(?:\.|\[\s*['"\x60])${KEY_NAME}`), + new RegExp(String.raw`\b(?:os\.environ(?:\.get\(|\[)|os\.getenv\(|ENV(?:\.fetch\(|\[))\s*['"]${KEY_NAME}`), + new RegExp(String.raw`\$\{?${KEY_NAME}`), + /\b(?:keychain|keytar|find-generic-password|find-internet-password|secret-tool|git-credential|credential\.helper)\b/i, /\bgh auth token\b/, /(?:^|[\s'"/])\.netrc\b/, +]; + +/** A hash of the patterns that decide the classes, not of this file's bytes: a comment edit keeps it, a pattern edit changes it. */ +export const TARGETED_RULES = createHash('sha256').update(JSON.stringify({ + TARGETED_CLASSES, MONEY_PATH_TOKENS, AUTH_PATH_TOKENS, AUTH_PATH_PREFIXES, KEY_NAME, + patterns: Object.entries({ MONEY_LINE, SECRET_LINE, SECRET_PATH: [SECRET_PATH] }).map(([k, v]) => [k, v.map(r => `/${r.source}/${r.flags}`)]), +})).digest('hex').slice(0, 16); + +export interface TargetedInput { + paths: readonly string[]; + /** Added and deleted lines per path (lib/review-collect.ts). */ + added?: Record; + removed?: Record; + /** gstack-diff-scope output (SCOPE_* keys); missing or SCOPE_ERROR fails closed. */ + scope?: Record; + /** Gate hunks the gate scan tagged; null when it did not run, which fails closed once a code path changed. */ + gateTagged: number | null; + root?: string | readonly string[]; + /** Collector failures (a diff git could not list). */ + errors?: readonly { code: string; input: string }[]; +} +export interface TargetedResult { classes: TargetedClass[]; reasons: string[]; errors: string[]; rules: string } + +const tokens = (p: string) => p.replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean); + +/** The classes a diff touches, in TARGETED_CLASSES order, with one reason per class; any input error returns all four. */ +export function targetedClasses(i: TargetedInput): TargetedResult { + const errors = (i.errors ?? []).map(e => `${e.code} (${e.input})`); + const s = i.scope; + if (!s) errors.push('scope unavailable (gstack-diff-scope did not run)'); + else if (s.SCOPE_ERROR) errors.push(`scope error: ${s.SCOPE_ERROR}`); + const code = i.paths.filter(p => classifyPath(p, i.root) === 'code'); + if (code.length && i.gateTagged === null) errors.push('gate scan unavailable'); + if (errors.length) return { classes: [...TARGETED_CLASSES], reasons: errors.map(e => `fail-closed: ${e}`), errors, rules: TARGETED_RULES }; + const why = new Map(); + const hit = (c: TargetedClass, reason: string) => { if (!why.has(c)) why.set(c, reason); }; + if ((i.gateTagged ?? 0) > 0) hit('gate', `gate scan tagged ${i.gateTagged} hunk(s)`); + for (const p of code) { + const t = tokens(p); + if (s!.SCOPE_AUTH === 'true' && t.some(x => AUTH_PATH_TOKENS.includes(x) || AUTH_PATH_PREFIXES.some(a => x.startsWith(a)))) hit('auth', `auth path: ${p}`); + if (SECRET_PATH.test(p)) hit('secret', `secret path: ${p}`); + if (t.some(x => MONEY_PATH_TOKENS.includes(x))) hit('money', `money path: ${p}`); + for (const line of [...(i.added?.[p] ?? []), ...(i.removed?.[p] ?? [])]) { + if (!why.has('money') && MONEY_LINE.some(r => r.test(line))) hit('money', `money line: ${p}`); + if (!why.has('secret') && SECRET_LINE.some(r => r.test(line))) hit('secret', `secret line: ${p}`); + } + } + const classes = TARGETED_CLASSES.filter(c => why.has(c)); + return { classes, reasons: classes.map(c => why.get(c)!), errors: [], rules: TARGETED_RULES }; +} + +/** The static brief every targeted reader gets its blocks from; its sha256 is the `brief` the plan prints and the receipt records. */ +export const TARGETED_BRIEF_FILE = path.join(import.meta.dir, '..', 'review', 'specialists', 'targeted.md'); +export function targetedBriefSha(file = TARGETED_BRIEF_FILE): string { + try { return createHash('sha256').update(fs.readFileSync(file)).digest('hex'); } catch { return 'missing'; } +} + +/** + * The patch the targeted reader reads: the patch-id of the whole committed diff from `mergeBase`, tests included, so a + * test-only fix that adds a gate hunk changes it. `uncommitted` with tracked edits or untracked files, `none` for an empty + * or unreadable diff; neither proves what a reader read. Not the receipt's product_patch_id (that leaves tests out). + */ +export function targetedPatchId(root: string, mergeBase: string): string { + const git = (args: string[], input?: string) => spawnSync('git', args, { cwd: root, input, encoding: 'utf8', timeout: 60_000, maxBuffer: 256 * 1024 * 1024 }); + if (git(['diff', '--quiet', 'HEAD', '--']).status !== 0 || git(['ls-files', '--others', '--exclude-standard']).stdout?.trim()) return 'uncommitted'; + const diff = git(['diff', '--no-color', '--no-ext-diff', mergeBase, 'HEAD']); + if (diff.status !== 0 || !diff.stdout.trim()) return 'none'; + return git(['patch-id', '--verbatim'], diff.stdout).stdout?.trim().split(/\s+/)[0] || 'none'; +} + +/** A class list as printed and recorded: comma-joined, or `none`. */ +export const classList = (classes: readonly string[]) => (classes.length ? classes.join(',') : 'none'); + +/** Parse a printed class list (`money,gate` or `none`); an unknown name gives null. */ +export function parseClassList(text: string): TargetedClass[] | null { + const names = text.trim() === 'none' ? [] : text.split(',').map(s => s.trim()).filter(Boolean); + if (names.some(n => !(TARGETED_CLASSES as readonly string[]).includes(n))) return null; + return TARGETED_CLASSES.filter(c => names.includes(c)); +} + +/** The brief a targeted reader gets: review/specialists/targeted.md's preamble plus the `## ` block of each class that hit. */ +export function targetedBrief(text: string, classes: readonly TargetedClass[]): string { + const parts = text.replace(/\r\n/g, '\n').split(/\n(?=## )/); + const blocks = new Map(parts.slice(1).map(b => [/^## (\w+)/.exec(b)?.[1] ?? '', b.trimEnd()])); + const missing = classes.filter(c => !blocks.has(c)); + if (!classes.length || missing.length) throw new Error(`targeted brief: ${classes.length ? `no block for ${missing.join(', ')}` : 'no class to brief'}`); + return [parts[0]!.trimEnd(), ...classes.map(c => blocks.get(c)!)].join('\n\n'); +} diff --git a/lib/ship-budget.ts b/lib/ship-budget.ts index 11bcfcc12..5b7adb1a7 100644 --- a/lib/ship-budget.ts +++ b/lib/ship-budget.ts @@ -29,7 +29,7 @@ import { SMALL_DIFF_LINES, type Role } from './review-depth'; export const SHIP_STEPS = ['0.9', '1', '2', '3', '4', '5', '6', '6.5', '7', '8', '9', '10', '11', '11.5', '12', '13', '14', '14.5', '15', '16', '17', '18', '19', '20', '21', '22'] as const; /** Reviewer roles plus `docs`, the Step 14.5 documentation audit: each is one reader priced and reconciled the same way. */ -export const DISPATCH_ROLES = ['specialist', 'red-team', 'adversarial-native', 'outside-voice', 'fix-check', 'docs'] as const satisfies readonly (Role | 'docs')[]; +export const DISPATCH_ROLES = ['specialist', 'red-team', 'adversarial-native', 'outside-voice', 'fix-check', 'targeted', 'docs'] as const satisfies readonly (Role | 'docs')[]; type MissingRole = Exclude; const ROLES_COMPLETE: [MissingRole] extends [never] ? true : never = true; void ROLES_COMPLETE; diff --git a/lib/ship-queue-train.ts b/lib/ship-queue-train.ts index fedf5681e..11525cfb4 100644 --- a/lib/ship-queue-train.ts +++ b/lib/ship-queue-train.ts @@ -365,6 +365,7 @@ function admitSlots(env: TrainEnv, pins: Array<{ pr: string; sha: string }>, dry } const shipped = bindShippedReceipt(ctx.repoRoot, `refs/remotes/${env.remote}/${ctx.base}`, info.body, p.pr, [info.headRefOid]); if (!shipped.ok) return outRefused({ code: shipped.code, message: shipped.message }, [shipped.line, ...(shipped.depthLine ? [shipped.depthLine] : [])], `#${p.pr}'s lane runs /ship, then ${Q} ready --pr ${p.pr} --screen|--no-screen; then ${Q} train build --prs `, 'none'); + lines.push(`${shipped.targetedLine} pr=${p.pr}`); const ready = loadReady(env.repo.keys, p.pr, info.headRefOid); if (!ready) return outRefused({ code: 'QUEUE_NOT_READY', message: `no READY receipt for #${p.pr} at ${info.headRefOid.slice(0, 12)} (local state or ${'refs/notes/gstack-trains'})` }, [], `the lane runs ${Q} ready --pr ${p.pr} --screen|--no-screen`, 'none'); let screen = ready.screen; diff --git a/lib/ship-queue.ts b/lib/ship-queue.ts index 73c309fa0..ae3925ece 100644 --- a/lib/ship-queue.ts +++ b/lib/ship-queue.ts @@ -271,6 +271,7 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate gateLine(policy, attempt), receipt.line, ...(receipt.depthLine ? [receipt.depthLine] : []), + ...(receipt.targetedLine ? [receipt.targetedLine] : []), ]; const gate = policy.policy.full_e2e ? gateEvidence(ctx, o.pr, head, attempt, o.gateExit, o.gateLog) : null; if (gate) lines.push(gate.line); diff --git a/lib/ship-receipt-depth.ts b/lib/ship-receipt-depth.ts index c3032a1f7..15d4bdbbf 100644 --- a/lib/ship-receipt-depth.ts +++ b/lib/ship-receipt-depth.ts @@ -11,7 +11,10 @@ * `review_loop_exception`: the owner's acceptance of a stop after a * fix-check edit under frozen scope (#3559), shaped like the docs exception. * The queue recomputes the depth the head needs from git objects - * (lib/review-collect.ts) and refuses a receipt below it. Kept beside + * (lib/review-collect.ts) and refuses a receipt below it. + * `review_targeted`: the targeted pass on money, gate, auth and secret code + * (lib/review-targeted.ts), which the queue reports beside the depth and does + * not refuse on yet (GST-257 PR 1). Kept beside * lib/ship-receipt.ts so that module stays under the size ratchet. */ import { spawnSync } from 'node:child_process'; @@ -19,6 +22,7 @@ import * as path from 'node:path'; import type { ShipReceipt } from './headless-artifacts'; import { collectCommit } from './review-collect'; import { depthOfCommitInput, gateScanNeeded, type Depth } from './review-depth'; +import { TARGETED_CLASSES, classList, targetedClasses, type TargetedClass } from './review-targeted'; import { runScriptSync } from './script-invocation'; import { bindReceipt, type ReceiptBindingCode } from './ship-receipt'; import { docsExceptionLine, parseReceiptDocs } from './ship-receipt-docs'; @@ -122,8 +126,46 @@ export function exceptionProblems(body: string, r: ShipReceipt, isAncestor?: (co return out; } +export type ReviewTargeted = NonNullable; + +/** `write --review-targeted` input (gstack-review-dispatch verify --targeted-out), bound to the receipt's product patch-id here. */ +export function parseReviewTargeted(value: unknown, productPatchId: string): { ok: true; targeted: ReviewTargeted } | { ok: false; message: string } { + const bad = (message: string) => ({ ok: false as const, message }); + if (!value || typeof value !== 'object' || Array.isArray(value)) return bad('review_targeted must be { classes, rules, brief_sha, status, findings, raised_by }'); + const v = value as Record; + for (const key of Object.keys(v)) if (!['classes', 'rules', 'brief_sha', 'status', 'findings', 'raised_by'].includes(key)) return bad(`review_targeted: unknown key ${JSON.stringify(key)}${key === 'product_patch_id' ? ' (write binds the pass to the receipt\'s product_patch_id)' : ''}`); + if (!Array.isArray(v.classes) || !v.classes.every(c => (TARGETED_CLASSES as readonly unknown[]).includes(c))) return bad(`review_targeted.classes must list ${TARGETED_CLASSES.join(', ')}`); + if (!text(v.rules) || !text(v.raised_by)) return bad('review_targeted.rules and raised_by must be non-empty one-line strings'); + if (typeof v.brief_sha !== 'string' || !/^[0-9a-f]{64}$/.test(v.brief_sha)) return bad('review_targeted.brief_sha must be the sha256 of review/specialists/targeted.md'); + if (!['done', 'failed', 'not-run'].includes(v.status as string)) return bad('review_targeted.status must be done, failed or not-run'); + if (typeof v.findings !== 'number' || !Number.isInteger(v.findings) || v.findings < 0) return bad('review_targeted.findings must be an integer >= 0'); + const classes = TARGETED_CLASSES.filter(c => (v.classes as string[]).includes(c)); + return { ok: true, targeted: { classes, rules: v.rules as string, brief_sha: v.brief_sha, product_patch_id: productPatchId, status: v.status as ReviewTargeted['status'], findings: v.findings, raised_by: (v.raised_by as string).trim() } }; +} + +/** The queue's report-only line: the classes the head needs against the classes a done targeted pass covered; never a refusal (GST-257 PR 1). */ +export function receiptTargetedLine(required: readonly TargetedClass[], r: ShipReceipt): string { + const rt = r.review_targeted; + const covered: readonly string[] = rt?.status === 'done' ? rt.classes : []; + const ok = required.every(c => covered.includes(c)); + return `REVIEW_TARGETED: required=${classList(required)} receipt=${rt ? classList(covered) : 'legacy'} verdict=${ok ? 'ok' : 'missing'}`; +} + export interface DepthVerdict { ok: boolean; line: string; message?: string } +/** The head's committed range against the base, read once for the depth verdict and the targeted line. */ +interface HeadInputs { baseSha: { ok: boolean; out: string }; c: ReturnType; scope: Record } +function headInputs(root: string, baseRef: string, head: string): HeadInputs { + const baseSha = git(root, ['rev-parse', '--verify', '--quiet', `${baseRef}^{commit}`]); + const baseRev = baseSha.ok ? baseSha.out : baseRef; + return { baseSha, c: collectCommit(root, baseRev, head), scope: scopeOfRange(root, BIN_DIR, baseRev, head) }; +} +/** The receipt's recorded gate scan when it names this head; otherwise null (unavailable). */ +const boundScan = (r: ShipReceipt, head: string): number | null => { + const g = r.review_depth?.gate_scan; + return g && g.tagged !== null && /^[0-9a-f]{40,64}$/.test(g.head) && g.head === head.toLowerCase() ? g.tagged : null; +}; + /** * The depth the bound head needs against `baseRef`, recomputed from git * objects; the recorded gate scan counts only when it names the receipt's @@ -131,14 +173,13 @@ export interface DepthVerdict { ok: boolean; line: string; message?: string } * with no review_depth and no gstack_version predates depth receipts and is * accepted as legacy; one from a writer that records depth but lacks it is not. */ -export function receiptDepthVerdict(root: string, baseRef: string, r: ShipReceipt, head: string): DepthVerdict { +export function receiptDepthVerdict(root: string, baseRef: string, r: ShipReceipt, head: string, at?: HeadInputs): DepthVerdict { const rd = r.review_depth; if (!rd && !r.gstack_version) return { ok: true, line: 'REVIEW_DEPTH: legacy receipt (written before receipts recorded review_depth); accepted' }; - const baseSha = git(root, ['rev-parse', '--verify', '--quiet', `${baseRef}^{commit}`]); - const baseRev = baseSha.ok ? baseSha.out : baseRef; - const c = collectCommit(root, baseRev, head); - const scanBound = !!rd && rd.gate_scan.tagged !== null && /^[0-9a-f]{40,64}$/.test(rd.gate_scan.head) && rd.gate_scan.head === head.toLowerCase(); - const need = depthOfCommitInput({ ...c, scope: scopeOfRange(root, BIN_DIR, baseRev, head), gateTagged: scanBound ? rd!.gate_scan.tagged : null }); + const { baseSha, c, scope } = at ?? headInputs(root, baseRef, head); + const tagged = boundScan(r, head); + const scanBound = tagged !== null; + const need = depthOfCommitInput({ ...c, scope, gateTagged: tagged }); const have = rd?.depth; const inputs = `base@${short(baseSha.ok ? baseSha.out : null)},registry@${short(c.registry.sha)},gate_scan@${scanBound ? short(rd!.gate_scan.head) : 'missing'}`; const line = `REVIEW_DEPTH: receipt=${have ?? 'none'} required=${need.depth} runtime_lines=${need.runtimeLines} class=${need.class} reasons=${need.reasons.join(',')} inputs=${inputs}`; @@ -148,17 +189,20 @@ export function receiptDepthVerdict(root: string, baseRef: string, r: ShipReceip return { ok: false, line, message: `the receipt ${why}, but head ${head.slice(0, 12)} needs ${need.depth} (${need.reasons.join(', ')}${errs ? `; ${errs}` : ''}); the receipt is unchanged; run /ship on ${head.slice(0, 12)}` }; } -/** bindReceipt plus the owner exceptions and the review depth: what READY and train build accept. */ +/** bindReceipt plus the owner exceptions and the review depth: what READY and train build accept; the targeted line is reported, never refused on. */ export function bindShippedReceipt(root: string, baseRef: string, body: string, pr: string | undefined, accepted: readonly string[]): - | { ok: true; receipt: ShipReceipt; head: string; line: string; depthLine: string } - | { ok: false; code: ReceiptBindingCode | 'QUEUE_RECEIPT_DEPTH_RAISED' | 'QUEUE_RECEIPT_EXCEPTION_INVALID'; message: string; line: string; depthLine?: string } { + | { ok: true; receipt: ShipReceipt; head: string; line: string; depthLine: string; targetedLine: string } + | { ok: false; code: ReceiptBindingCode | 'QUEUE_RECEIPT_DEPTH_RAISED' | 'QUEUE_RECEIPT_EXCEPTION_INVALID'; message: string; line: string; depthLine?: string; targetedLine?: string } { const b = bindReceipt(body, pr, accepted); if (!b.ok) return b; const ancestor = (commit: string) => b.receipt.carry?.ancestry === 'rewritten' || git(root, ['merge-base', '--is-ancestor', commit, b.head]).ok; const problems = exceptionProblems(body, b.receipt, ancestor); if (problems.length) return { ok: false, code: 'QUEUE_RECEIPT_EXCEPTION_INVALID', message: `the receipt's owner exception does not stand: ${problems.join('; ')}`, line: b.line }; - const d = receiptDepthVerdict(root, baseRef, b.receipt, b.head); - if (!d.ok) return { ok: false, code: 'QUEUE_RECEIPT_DEPTH_RAISED', message: d.message!, line: b.line, depthLine: d.line }; - return { ...b, depthLine: d.line }; + const at = headInputs(root, baseRef, b.head); + const d = receiptDepthVerdict(root, baseRef, b.receipt, b.head, at); + const gateTagged = boundScan(b.receipt, b.head) ?? (gateScanNeeded(at.c.paths, at.c.root) ? null : 0); + const targetedLine = receiptTargetedLine(targetedClasses({ ...at.c, scope: at.scope, gateTagged }).classes, b.receipt); + if (!d.ok) return { ok: false, code: 'QUEUE_RECEIPT_DEPTH_RAISED', message: d.message!, line: b.line, depthLine: d.line, targetedLine }; + return { ...b, depthLine: d.line, targetedLine }; } diff --git a/lib/ship-receipt.ts b/lib/ship-receipt.ts index 6cc75afa4..fd64130c5 100644 --- a/lib/ship-receipt.ts +++ b/lib/ship-receipt.ts @@ -19,7 +19,7 @@ const KEY_ORDER: Array = [ 'schema_version', 'pr', 'head', 'version', 'base', 'gated_tree', 'predecessor', 'tree', 'gate_reuse', 'gate', 'ci', 'spend_usd', 'session_kind', 'artifacts_consumed', 'run', 'thread', 'policy', 'queue_mode', 'preregistration', 'history', 'head_tree', 'product_patch_id', 'gstack_version', 'review_depth', 'pregate', 'requires_remote', 'issues', 'approval', 'docs', 'review_loop', - 'review_loop_exception', 'carry', + 'review_loop_exception', 'review_targeted', 'carry', ]; /** Keys in the fixed order so identifiers lead; undefined values are dropped. */ diff --git a/package.json b/package.json index d3498de0b..da3e030ab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.357", + "version": "1.91.358", "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/review/specialists/targeted.md b/review/specialists/targeted.md new file mode 100644 index 000000000..34455128e --- /dev/null +++ b/review/specialists/targeted.md @@ -0,0 +1,38 @@ +# Targeted Review Brief: money, gate, auth and secret code + +Scope: every diff whose `gstack-review-plan` TARGETED line names a class, at every depth; the reader gets the preamble and the block of each class that hit. +Output: JSON objects, one finding per line. Schema: +{"severity":"CRITICAL|INFORMATIONAL","confidence":N,"path":"file","line":N,"category":"money|gate|auth|secret","summary":"...","fix":"...","fingerprint":"path:line:category","specialist":"targeted"} +Optional: line, fix, fingerprint, evidence, test_stub, cause. +If no findings: output `NO FINDINGS` and nothing else. `NO FINDINGS` is a complete result, not a failure to look. + +--- + +Read-only: read the diff and the code it reaches, and edit, commit, post or run nothing that writes. +Read deleted lines as closely as added ones: a removed cap, check or redaction is the change under review. +Follow each changed value to where it is enforced: a cap, check or redaction that exists but is never reached is missing. + +CRITICAL is the blocking floor: a finding that would fail a test, break production, lose data or secrets, or open a security hole (injection, auth bypass, secret exposure). Everything else is INFORMATIONAL. + +## money +The checklist's Forced-CRITICAL line covers this block (review/checklist.md): uncapped spend, "a paid call, retry or loop with no cap", is "CRITICAL on sight", and no lane, specialist, dedup step or saved decision downgrades it. +- A paid call (a model API, a cloud VM, a paid search) reachable without a cap, or a retry, loop or fan-out around one with no bound on count or spend. +- A cap read but not enforced: checked after the spend, compared in the wrong unit (cents and dollars, tokens and USD), or a missing value read as unlimited. +- A cap check removed, loosened, or moved behind a flag that defaults to off. +- Spend recorded wrong: an estimate stored as actual cost, a failed call recorded as zero, a double count, or a ledger write that can be skipped. + +## gate +- A check that fails open: an error, timeout, missing input or unreadable file read as a pass, or an empty result read as green. +- A threshold, required check, allowlist or rule loosened, skipped or made optional; a test skipped or deleted together with the guard it covered. +- A verdict bound to the wrong subject: another commit, tree, branch, PR or run than the one being merged, or a stale result reused. +- A rule hash, pin or ratchet that detects such an edit changed in the same diff without the evidence it asks for. + +## auth +- A bypass: a route, command or path reachable without the identity check its siblings run, or a default that allows. +- A permission checked on the wrong subject: the caller instead of the owner, a role read from input, or a check made before the object is resolved. +- A session or token accepted after expiry, revocation or a role change, or a callback that skips its single-use state value. + +## secret +- A secret written where others can read it: logs, error messages, PR bodies, comments, artifacts, URLs, readable files, or a child process's arguments. +- A redaction, scrub, mask or file mode removed or bypassed, or a new output path that skips the existing redactor. +- A key read from a broader source than before, a fallback to a hardcoded value, or a real credential committed in code or fixtures. diff --git a/ship/sections/adversarial.md b/ship/sections/adversarial.md index 0db5be6c2..0bc8600b3 100644 --- a/ship/sections/adversarial.md +++ b/ship/sections/adversarial.md @@ -4,7 +4,8 @@ `DISPATCH: adversarial-native` and the outside voice only on `DISPATCH: outside-voice`. Without the native line (light depth at runtime 0; above 0 the plan lists it beside the batched specialist), print `ADVERSARIAL: skipped (review plan)`; -with neither line, take decision 3 of Finish the adversarial phase. Under a token cap, +with neither line, take decision 3 of Finish the adversarial phase. On `DISPATCH: targeted`, also run the targeted reader +(`gstack-review-dispatch render --role targeted --classes --base --diff-base `, the merge base on every pass, never RANGE_BASE) like the voices; `verify --targeted-out` feeds the receipt's `--review-targeted`. Under a token cap, reserve the planned voices as one batch (`gstack-ship-budget check --next dispatch:`) and add `--reservation ` to the `--start adversarial-review` call below. diff --git a/ship/sections/adversarial.md.tmpl b/ship/sections/adversarial.md.tmpl index 417be026e..ea99ddfdd 100644 --- a/ship/sections/adversarial.md.tmpl +++ b/ship/sections/adversarial.md.tmpl @@ -2,7 +2,8 @@ `DISPATCH: adversarial-native` and the outside voice only on `DISPATCH: outside-voice`. Without the native line (light depth at runtime 0; above 0 the plan lists it beside the batched specialist), print `ADVERSARIAL: skipped (review plan)`; -with neither line, take decision 3 of Finish the adversarial phase. Under a token cap, +with neither line, take decision 3 of Finish the adversarial phase. On `DISPATCH: targeted`, also run the targeted reader +(`gstack-review-dispatch render --role targeted --classes --base --diff-base `, the merge base on every pass, never RANGE_BASE) like the voices; `verify --targeted-out` feeds the receipt's `--review-targeted`. Under a token cap, reserve the planned voices as one batch (`gstack-ship-budget check --next dispatch:`) and add `--reservation ` to the `--start adversarial-review` call below. diff --git a/test/review-packets-removed.test.ts b/test/review-packets-removed.test.ts index 0b5bdb7b0..ab7ff8240 100644 --- a/test/review-packets-removed.test.ts +++ b/test/review-packets-removed.test.ts @@ -55,11 +55,12 @@ const NO_LOSS_REPLAYS = { 'PR-a': "GSTA-167 variant G Arm 4: structural plan identity at the 14 cohort heads and the free exact re-score of saved C, D and A' findings (parts 4a and 4b)", 'G1': "GSTA-167 variant G Arm G1: live no-loss replay of both prose voices with part 1's prompts, 2 passes, normalizer v2, paired rule against pooled C' (parts 1 and 3; specialists at medium as in C and C2)", 'L': "GSTA-167 variant G Arm L: live no-loss replay of one batched reader (testing and security plus scoped checklists) and the Red Team on its findings, 2 passes, scored L∘G1 against G1 on the paired rule (part 2)", + 'GST-257': "GST-257 PR 1 dispatch-superset no-loss check (test/review-targeted.test.ts): on every fixture diff the branch dispatches main's reviewers at 8be7f21 unchanged plus only the targeted reader, and over the last 100 non-release commits on main it lost none; adds the targeted role and brief to the G1 and L bytes, whose existing reviewer text is unchanged", } as const; type Replay = keyof typeof NO_LOSS_REPLAYS; const REVIEWER_BRIEFS: Record = { 'ship/sections/review-army.md': { sha: 'b7ca291a1cc63750bfb9710a4c12fd971523b9598da2a32ef80fb258eb0eceab', replay: 'L' }, - 'ship/sections/adversarial.md': { sha: 'c48aa7717c7438941fa23418367e19a62e07edda7445d0d5a55924e172dd5d33', replay: 'G1' }, + 'ship/sections/adversarial.md': { sha: 'd776ee5bc6e0c13930f8f4de05bad2f1ed9e52bc9fc5df80ae3ba7680455b2bb', replay: 'GST-257' }, 'review/sections/review-army.md': { sha: '78329b64640260cd8831ca81a9189f3f8dcade9c406431631d3f2cd798cd3967', replay: '#3638' }, 'review/sections/adversarial.md': { sha: 'e6962fa25b78f362654f2954f79683e524d2ebd452d1494f8a2b099888e8e255', replay: 'G1' }, 'review/checklist.md': { sha: '607788afeb2c83f2dff1dccd58d212616ec72393d28cda2fa18690c1a83df699', replay: '#3638' }, @@ -74,8 +75,9 @@ const REVIEWER_BRIEFS: Record = { 'review/specialists/simplification.md': { sha: 'f33846b1dcf2fdeef82d0744ab2ef48fb4a33b34a737dafc33dd0e891756689f', replay: '#3638' }, 'review/specialists/state-machines.md': { sha: '597215e4438d85d9cc71b48097c61eef7cba4f4b09dc14c92b4b94893b3d3ea2', replay: '#3638' }, 'review/specialists/testing.md': { sha: 'd6cc2bef7be562387d0c0a22fefe646dff9c295f1d34d8453a6cb496584150e4', replay: '#3638' }, + 'review/specialists/targeted.md': { sha: '7f92a0d0a8319c3c0e076fb3b1a4689c45120b2be2c14dfc7d4cb03aafa9d6a5', replay: 'GST-257' }, 'scripts/resolvers/fix-check.ts': { sha: '712bf5b0c439e96bac32f19cbe25394dabb4c94aa66600100bfde9b100e15a08', replay: '#3638' }, - 'lib/review-depth.ts': { sha: 'd4715a5f534d1470c63da879a3d66bb090a9b4929065d1f06c30acd97ef77bb0', replay: 'L' }, + 'lib/review-depth.ts': { sha: '4542785a1e217d8dd054985978b42c055b9fd79082579cb7664f187f7066e48e', replay: 'GST-257' }, }; describe('reviewer briefs stay at their no-loss-tested bytes (GSTA-167)', () => { diff --git a/test/review-targeted.test.ts b/test/review-targeted.test.ts new file mode 100644 index 000000000..3f0f69859 --- /dev/null +++ b/test/review-targeted.test.ts @@ -0,0 +1,350 @@ +/** + * GST-257 PR 1: the targeted review pass on money, gate, auth and secret code + * (lib/review-targeted.ts, docs/review-policy.md#targeted-pass-on-money-gate-auth-and-secret-code). + * The classifier, the plan's TARGETED and DISPATCH lines, the dispatch-superset no-loss check + * against main's reviewers, the production path from render to the receipt and the review-loop + * summary, and the queue's report-only REVIEW_TARGETED line. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { classifyDiff, planReview, scanNumbers, type PlanInput } from '../lib/review-depth'; +import { raisedBy, summarizeReviewLoop } from '../lib/review-scope'; +import { TARGETED_RULES, targetedBrief, targetedBriefSha, targetedClasses, type TargetedClass, type TargetedInput } from '../lib/review-targeted'; +import { carryReceipt, extractReceipt, renderReceiptBlock } from '../lib/ship-receipt'; +import { bindShippedReceipt, parseReviewTargeted, receiptTargetedLine } from '../lib/ship-receipt-depth'; +import type { ShipReceipt } from '../lib/headless-artifacts'; +import { cleanup, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/restamp-fixture'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const PLAN = path.join(ROOT, 'bin', 'gstack-review-plan'); +const dirs: string[] = []; +const repos: FixtureRepo[] = []; +afterEach(() => { while (dirs.length) fs.rmSync(dirs.pop()!, { recursive: true, force: true }); while (repos.length) cleanup(repos.pop()!); }); + +const input = (paths: string[], o: Partial = {}): TargetedInput => ({ paths, added: {}, removed: {}, scope: { SCOPE_AUTH: 'false' }, gateTagged: 0, ...o }); +const classesOf = (i: TargetedInput) => targetedClasses(i).classes; + +describe('targetedClasses: the four classes from added and deleted lines', () => { + // Value: protects=a diff that adds or removes money, auth or secret handling gets the targeted reader; fails_when=a class reads only + // added lines, so a removed cap or redaction slips through, or a class fires on an unrelated diff; seam=lib/review-targeted.ts + test('each class has a positive and a negative case, and deleted lines count', () => { + expect(classesOf(input(['src/run.ts'], { added: { 'src/run.ts': ['const spend_cap_usd = 5;'] } }))).toEqual(['money']); + expect(classesOf(input(['src/run.ts'], { removed: { 'src/run.ts': [' if (spent > capUsd) throw new Error("over cap");'] } }))).toEqual(['money']); + expect(classesOf(input(['src/run.ts'], { added: { 'src/run.ts': ['const MAX_SPEND_USD = 10;'] } }))).toEqual(['money']); + expect(classesOf(input(['src/evals.ts'], { added: { 'src/evals.ts': ['const client = new Anthropic();'] } }))).toEqual(['money']); + expect(classesOf(input(['src/billing/invoice.ts']))).toEqual(['money']); + expect(classesOf(input(['src/run.ts'], { added: { 'src/run.ts': ['const total = a + b;'] } }))).toEqual([]); + expect(classesOf(input(['src/a.ts'], { gateTagged: 2 }))).toEqual(['gate']); + expect(classesOf(input(['lib/pregate-check.ts']))).toEqual([]); + expect(classesOf(input(['src/auth/session.ts'], { scope: { SCOPE_AUTH: 'true' } }))).toEqual(['auth']); + expect(classesOf(input(['src/auth/session.ts']))).toEqual([]); + expect(classesOf(input(['lib/authentication.ts', 'src/authorizer/index.ts'], { scope: { SCOPE_AUTH: 'true' } }))).toEqual(['auth']); + expect(classesOf(input(['src/client.ts'], { added: { 'src/client.ts': ['const key = process.env.OPENAI_API_KEY;'] } }))).toEqual(['secret']); + expect(classesOf(input(['src/log.ts'], { removed: { 'src/log.ts': [' console.log(s.replaceAll(process.env.ANTHROPIC_API_KEY ?? "", "***"));'] } }))).toEqual(['secret']); + expect(classesOf(input(['bin/run.sh'], { added: { 'bin/run.sh': ['curl -H "Authorization: $GH_TOKEN" "$URL"'] } }))).toEqual(['secret']); + expect(classesOf(input(['lib/credentials.ts']))).toEqual(['secret']); + expect(classesOf(input(['src/limits.ts'], { added: { 'src/limits.ts': ['const n = process.env.MAX_TOKENS;'] } }))).toEqual([]); + }); + + test('matching is token-bounded: costume, feeder, pricey and author name no class', () => { + expect(classesOf(input(['src/costume.ts'], { added: { 'src/costume.ts': ['export const costume = 1;', 'export const feeder = 2;', 'export const pricey = 3;', 'const usdRate = 4;'] } }))).toEqual([]); + expect(classesOf(input(['src/author.ts', 'src/feeder/list.ts'], { scope: { SCOPE_AUTH: 'true' } }))).toEqual([]); + expect(classesOf(input(['src/spend_usd_total.ts'], { added: { 'src/spend_usd_total.ts': ['const spend_usd_total = 1;'] } }))).toEqual(['money']); + }); + + test('docs and tests paths never trigger, whatever their lines say', () => { + const lines = ['const spend_cap_usd = 5;', 'const k = process.env.OPENAI_API_KEY;', 'new Anthropic()']; + const paths = ['docs/billing.md', 'test/billing.test.ts', 'test/fixtures/secrets/key.ts', 'CHANGELOG.md']; + expect(classesOf(input(paths, { scope: { SCOPE_AUTH: 'true' }, added: Object.fromEntries(paths.map(p => [p, lines])), removed: Object.fromEntries(paths.map(p => [p, lines])) }))).toEqual([]); + }); + + // Value: protects=the classifier fails closed like the depth does; fails_when=an unreadable diff, a scope error or a missing gate + // scan returns no class, so the targeted reader silently skips; seam=targetedClasses errors + test('an input the classifier cannot read fails closed to all four classes with the reason', () => { + const all: TargetedClass[] = ['money', 'gate', 'auth', 'secret']; + expect(targetedClasses(input(['src/a.ts'], { scope: undefined }))).toMatchObject({ classes: all, errors: ['scope unavailable (gstack-diff-scope did not run)'] }); + expect(targetedClasses(input(['src/a.ts'], { scope: { SCOPE_ERROR: 'unmatched' } })).classes).toEqual(all); + expect(targetedClasses(input(['src/a.ts'], { gateTagged: null })).errors).toEqual(['gate scan unavailable']); + expect(targetedClasses(input(['src/a.ts'], { errors: [{ code: 'numstat_failed', input: 'git diff --numstat' }] })).classes).toEqual(all); + expect(classesOf(input(['docs/a.md'], { gateTagged: null }))).toEqual([]); + expect(TARGETED_RULES).toMatch(/^[0-9a-f]{16}$/); + }); + + test('the classifier and the brief are gate class, so an edit that weakens them gets full review', () => { + for (const p of ['lib/review-targeted.ts', 'review/specialists/targeted.md']) expect(classifyDiff({ paths: [p], scope: {}, gateTagged: 0 }).class).toBe('gate'); + }); +}); + +describe('the targeted brief', () => { + const text = fs.readFileSync(path.join(ROOT, 'review/specialists/targeted.md'), 'utf8'); + // Value: protects=the brief carries Garry's floor and the checklist's uncapped-spend rule word for word; fails_when=the floor is + // paraphrased or the money block cites a line the checklist no longer has; seam=review/specialists/targeted.md + test('states the floor verbatim, cites the Forced-CRITICAL spend line the checklist still has, and keeps the specialist schema', () => { + expect(text).toContain('CRITICAL is the blocking floor: a finding that would fail a test, break production, lose data or secrets, or open a security hole (injection, auth bypass, secret exposure). Everything else is INFORMATIONAL.'); + const checklist = fs.readFileSync(path.join(ROOT, 'review/checklist.md'), 'utf8'); + for (const quote of ['a paid call, retry or loop with no cap', 'CRITICAL on sight']) { expect(text).toContain(quote); expect(checklist).toContain(quote); } + expect(text).toContain('Read-only'); + expect(text).toContain('If no findings: output `NO FINDINGS` and nothing else.'); + expect(text).toContain('"specialist":"targeted"'); + }); + test('targetedBrief keeps the preamble and only the blocks of the classes that hit', () => { + const brief = targetedBrief(text, ['money', 'secret']); + expect(brief).toContain('CRITICAL is the blocking floor'); + expect(brief).toContain('## money'); + expect(brief).toContain('## secret'); + expect(brief).not.toContain('## gate'); + expect(brief).not.toContain('## auth'); + expect(() => targetedBrief(text, [])).toThrow(/no class/); + expect(targetedBriefSha()).toBe(createHash('sha256').update(fs.readFileSync(path.join(ROOT, 'review/specialists/targeted.md'))).digest('hex')); + }); +}); + +describe('planReview dispatches the targeted reader', () => { + const plan = (o: Partial) => planReview({ paths: ['src/a.ts'], scope: {}, gateTagged: 0, diffLines: 5, numbers: scanNumbers({}), history: { fullPasses: 0, last: 'none' }, ...o } as PlanInput); + const roles = (p: ReturnType) => p.dispatches.map(d => `${d.role} ${d.effort}`); + test('at light, standard and full depth when a class hit, never when none did', () => { + expect(plan({ targeted: { classes: ['money'] } })).toMatchObject({ depth: 'light' }); + expect(roles(plan({ targeted: { classes: ['money'] } }))).toContain('targeted full'); + expect(plan({ diffLines: 80, perPath: [{ path: 'src/a.ts', lines: 80, binary: false }], targeted: { classes: ['secret'] } }).depth).toBe('standard'); + expect(roles(plan({ diffLines: 80, perPath: [{ path: 'src/a.ts', lines: 80, binary: false }], targeted: { classes: ['secret'] } }))).toContain('targeted full'); + expect(plan({ gateTagged: 1, targeted: { classes: ['gate'] } }).depth).toBe('full'); + expect(roles(plan({ gateTagged: 1, targeted: { classes: ['gate'] } }))).toContain('targeted full'); + expect(roles(plan({ targeted: { classes: [] } }))).not.toContain('targeted full'); + expect(roles(plan({ targeted: { classes: ['money'] }, history: { fullPasses: 1, last: 'full', fixedBelow: true } }))).toEqual(['fix-check full', 'targeted full']); + expect(plan({ targeted: { classes: ['money'] }, history: { fullPasses: 1, last: 'full' } }).dispatches).toEqual([]); + const ran = plan({ targeted: { classes: ['money'], ran: true } }); + expect(roles(ran)).not.toContain('targeted full'); + expect(ran.skipped).toContain('targeted (already read this product patch)'); + }); +}); + +// --- gstack-review-plan on fixture repositories outside the checkout --------------------------------------------------- +const lines = (n: number, f: (i: number) => string) => Array.from({ length: n }, (_, i) => f(i)).join('\n') + '\n'; +type Fixture = { base?: Record; head: Record; args?: string[] }; +const FIXTURES: Record = { + 'none-light': { head: { 'src/util.ts': lines(5, i => `export const v${i} = ${i};`) } }, + 'none-standard': { head: { 'src/util.ts': lines(80, i => `export const v${i} = ${i};`) } }, + 'none-docs': { head: { 'docs/billing.md': 'Set spend_cap_usd to 5 and read process.env.OPENAI_API_KEY.\n' } }, + 'none-tests': { head: { 'test/billing.test.ts': 'const spend_cap_usd = 5;\nconst k = process.env.OPENAI_API_KEY;\n' } }, + 'none-costume': { head: { 'src/costume.ts': 'export const costume = 1;\nexport const feeder = 2;\nexport const pricey = 3;\n' } }, + 'none-gate-path': { head: { 'lib/pregate-check.ts': 'export const strict = true;\n' } }, + 'secret-light': { head: { 'src/client.ts': 'export const key = process.env.OPENAI_API_KEY;\n' } }, + 'secret-path': { head: { 'lib/credentials.ts': 'export const store = new Map();\n' } }, + 'secret-removed': { base: { 'src/log.ts': 'export function log(s: string) {\n console.log(s.replaceAll(process.env.ANTHROPIC_API_KEY ?? "", "***"));\n}\n' }, head: { 'src/log.ts': 'export function log(s: string) {\n console.log(s);\n}\n' } }, + 'gate-tagged': { head: { 'src/a.ts': 'export const a = 1;\n' }, args: ['--gate-tagged', '2'] }, + 'money-light': { head: { 'src/charge.ts': 'export const spend_cap_usd = 5;\n' } }, + 'money-path': { head: { 'src/billing/invoice.ts': 'export const n = 1;\n' } }, + 'money-removed': { base: { 'src/run.ts': 'export function run(spent: number, capUsd: number) {\n if (spent > capUsd) throw new Error("over cap");\n return spent;\n}\n' }, head: { 'src/run.ts': 'export function run(spent: number, capUsd: number) {\n return spent;\n}\n' } }, + 'money-standard': { head: { 'src/evals.ts': 'import Anthropic from "@anthropic-ai/sdk";\nconst client = new Anthropic();\n' + lines(78, i => `export const v${i} = ${i};`) } }, + 'auth-path': { head: { 'src/auth/session.ts': 'export const ttl = 60;\n' } }, + 'money-fix-check': { head: { 'src/charge.ts': 'export const spend_cap_usd = 5;\n' }, args: ['--pass', '1', '--last', 'full', '--fixed-below'] }, + 'money-pass2': { head: { 'src/charge.ts': 'export const spend_cap_usd = 5;\n' }, args: ['--pass', '1', '--last', 'full', '--fixed-blocking'] }, + 'gate-repeat': { head: { 'lib/pregate-check.ts': 'export const strict = true;\n' }, args: ['--gate-tagged', '1', '--pass', '1', '--last', 'full', '--fixed-blocking', '--adversarial-ran'] }, +}; +/** + * main's reviewers on each fixture: gstack-review-plan at 8be7f21 (v1.91.351.0, before this change), DISPATCH lines verbatim. + * The branch must dispatch every one of them unchanged, and add only the targeted reader (the no-loss check: a superset). + */ +const MAIN: Record = { + 'none-light': { depth: 'light', classes: 'none', main: ['specialist batched effort=full checklists=testing,security,state-machines,red-team', 'adversarial-native adversarial effort=full reason=backtest inconclusive (GSTA-147 D5)'] }, + 'none-standard': { depth: 'standard', classes: 'none', main: ['specialist batched effort=medium checklists=testing,security,state-machines', 'adversarial-native adversarial effort=full', 'outside-voice outside-voice effort=host'] }, + 'none-docs': { depth: 'light', classes: 'none', main: [] }, + 'none-tests': { depth: 'light', classes: 'none', main: [] }, + 'none-costume': { depth: 'light', classes: 'none', main: ['specialist batched effort=full checklists=testing,security,state-machines,red-team', 'adversarial-native adversarial effort=full reason=backtest inconclusive (GSTA-147 D5)'] }, + 'none-gate-path': { depth: 'full', classes: 'none', main: ['specialist batched effort=medium checklists=testing,security,state-machines', 'red-team red-team effort=full', 'adversarial-native adversarial effort=full', 'outside-voice outside-voice effort=host'] }, + 'secret-light': { depth: 'light', classes: 'secret', main: ['specialist batched effort=full checklists=testing,security,state-machines,red-team', 'adversarial-native adversarial effort=full reason=backtest inconclusive (GSTA-147 D5)'] }, + 'secret-path': { depth: 'full', classes: 'secret', main: ['specialist batched effort=medium checklists=testing,security,state-machines', 'red-team red-team effort=full', 'adversarial-native adversarial effort=full', 'outside-voice outside-voice effort=host'] }, + 'secret-removed': { depth: 'light', classes: 'secret', main: ['specialist batched effort=full checklists=testing,security,state-machines,red-team', 'adversarial-native adversarial effort=full reason=backtest inconclusive (GSTA-147 D5)'] }, + 'gate-tagged': { depth: 'full', classes: 'gate', main: ['specialist batched effort=medium checklists=testing,security,state-machines', 'red-team red-team effort=full', 'adversarial-native adversarial effort=full', 'outside-voice outside-voice effort=host'] }, + 'money-light': { depth: 'light', classes: 'money', main: ['specialist batched effort=full checklists=testing,security,state-machines,red-team', 'adversarial-native adversarial effort=full reason=backtest inconclusive (GSTA-147 D5)'] }, + 'money-path': { depth: 'light', classes: 'money', main: ['specialist batched effort=full checklists=testing,security,state-machines,red-team', 'adversarial-native adversarial effort=full reason=backtest inconclusive (GSTA-147 D5)'] }, + 'money-removed': { depth: 'light', classes: 'money', main: ['specialist batched effort=full checklists=testing,security,state-machines,red-team', 'adversarial-native adversarial effort=full reason=backtest inconclusive (GSTA-147 D5)'] }, + 'money-standard': { depth: 'standard', classes: 'money', main: ['specialist batched effort=medium checklists=testing,security,state-machines', 'adversarial-native adversarial effort=full', 'outside-voice outside-voice effort=host'] }, + 'auth-path': { depth: 'full', classes: 'auth', main: ['specialist batched effort=medium checklists=testing,security,state-machines', 'red-team red-team effort=full', 'adversarial-native adversarial effort=full', 'outside-voice outside-voice effort=host'] }, + 'money-fix-check': { depth: 'light', classes: 'money', main: ['fix-check fix-check effort=full'] }, + 'money-pass2': { depth: 'light', classes: 'money', main: ['specialist batched effort=medium checklists=testing,security,state-machines', 'red-team red-team effort=full', 'adversarial-native adversarial effort=full', 'outside-voice outside-voice effort=host'] }, + 'gate-repeat': { depth: 'full', classes: 'gate', main: ['specialist batched effort=medium checklists=testing,security,state-machines', 'red-team red-team effort=full'] }, +}; + +function planRepo(f: Fixture) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'targeted-plan-')); + const state = fs.mkdtempSync(path.join(os.tmpdir(), 'targeted-plan-state-')); + dirs.push(dir, state); + const git = (...a: string[]) => spawnSync('git', a, { cwd: dir, encoding: 'utf8', timeout: 10_000 }); + const write = (files: Record) => { for (const [p, t] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(dir, p)), { recursive: true }); fs.writeFileSync(path.join(dir, p), t); } }; + git('init', '-q', '-b', 'main'); git('config', 'user.email', 't@t'); git('config', 'user.name', 't'); git('config', 'commit.gpgsign', 'false'); + write({ 'README.md': '# x\n', ...(f.base ?? {}) }); git('add', '.'); git('commit', '-qm', 'base'); + git('checkout', '-qb', 'feat'); write(f.head); git('add', '.'); git('commit', '-qm', 'feat'); + const run = (args: string[]) => spawnSync(PLAN, ['--base', 'main', ...(args.includes('--gate-tagged') ? [] : ['--gate-tagged', '0']), ...args], { cwd: dir, encoding: 'utf8', timeout: 60_000, env: { ...process.env, GSTACK_STATE_ROOT: state } }); + return { dir, run, write }; +} +const field = (out: string, prefix: string, key: string) => new RegExp(`^${prefix}.* ${key}=(\\S+)`, 'm').exec(out)?.[1]; + +describe('gstack-review-plan prints TARGETED and dispatches the targeted reader', () => { + // Value: protects=the no-loss rule for review changes (CREW, 2026-10-30): every reviewer main dispatches on these diffs, at light, + // standard and full depth and on fix-check, scoped and repeat steps, is still dispatched unchanged, and the only addition is the + // targeted reader on a class diff; fails_when=the change drops, renames or re-efforts any reviewer, or adds the reader on a diff + // with no class; seam=bin/gstack-review-plan end to end; no_claim=that the reader finds the defect (the paid replay's question) + test('the dispatch-superset no-loss check: on every fixture the branch dispatches main\'s reviewers plus only the targeted reader', () => { + expect(Object.keys(MAIN).sort()).toEqual(Object.keys(FIXTURES).sort()); + expect(new Set(Object.values(MAIN).map(m => m.depth))).toEqual(new Set(['light', 'standard', 'full'])); + for (const [name, f] of Object.entries(FIXTURES)) { + const r = planRepo(f).run(f.args ?? []); + expect(r.status, `${name}: ${r.stderr}`).toBe(0); + const got = r.stdout.split('\n').filter(l => l.startsWith('DISPATCH: ')).map(l => l.slice('DISPATCH: '.length)); + const want = MAIN[name]!; + expect(r.stdout, name).toContain(`REVIEW_DEPTH: ${want.depth} `); + expect(got.filter(d => !want.main.includes(d)), name).toEqual(want.classes === 'none' ? [] : ['targeted targeted effort=full']); + expect(want.main.filter(d => !got.includes(d)), name).toEqual([]); + expect(field(r.stdout, 'TARGETED:', 'classes'), name).toBe(want.classes); + } + }, 180_000); + + test('the TARGETED line carries the rules hash and the brief\'s sha256; --targeted-ran skips the reader only for the same product patch', () => { + const repo = planRepo(FIXTURES['money-light']!); + const first = repo.run([]); + expect(field(first.stdout, 'TARGETED:', 'rules')).toBe(TARGETED_RULES); + expect(field(first.stdout, 'TARGETED:', 'brief')).toBe(targetedBriefSha()); + const patch = field(first.stdout, 'TARGETED:', 'targeted_patch_id')!; + expect(patch).toMatch(/^[0-9a-f]{40}$/); + const same = repo.run(['--pass', '1', '--last', 'full', '--fixed-below', '--targeted-ran', patch]); + expect(same.stdout).not.toContain('DISPATCH: targeted'); + expect(same.stdout).toContain('SKIP: targeted (already read this product patch)'); + const other = repo.run(['--pass', '1', '--last', 'full', '--fixed-below', '--targeted-ran', 'f'.repeat(40)]); + expect(other.stdout).toContain('DISPATCH: targeted targeted effort=full'); + expect(other.stdout).toContain('DIFF_EXCLUDE: targeted none'); + repo.write({ 'src/charge.ts': 'export const spend_cap_usd = 6;\n' }); + const dirty = repo.run(['--pass', '1', '--last', 'full', '--fixed-below', '--targeted-ran', patch]); + expect(field(dirty.stdout, 'TARGETED:', 'targeted_patch_id')).toBe('uncommitted'); + expect(dirty.stdout).toContain('DISPATCH: targeted targeted effort=full'); + expect(JSON.parse(repo.run(['--json']).stdout).targeted).toMatchObject({ classes: ['money'], rules: TARGETED_RULES, targeted_patch_id: 'uncommitted' }); + const read = planRepo(FIXTURES['money-light']!); + const readId = field(read.run([]).stdout, 'TARGETED:', 'targeted_patch_id')!; + read.write({ 'test/charge.test.ts': 'test("cap", () => {});\n' }); + for (const args of [['add', '.'], ['commit', '-qm', 'test-only fix']]) expect(spawnSync('git', args, { cwd: read.dir, encoding: 'utf8', timeout: 30_000 }).status).toBe(0); + const testFix = read.run(['--gate-tagged', '1', '--pass', '1', '--last', 'full', '--fixed-below', '--targeted-ran', readId]); + expect(field(testFix.stdout, 'TARGETED:', 'classes')).toBe('money,gate'); + expect(field(testFix.stdout, 'TARGETED:', 'targeted_patch_id')).not.toBe(readId); + expect(testFix.stdout).toContain('DISPATCH: targeted targeted effort=full'); + }, 90_000); +}); + +// --- production path: render, sent, collect, judge, verify, receipt write, summary and the queue ------------------------ +describe('the targeted role end to end on a fixture repository', () => { + // Value: protects=the targeted reader's record reaches the receipt only through the verbatim-dispatch ledger, bound to the + // receipt's product patch, and the review loop and the queue see it; fails_when=render, sent, collect, judge or verify drop the + // role, the record claims done without a collected output, write binds another product patch, summary loses the role, or the + // queue refuses on it; seam=bin/gstack-review-dispatch, bin/gstack-ship-receipt write, bin/gstack-review-scope summary and + // bindShippedReceipt; no_claim=PR 2's enforcement + test('render → sent → collect → judge → verify --targeted-out → write --review-targeted → summary → REVIEW_TARGETED', () => { + const repo = makeFixtureRepo({ prefix: 'targeted-e2e-' }); + repos.push(repo); + repo.git('checkout', '-q', '-b', 'feat'); + fs.mkdirSync(path.join(repo.work, 'src'), { recursive: true }); + fs.writeFileSync(path.join(repo.work, 'src', 'charge.ts'), 'export const spend_cap_usd = 5;\n'); + repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'charge'); + const head = repo.git('rev-parse', 'HEAD'); + const base = repo.git('merge-base', 'origin/main', 'HEAD'); + const plan = runBin(repo, 'gstack-review-plan', ['--base', 'main']); + expect(plan.out).toContain('TARGETED: classes=money '); + expect(plan.out).toContain('DISPATCH: targeted targeted effort=full'); + const out = path.join(repo.root, 'briefs'); + const ledger = path.join(out, 'review-ledger.jsonl'); + const render = runBin(repo, 'gstack-review-dispatch', ['render', '--role', 'targeted', '--classes', 'money', '--diff-base', base, '--out', out]); + expect(render.status, render.err).toBe(0); + const file = /file=(\S+)/.exec(render.out)![1]!; + const brief = fs.readFileSync(file, 'utf8'); + expect(brief).toContain('## money'); + expect(brief).not.toContain('## gate'); + expect(brief).toContain(`DIFF_BASE=${base} && git diff "$DIFF_BASE"`); + expect(brief).toContain('Read the targeted brief above ('); + expect(runBin(repo, 'gstack-review-dispatch', ['render', '--role', 'targeted', '--classes', 'cost', '--diff-base', base, '--out', path.join(repo.root, 'bad')]).err).toContain('BRIEF_RENDER_FAILED: slot classes must list'); + expect(runBin(repo, 'gstack-review-dispatch', ['render', '--role', 'targeted', '--classes', 'money', '--base', 'main', '--diff-base', base.slice(0, 12), '--out', path.join(repo.root, 'short')]).status).toBe(0); + expect(runBin(repo, 'gstack-review-dispatch', ['render', '--role', 'targeted', '--classes', 'money', '--diff-base', head, '--out', path.join(repo.root, 'range')]).err).toContain(`the targeted reader reads the whole diff on every pass: --diff-base must be the merge base with main (${base})`); + expect(runBin(repo, 'gstack-review-dispatch', ['sent', '--ledger', ledger, '--role', 'targeted', '--sent', file]).out).toContain('BRIEF_SENT: reader=targeted'); + const output = path.join(repo.root, 'targeted.out'); + const finding = { severity: 'CRITICAL', confidence: 8, path: 'src/charge.ts', line: 1, category: 'money', summary: 'cap is never enforced', fingerprint: 'src/charge.ts:1:money', specialist: 'targeted' }; + fs.writeFileSync(output, JSON.stringify(finding) + '\n'); + const merged = path.join(repo.root, 'merged.jsonl'); + expect(runBin(repo, 'gstack-review-dispatch', ['collect', '--ledger', ledger, '--role', 'targeted', '--output', output, '--merged', merged]).out).toContain('COLLECT: reader=targeted findings=1'); + const judged = path.join(repo.root, 'judged.jsonl'); + fs.writeFileSync(judged, JSON.stringify({ id: 'targeted#1', severity: 'CRITICAL' }) + '\n'); + expect(runBin(repo, 'gstack-review-dispatch', ['judge', '--ledger', ledger, '--merged', merged, '--judged', judged]).out).toContain('JUDGMENT: ok'); + const record = path.join(repo.root, 'targeted.json'); + const verify = runBin(repo, 'gstack-review-dispatch', ['verify', '--ledger', ledger, '--targeted-out', record]); + expect(verify.status, verify.out).toBe(0); + expect(verify.out).toContain(`TARGETED_RECORD: classes=money status=done findings=1 brief=${targetedBriefSha().slice(0, 12)} rules=${TARGETED_RULES}`); + expect(verify.out).toContain('VERIFY: clean'); + fs.writeFileSync(path.join(repo.work, 'src', 'charge.ts'), 'export const spend_cap_usd = 50;\n'); + repo.git('commit', '-qam', 'later product change the reader never read'); + expect(runBin(repo, 'gstack-review-dispatch', ['verify', '--ledger', ledger]).out).toContain('TARGETED_RECORD: classes=money status=failed findings=0'); + repo.git('reset', '-q', '--hard', head); + fs.writeFileSync(path.join(repo.work, 'src', 'charge.ts'), 'export const spend_cap_usd = 7;\n'); + expect(runBin(repo, 'gstack-review-dispatch', ['verify', '--ledger', ledger]).out).toContain('TARGETED_RECORD: classes=money status=failed findings=0'); + repo.git('checkout', '--', 'src/charge.ts'); + expect(runBin(repo, 'gstack-review-dispatch', ['verify', '--ledger', ledger]).out).toContain('TARGETED_RECORD: classes=money status=done findings=1'); + + const body = path.join(repo.root, 'body.md'); + const write = runBin(repo, 'gstack-ship-receipt', ['write', '--base', 'main', '--session-kind', 'unattended', '--review-targeted', `@${record}`, '--body', body, '--json']); + expect(write.status, write.err).toBe(0); + const receipt = extractReceipt(fs.readFileSync(body, 'utf8')).receipt!; + expect(receipt.review_targeted).toEqual({ classes: ['money'], rules: TARGETED_RULES, brief_sha: targetedBriefSha(), product_patch_id: receipt.product_patch_id!, status: 'done', findings: 1, raised_by: 'targeted' }); + const rebound = runBin(repo, 'gstack-ship-receipt', ['write', '--base', 'main', '--session-kind', 'unattended', '--review-targeted', JSON.stringify({ ...JSON.parse(fs.readFileSync(record, 'utf8')), product_patch_id: 'x' }), '--json']); + expect(rebound.status).not.toBe(0); + expect(rebound.err).toContain("write binds the pass to the receipt's product_patch_id"); + const plain = runBin(repo, 'gstack-ship-receipt', ['write', '--base', 'main', '--session-kind', 'unattended', '--json']); + expect(plain.status, plain.err).toBe(0); + expect(JSON.parse(plain.out)).not.toHaveProperty('review_targeted'); + + const slug = runBin(repo, 'gstack-slug', ['--get', 'SLUG']).out.trim(); + const logDir = path.join(repo.home, 'projects', slug); + 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']); + expect(summary.status, summary.err).toBe(0); + expect(JSON.parse(summary.out).dispatches).toEqual({ specialist: 1, 'adversarial-native': 1, targeted: 1 }); + + const bodyText = fs.readFileSync(body, 'utf8'); + const ok = bindShippedReceipt(repo.work, 'origin/main', bodyText, undefined, [head]); + expect(ok).toMatchObject({ ok: true, targetedLine: 'REVIEW_TARGETED: required=money receipt=money verdict=ok' }); + const { review_targeted: _drop, ...legacy } = receipt; + const missing = bindShippedReceipt(repo.work, 'origin/main', renderReceiptBlock(legacy as ShipReceipt), undefined, [head]); + expect(missing).toMatchObject({ ok: true, targetedLine: 'REVIEW_TARGETED: required=money receipt=legacy verdict=missing' }); + const failed = bindShippedReceipt(repo.work, 'origin/main', renderReceiptBlock({ ...receipt, review_targeted: { ...receipt.review_targeted!, status: 'failed' } }), undefined, [head]); + expect(failed).toMatchObject({ ok: true, targetedLine: 'REVIEW_TARGETED: required=money receipt=none verdict=missing' }); + }, 180_000); +}); + +describe('the receipt record and the queue line', () => { + const record = { classes: ['gate', 'money'], rules: TARGETED_RULES, brief_sha: 'a'.repeat(64), status: 'done', findings: 0, raised_by: 'targeted' }; + test('parseReviewTargeted binds the receipt\'s product patch, orders the classes and refuses a malformed record', () => { + expect(parseReviewTargeted(record, 'p1')).toEqual({ ok: true, targeted: { ...record, classes: ['money', 'gate'], product_patch_id: 'p1' } as any }); + for (const bad of [{ ...record, classes: ['cost'] }, { ...record, status: 'skipped' }, { ...record, findings: -1 }, { ...record, brief_sha: 'abc' }, { ...record, extra: 1 }, null]) expect(parseReviewTargeted(bad, 'p1').ok).toBe(false); + }); + // Value: protects=PR 1's queue only reports; fails_when=the line misreads a legacy, failed or partial receipt as covered, or a + // receipt without classes as required; seam=receiptTargetedLine (the line ready and train build print) + test('REVIEW_TARGETED reports ok, missing and legacy', () => { + const r = (rt?: object) => ({ schema_version: 1, head: 'h', base: 'main', ...(rt ? { review_targeted: { ...record, product_patch_id: 'p', ...rt } } : {}) }) as ShipReceipt; + expect(receiptTargetedLine([], r())).toBe('REVIEW_TARGETED: required=none receipt=legacy verdict=ok'); + expect(receiptTargetedLine(['money'], r())).toBe('REVIEW_TARGETED: required=money receipt=legacy verdict=missing'); + expect(receiptTargetedLine(['money'], r({}))).toBe('REVIEW_TARGETED: required=money receipt=gate,money verdict=ok'); + expect(receiptTargetedLine(['money', 'secret'], r({}))).toBe('REVIEW_TARGETED: required=money,secret receipt=gate,money verdict=missing'); + expect(receiptTargetedLine(['gate'], r({ status: 'failed' }))).toBe('REVIEW_TARGETED: required=gate receipt=none verdict=missing'); + expect(receiptTargetedLine([], r({ classes: [], status: 'not-run' }))).toBe('REVIEW_TARGETED: required=none receipt=none verdict=ok'); + }); + test('a carry keeps review_targeted with the unchanged product patch', () => { + const receipt = { schema_version: 1, head: 'a'.repeat(40), base: 'main', product_patch_id: 'p', review_targeted: { ...record, product_patch_id: 'p' } } as ShipReceipt; + const carried = carryReceipt(receipt, 'b'.repeat(40), { from: 'a'.repeat(40), reason: 'base merge', product_patch_id: 'p', changed_paths: [], merge_commits: 1, range_diff: 'same', covers: [], reruns: [] }, { tree: 't', productPatchId: 'p' }); + expect(carried.review_targeted).toEqual(receipt.review_targeted); + expect(carried.product_patch_id).toBe(carried.review_targeted!.product_patch_id); + }); + test('the targeted reader\'s findings are attributed to it and read as a Step 11 voice', () => { + expect(raisedBy({ specialist: 'targeted' })).toBe('targeted'); + expect(summarizeReviewLoop([{ skill: 'review', via: 'ship', timestamp: '2026-10-30T10:00:00Z', dispatches: [{ role: 'targeted', prompt_bytes: 40 }] }], '2026-10-30T09:00:00Z')).toMatchObject({ dispatches: { targeted: 1 }, est_input_tokens: 10 }); + }); +}); diff --git a/test/ship-budget-chain.test.ts b/test/ship-budget-chain.test.ts index 8f4f0f166..0e3b5968e 100644 --- a/test/ship-budget-chain.test.ts +++ b/test/ship-budget-chain.test.ts @@ -383,7 +383,7 @@ describe('variant E: the review segment and the docs reader (GSTA-167 replay 5)' expect(settled.out).toMatch(new RegExp(`^DRIFT: id=${id} kind=dispatch next=dispatch:docs projected=${K.per_dispatch} actual=2000000 `, 'm')); const step = check(h, s.run, '14.5', '15', ['--reading', '3500000']); expect(step.out).toMatch(/^DRIFT: id=\S+ kind=step next=14\.5 projected=\d+ actual=400000 /m); - expect(h.sb(['check', '--run', s.run, '--step', '15', '--next', 'dispatch:doc', '--reading', '3600000']).err).toContain('valid: specialist, red-team, adversarial-native, outside-voice, fix-check, docs'); + expect(h.sb(['check', '--run', s.run, '--step', '15', '--next', 'dispatch:doc', '--reading', '3600000']).err).toContain('valid: specialist, red-team, adversarial-native, outside-voice, fix-check, targeted, docs'); }); }); diff --git a/test/size-ledger/ship/gst-257-targeted-review.json b/test/size-ledger/ship/gst-257-targeted-review.json new file mode 100644 index 000000000..d6dec8e4c --- /dev/null +++ b/test/size-ledger/ship/gst-257-targeted-review.json @@ -0,0 +1,4 @@ +{ + "bytes": 302, + "why": "GST-257: Step 11 dispatches the targeted reader on money, gate, auth and secret diffs, on the merge base every pass" +}