From 8966f3620c08765688044d4c1acbb4eb7bf8f0f4 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 30 Oct 2026 22:06:03 -0700 Subject: [PATCH] v1.91.350.0 feat(review): GSTA-167 variant G reviewer stack: blocking floor in both voices, one batched specialist, data-file classifier (#3660) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(review): variant G part 4: data files no code names are docs in classifyDiff, untested state-matrix cells map below the floor per cell (GSTA-167 PR-a) Part 4a: lib/review-depth.ts owns DATA_PATH (JSON, JSONL, NDJSON, CSV, TSV, raster images; YAML, TOML and SVG stay code) and applies the data rule only in classifyDiff, to candidates lib/review-collect.ts finds no non-docs consumer for (git grep of basenames at head, merge base and, live, the working tree; data-to-data chains four deep; over 2,000 candidates keeps all code; a grep failure fails closed). classifyPath and every path-only caller are unchanged; the receipt gate scan decides through gateScanNeeded (path-only), so it cannot be skipped. gstack-ship-preflight computes RUNTIME_LINES with classifyDiff over gstack-review-plan --json (now with consumed). Part 4b: lib/review-scope.ts mapStateMatrixOutput maps an "untested cell" finding to INFORMATIONAL when its cell has test null in the same output's matrix and it is the only finding on that cell; gstack-state-matrix findings prints it for Step 9.2 before the merge. state-machines.md is byte-identical; check still reads the raw output. Reviewer-brief pins repinned under no-loss replay PR-a (Arm 4: plan identity at the 14 cohort heads; free exact re-score of saved C, D and A' findings). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(review): variant G parts 1 and 3: blocking floor in both prose voices, specialists at medium (GSTA-167) Part 1: the native adversarial and outside-voice prompts end with the blocking floor ("would fail a test, break production, or lose data or secrets") and ask each finding for Severity and Blocks: yes|no. Additive rule: a finding is CRITICAL when its label rule says so or it says Blocks: yes; Blocks: no never lowers one (proseVoiceSeverity, BLOCKING_FLOOR in lib/review-scope.ts). Part 3: ROLE_EFFORT puts specialists at medium; Red Team, native adversarial and fix-check stay full; light's batched specialist (carries the Red Team prompt) stays full; --no-tiers sends specialists to host. Repinned reviewer briefs with no-loss replay G1 (live Arm G1, not yet run): ship/sections/review-army.md, ship/sections/adversarial.md, review/sections/adversarial.md, lib/review-depth.ts. Merge only on G1's pass. Review-skill parity ratio 1.3136 -> 1.3180 and the gstack-review size pins +537 for the approved +0.3k tokens per voice. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(review): variant G part 2: lean ensemble, one batched specialist at every depth (GSTA-167) planReview dispatches one 'specialist batched' at standard and full depth (and light, which adds the Red Team checklist): Testing and Security always on code and gate class, plus State-machines, Data-migration, API-contract and Design when scope asks; a forced checklist always joins. Maintainability, Performance and Simplification are read only when forced (SKIP names them). LIGHT_FIXED is [testing, security]; BATCH_SCOPED joins DEPTH_RULES so old receipts re-plan. The Red Team rule and prompt are unchanged; it reads the batched reader's merged findings. specialistsFor stays as the legacy selection the dispatch compare script models. Repinned reviewer briefs with no-loss replay L (live Arm L, scored L∘G1 vs G1; not yet run): ship/sections/review-army.md, lib/review-depth.ts. Merge only on L's pass. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(review): variant G on #3648's dispatch: Blocks: yes collects as CRITICAL, batched checklists by name (GSTA-167) Integrates parts 1 and 2 with gstack-review-dispatch (#3648): - lib/review-ledger.ts parseFindings: a prose voice's "Blocks: yes" makes its own line, or the finding on the line just before it, CRITICAL in merged.jsonl (normalizer v2, certified by round 5's G1), so judge refuses a lead that keeps it below the floor; with no finding before it the Blocks line is its own CRITICAL finding. "Blocks: no" never lowers a label. - lib/review-brief.ts: the outside-voice brief no longer appends "Give each finding a severity"; the section's prompt now asks for Severity and Blocks, so the brief is the certified section prompt plus the branch diff. - bin/gstack-review-dispatch render --checklists takes the plan's checklists= names (review/specialists/.md) as well as paths and refuses an unknown one, since the batched reader is now every depth's specialist dispatch. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore(changelog): GSTA-167 variant G reviewer stack fragment Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review): consumer scan survives grep.lineNumber/grep.column; collect reads separate-line Severity/Blocks /ship pass 1 (PR #3660): the Red Team CRITICAL at lib/review-collect.ts:120 and the informational findings in the same batch. proseVoiceSeverity had no caller and is removed; the normalizer v2 table now runs through parseFindings, the path collect uses. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review): collect keeps every prose blocker and one id per finding /ship pass 2 (PR #3660): an unlabeled line carrying an inline Blocks: yes collects as CRITICAL again; Severity/Blocks continuation needs a colon, stops at a list item, heading, bold paragraph opener or a repeated field, and never swallows a top-level bullet or its section label; the native voice's FIXABLE collects as CRITICAL under its role with or without a dispatch name; the consumer scan adds --no-color. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review): a later finding's fields never land on the one before it; explicit Severity wins its line /ship pass 2 Red Team (PR #3660): prose after a finding's Severity/Blocks field, a post-blank paragraph or a line in an indented block starts the next finding, and a line's explicit Severity: label decides it over other label words on the line. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review): FIXABLE outranks a Severity label on its line; a blocker keeps its own id and subject /ship fix-check (PR #3660): labels are named before the strongest is taken, so a one-line native FIXABLE ... Severity: Medium collects as CRITICAL; a later finding starts at a file:line after the open finding's fields, field bullets stay on their finding, a field line may omit the colon, and continuation prose never replaces a list item's subject. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): GSTA-167 fragment adds round 6 and the collect and consumer-scan fixes 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 | 19 +++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-review-dispatch | 6 +- bin/gstack-review-plan | 12 +- bin/gstack-state-matrix | 18 ++- capy/skill/gstack/SKILL.md | 2 +- docs/model-policy.md | 13 +- docs/review-policy.md | 65 +++++++-- docs/skills.md | 2 +- lib/review-brief.ts | 2 +- lib/review-collect.ts | 72 ++++++++-- lib/review-depth.ts | 91 ++++++++++-- lib/review-ledger.ts | 76 +++++++++-- lib/review-scope.ts | 40 +++++- lib/ship-preflight.ts | 12 +- lib/ship-receipt-depth.ts | 4 +- package.json | 2 +- review/sections/adversarial.md | 8 +- scripts/resolvers/outside-voice-steps.ts | 9 +- scripts/resolvers/review-army.ts | 7 +- scripts/review-dispatch-compare.ts | 2 +- ship/sections/adversarial.md | 8 +- ship/sections/review-army.md | 12 +- ship/sections/review-army.md.tmpl | 7 +- test/fixtures/skill-md-size-allowlist.json | 16 +-- test/helpers/carve-guards.ts | 2 +- test/prose-voice-floor.test.ts | 71 ++++++++++ test/review-collect.test.ts | 62 ++++++++- test/review-depth.test.ts | 152 ++++++++++++++++++--- test/review-dispatch.test.ts | 110 +++++++++++++++ test/review-packets-removed.test.ts | 62 +++++---- test/review-plan-cli.test.ts | 25 ++-- test/review-scope.test.ts | 64 ++++++++- test/ship-preflight.test.ts | 16 +++ test/ship-receipt-depth.test.ts | 22 ++- test/ship-release.test.ts | 13 ++ test/size-ledger/ship/gsta-167-g-a.json | 4 + test/size-ledger/ship/gsta-167-g-b.json | 4 + test/size-ledger/ship/gsta-167-g-c.json | 4 + 40 files changed, 955 insertions(+), 165 deletions(-) create mode 100644 test/prose-voice-floor.test.ts create mode 100644 test/size-ledger/ship/gsta-167-g-a.json create mode 100644 test/size-ledger/ship/gsta-167-g-b.json create mode 100644 test/size-ledger/ship/gsta-167-g-c.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 896d1eb51..63410dc5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [1.91.350.0] - 2026-10-31 + +**`/ship` review costs less and catches more: one batched specialist, a blocking floor in both prose voices, and data files that no code reads review as docs (GSTA-167 variant G).** The no-loss replay certified the stack before merge. Round 5 replayed 14 PRs with 29 known blockers, and the new reviewers found 24 against the control's 21, non-inferior at p=0.035. Round 6 pooled 41 blockers (the 29 plus 12 confirmed units from GSTA-2): 33 against 27, non-inferior at p=0.0055. The free re-score for the classifier and state-matrix change lost 0 blockers. + +### Changed +- **One batched specialist reads every checklist at each depth.** It always reads Testing and Security on code and gate diffs, and adds State-machines, Data-migration, API-contract and Design when scope asks for them. Maintainability, Performance and Simplification run only when forced, and `gstack-review-plan` names them on a `SKIP:` line. The Red Team reads the batched reader's merged findings. +- **Specialists run at medium reasoning effort.** The Red Team, the native adversarial pass and the fix-check stay at full effort. `--no-tiers` (or `review_effort_tiers off`) runs specialists at the host default. +- **Both prose voices carry the blocking floor.** The native adversarial and outside-voice prompts say a finding blocks only if it would fail a test, break production, or lose data or secrets, and end each finding with `Severity:` and `Blocks: yes|no`. A finding is CRITICAL when its old label rule says so or it says `Blocks: yes`, and `Blocks: no` never lowers one. `gstack-review-dispatch collect` records a `Blocks: yes` finding as CRITICAL, so `judge` refuses a lead judgment that keeps it below the floor. +- **A JSON, CSV, TSV or raster data file that no code names counts as docs, not runtime lines.** `classifyDiff` finds code consumers with `git grep`; YAML, TOML, SVG and config files stay code, and path-only callers (the receipt's gate scan, the release route) keep data files code. `gstack-ship-preflight`'s `RUNTIME_LINES` now comes from the same function. +- **An untested state-matrix cell is a test gap below the floor.** A wrong row or collapsed kinds stays CRITICAL, and the state-machines checklist itself is unchanged. + +### Fixed +- **`gstack-review-dispatch render --checklists` takes the plan's checklist names** as well as paths, and refuses an unknown one. The outside-voice brief no longer adds a separate severity line, since the prompt now asks for Severity and Blocks itself. +- **`collect` keeps one finding per id in the layout the prompts ask for.** A `Severity:` or `Blocks:` line below a finding joins that finding, an inline `Blocks: yes` with no label still collects as CRITICAL, and the native voice's FIXABLE collects as CRITICAL even beside a lower `Severity:`. When the layout is unclear, `collect` merges findings rather than dropping one. +- **The data-file consumer scan reads `git grep` the same way under any git config.** `grep.lineNumber`, `grep.column` and `color.grep` no longer hide a consumer, and a failed scan keeps every data file code with `consumer_scan_failed`. + +### Tests +- `test/prose-voice-floor.test.ts` renders both prose prompts for the claude and codex hosts and checks the floor sentence and the Blocks line. `test/review-depth.test.ts` covers the batched reader on the captured fixtures, effort per role and the data-file rule. `test/review-dispatch.test.ts` covers `Blocks: yes` collection and batched checklist names. `test/review-packets-removed.test.ts` repins the changed briefs to their no-loss replays (PR-a, G1, L). + ## [1.91.349.0] - 2026-10-31 **Nine flaky tests from the 2026-10-30 flaky pass stop failing correct runs.** Two paid detectors missed correct replies, one paid case lost the evidence of its own failure, five free tests raced their own fixtures or paid a cold start inside a timed call, and two Windows failures came from real defects: a lost IPC receipt in the QA deadline worker and a Chromium feature that fails loopback connections. diff --git a/VERSION b/VERSION index ceceb78d3..a9eef217c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.349.0 +1.91.350.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index c9154aba3..c2745c8e1 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.349.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.350.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 66a47d9c4..9e4ea74ce 100755 --- a/bin/gstack-review-dispatch +++ b/bin/gstack-review-dispatch @@ -24,7 +24,7 @@ const HELP = `gstack-review-dispatch — verbatim reviewer briefs and loss-free roles: ${BRIEF_ROLES.join(', ')} slots: --diff-base (every role but fix-check) --exclude '' --stack --test-fw --learnings-file --checklist (default /review/specialists/.md; red-team.md for red-team) - --checklists (specialist-batched) --findings-file --specialists (red-team) + --checklists (specialist-batched: the plan's checklists= names, or paths) --findings-file --specialists (red-team) --fix-list --hunks --tests [--callers ] (fix-check) 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. @@ -57,6 +57,8 @@ function render(): number { 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 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; if (role === 'outside-voice' && diffBase) { const r = spawnSync('git', ['-c', 'core.quotePath=false', 'diff', '--no-color', '--no-ext-diff', diffBase], { encoding: 'utf8', timeout: 60_000, maxBuffer: 256 * 1024 * 1024 }); @@ -65,7 +67,7 @@ function render(): number { } const slots: Slots = { name, diff_base: diffBase, exclude: flag('--exclude'), stack: flag('--stack'), test_fw: flag('--test-fw'), - learnings: readFile(flag('--learnings-file'), 'learnings'), checklist, checklists: flag('--checklists'), + learnings: readFile(flag('--learnings-file'), 'learnings'), checklist, checklists: checklists?.join(','), findings: readFile(flag('--findings-file'), 'findings'), specialists: flag('--specialists'), diff, fix_list: readFile(flag('--fix-list'), 'fix list'), hunks: readFile(flag('--hunks'), 'hunks'), tests: readFile(flag('--tests'), 'tests'), callers: readFile(flag('--callers'), 'callers'), }; diff --git a/bin/gstack-review-plan b/bin/gstack-review-plan index a0fcf3228..5826f66f1 100755 --- a/bin/gstack-review-plan +++ b/bin/gstack-review-plan @@ -39,12 +39,14 @@ const HELP = `gstack-review-plan — the review depth of the next /ship step (do --adversarial-ran Step 11's native adversarial pass already ran (a repeat skips it) --gate-tagged gate hunks the gate scan tagged (default: run gstack-gate-diff; failure means unavailable) --gated a,b specialists the hit-rate stats tag GATE_CANDIDATE - --force a,b|all specialists forced by prompt flags (--all-specialists is all) + --force a,b|all specialists forced by prompt flags (--all-specialists is all): at standard and full depth they join the + batched specialist's checklists, where maintainability, performance and simplification are read only when + forced; light runtime-0 and scoped passes dispatch them one reader each --reviewed-numbers the numeric-claim fingerprint the outside voice already reviewed --depth-floor standard|full raise the depth to at least this (never lowers it); /ship passes its prompt flag on every run --range-base the fix range's base (this pass's PASS_HEAD): after a fixed blocker on a diff below full depth, a committed range of at most ${SCOPED_RANGE_LINES} runtime lines plans a scoped pass; anything unread plans the full pass - --no-tiers audits and watchers at the host's default effort too (as gstack-config review_effort_tiers off does) + --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 --fix-check-prompt print the fix-check reviewer's prompt from --fix-list, --hunks, --tests and --callers files @@ -130,14 +132,14 @@ function main(): number { } } const plan = planReview({ - ...collected, scope, gateTagged, numbers: scanNumbers(added), force, gated: list('--gated'), floor, + ...collected, scope, gateTagged, 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 }) + '\n'); + 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'); 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}`]; @@ -150,7 +152,7 @@ function main(): number { if (!d.exclude.length) out.push(`DIFF_EXCLUDE: ${key} none`); } for (const s of plan.skipped) out.push(`SKIP: ${s}`); - out.push(`AUDIT_EFFORT: ${auditEffort} (coverage, plan-completion and docs audits, decision-trail reviewers, watchers; reviewers above stay full)`); + out.push(`AUDIT_EFFORT: ${auditEffort} (coverage, plan-completion and docs audits, decision-trail reviewers, watchers; reviewers above run at their effort=)`); process.stdout.write(out.join('\n') + '\n'); return EXIT.ok; } diff --git a/bin/gstack-state-matrix b/bin/gstack-state-matrix index 6e7ac0204..f17ee2cd0 100755 --- a/bin/gstack-state-matrix +++ b/bin/gstack-state-matrix @@ -5,6 +5,7 @@ * gstack-state-matrix detect [--base ] [--diff-base ] * gstack-state-matrix check [--repo ] [--expect ] [--json] * gstack-state-matrix outfile + * gstack-state-matrix findings * * `detect` prints `SCOPE_STATE_MACHINE=true|false` (shell-safe for * `source <(...)`, with `# state-machine:` comment lines naming the hits) from @@ -14,7 +15,10 @@ * last; exit 0 ok, 1 incomplete (STATE_MATRIX_INCOMPLETE). `outfile` creates and * prints a new empty 0600 file in the repo's private `.gstack/tmp/` (0700, kept * out of git through info/exclude) for the specialist's output, so a later - * review never checks or commits an earlier run's file. Logic: lib/state-matrix.ts. + * review never checks or commits an earlier run's file. `findings` prints the + * output for Step 9.3's merge with each untested cell mapped below the floor + * (lib/review-scope.ts mapStateMatrixOutput) and `STATE_MATRIX_FINDINGS:` on + * stderr. Logic: lib/state-matrix.ts. */ import { spawnSync } from 'node:child_process'; import { randomBytes } from 'node:crypto'; @@ -24,6 +28,7 @@ import { addedLines } from '../lib/banned-terms'; import { EXIT, exitTableHelp } from '../lib/headless-artifacts'; import { resultLine } from '../lib/result-codes'; import { resolveBaseBranch } from '../lib/ship-policy'; +import { mapStateMatrixOutput } from '../lib/review-scope'; import { checkStateMatrix, detectStateMachine, renderStateMatrixCheck, type Expectation } from '../lib/state-matrix'; const HELP = `gstack-state-matrix — kind × owner action × resulting row for proposal, hold and approval state machines @@ -32,6 +37,7 @@ const HELP = `gstack-state-matrix — kind × owner action × resulting row for check [--repo ] [--expect ] [--json] gaps in the state-machines specialist's output (review/specialists/state-machines.md) outfile print a new empty private file for that output + findings that output for the Step 9.3 merge, each untested cell mapped below the floor ${exitTableHelp()}`; @@ -71,6 +77,15 @@ function check(rest: string[]): number { return EXIT.ok; } +function findings(rest: string[]): number { + const input = rest.find(a => !a.startsWith('--')); + if (!input) { process.stderr.write(`gstack-state-matrix findings: a reviewer output file (or -) is required\n${HELP}\n`); return EXIT.usage; } + const r = mapStateMatrixOutput(input === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(input, 'utf8')); + process.stdout.write(r.text.endsWith('\n') ? r.text : `${r.text}\n`); + process.stderr.write(`STATE_MATRIX_FINDINGS: mapped=${r.mapped} (untested cells below the floor; docs/review-policy.md test gap)\n`); + return EXIT.ok; +} + function outfile(): number { const root = git(['rev-parse', '--show-toplevel']); const exclude = git(['rev-parse', '--git-path', 'info/exclude']); @@ -94,6 +109,7 @@ function main(argv: string[]): number { if (cmd === 'detect') return detect(rest); if (cmd === 'check') return check(rest); if (cmd === 'outfile') return outfile(); + if (cmd === 'findings') return findings(rest); process.stderr.write(`gstack-state-matrix: unknown command ${cmd}\n${HELP}\n`); return EXIT.usage; } diff --git a/capy/skill/gstack/SKILL.md b/capy/skill/gstack/SKILL.md index 1952edf04..9b7e22232 100644 --- a/capy/skill/gstack/SKILL.md +++ b/capy/skill/gstack/SKILL.md @@ -46,7 +46,7 @@ Capy checklist for `/autoplan`: drive `gstack-autoplan`, never a hand adaptation Token caps on /ship: pass a brief's cap as `--token-cap `. /ship reads `cost.tokens` and `cost.credits` from `tools.thread.get({ thread_id: "" })` (its own jam, subagents included; `"this"` names the root thread, so only a root run may pass it) inside the same execute program as the `gstack-ship-budget` command that takes them as `--reading --credits `, never in a call of its own. `ship/DRIVER.md` starts `--mode observe`: each program ends with `note`, and only a note past the cap stops (`docs/ship-budget.md`). -/ship on Capy is about a dozen calls, each one execute program (`ship/DRIVER.md`). A review pass, Step 11 and the Step 14.5 audit each run inside one program, in order: (1) `gstack-review-plan --base ` with the pass's flags (its `DISPATCH:` lines); (2) `gstack-review-log --start ` once per row the step logs (under ship/SKILL.md's admission mode, add `--reservation ` from check's `RESERVATION_ID:` line); (3) `BEFORE`, the counter's tokens and credits; (4) `tools.subagent.create`, one reader each, `machine: "shared"`, its brief copied verbatim from the section that dispatches it; audit readers (Steps 7, 8, 9.2.1, 14.5) cannot block, so they add `model: { model_id: "anthropic/claude-sonnet-5-5", reasoning_mode: "low" }` (`anthropic/claude-haiku-5-5` also fits); (5) a poll of `tools.subagent.get` with `transcript: "none"` every ~20 s for at most 240 s, where a reader is settled only at status `done` or `failed` or with a `summary` (a just-created `idle` reader has not run); (6) `gstack-ship-budget note --step --reading --credits --dispatched-at --dispatched-credits --readers `; (7) return only each reader's final output, or what still runs. The parent never walks the dispatch steps call by call. Waits: never let 5 minutes pass between calls. During tests, pregate, readers and CI, re-issue `wait` with `duration_seconds: 240` and the operation ids: a call after a longer gap bills the whole context fresh (about 40x a cached call), and a ping costs a few percent of that. A small waiting child saves nothing: the parent's cache expires during its wait too. +/ship on Capy is about a dozen calls, each one execute program (`ship/DRIVER.md`). A review pass, Step 11 and the Step 14.5 audit each run inside one program, in order: (1) `gstack-review-plan --base ` with the pass's flags (its `DISPATCH:` lines); (2) `gstack-review-log --start ` once per row the step logs (under ship/SKILL.md's admission mode, add `--reservation ` from check's `RESERVATION_ID:` line); (3) `BEFORE`, the counter's tokens and credits; (4) `tools.subagent.create`, one reader each, `machine: "shared"`, reasoning from its `effort=` (`full` the lane's, `medium` medium, `light` low), its brief copied verbatim from the section that dispatches it; audit readers (Steps 7, 8, 9.2.1, 14.5) cannot block, so they add `model: { model_id: "anthropic/claude-sonnet-5-5", reasoning_mode: "low" }` (`anthropic/claude-haiku-5-5` also fits); (5) a poll of `tools.subagent.get` with `transcript: "none"` every ~20 s for at most 240 s, where a reader is settled only at status `done` or `failed` or with a `summary` (a just-created `idle` reader has not run); (6) `gstack-ship-budget note --step --reading --credits --dispatched-at --dispatched-credits --readers `; (7) return only each reader's final output, or what still runs. The parent never walks the dispatch steps call by call. Waits: never let 5 minutes pass between calls. During tests, pregate, readers and CI, re-issue `wait` with `duration_seconds: 240` and the operation ids: a call after a longer gap bills the whole context fresh (about 40x a cached call), and a ping costs a few percent of that. A small waiting child saves nothing: the parent's cache expires during its wait too. ## Plan, approve, ship diff --git a/docs/model-policy.md b/docs/model-policy.md index e3a6612c3..60db5f375 100644 --- a/docs/model-policy.md +++ b/docs/model-policy.md @@ -206,18 +206,19 @@ set, because a higher effort would spend the time the gate is sized for. ### Review effort tiers -Every reviewer that can block a ship runs at `full` effort on the lane's -model: the core checklist, each specialist, the Red Team, the native -adversarial pass and the fix-check. `gstack-review-plan` names each -dispatch's effort, and `host` for the outside voice (its provider and +The core checklist, the Red Team, the native adversarial pass and the +fix-check run at `full` effort, the lane's reasoning. Specialists run at +`medium`, reasoning medium on the same model (GSTA-167 part 3: the no-loss +controls C and C2 ran them there); light's batched specialist carries the Red +Team prompt and stays `full`. `gstack-review-plan` names each dispatch's effort, and `host` for the outside voice (its provider and `codex_effort` above). Only work that cannot block runs `light`, the same model at a medium or low setting and never a cheaper model family: the coverage, plan-completion and docs audits, decision-trail reviewers and watchers -(`AUDIT_EFFORT:` in the plan's output). A paid replay showed why reviewers stay +(`AUDIT_EFFORT:` in the plan's output). A paid replay showed why the fix-check stays at full: a medium-effort fix-check missed a fail-open in gate code that the same check at full effort caught. A host that cannot set a subagent's effort runs it at its default. `gstack-config set review_effort_tiers off` (default `on`) -runs the audits and watchers at the host default too +runs the specialists, audits and watchers at the host default too ([review-policy.md](review-policy.md#review-depth)). ## Which workflows use the tiers? diff --git a/docs/review-policy.md b/docs/review-policy.md index 032d87fea..182d17bc5 100644 --- a/docs/review-policy.md +++ b/docs/review-policy.md @@ -58,7 +58,7 @@ whatever class it resembles. | **bounded repeat work** | A re-dispatch on every call, a redundant screen after main moves, an orphaned scratch branch, a refusal that could come one step earlier in the sequence, a settle that could wait one more check. | The operation completes, nothing is lost, and the cost is one more bounded run; uncapped spend is the sideways sweep's CRITICAL, not this class. | | **conservative refusal** | Stale state scoped wider than its cause: a RED verdict that keeps refusing a head after main moved, a pending result that keeps refusing after it settled. | A refusal costs a rerun and never passes something wrong; a wrong pass would be the floor. | | **artifact presentation** | A string from an artifact printed verbatim into a parsed line (whitespace, control characters); a classifier that reads a non-zero exit code beside a non-RED verdict as RED. | Display and classification of the repository's own output; it forges no parsed field. | -| **test gap** | A state-matrix cell without a test, a coverage percentage, a weak or flaky test, a fixture that shares the coordinator's environment. | Category `testing`, below the floor by definition above; a gap is work for the test audit, not a defect in the diff. | +| **test gap** | A state-matrix cell without a test, a coverage percentage, a weak or flaky test, a fixture that shares the coordinator's environment. | Category `testing`, below the floor by definition above; a gap is work for the test audit, not a defect in the diff. Precedence for state machines: the specialist still reports each untested cell CRITICAL so `gstack-state-matrix check` proves the matrix complete, and `gstack-state-matrix findings` maps it to INFORMATIONAL per matrix cell before the merge; a wrong row or collapsed kinds stays CRITICAL, and a cell with a second finding keeps both. | A reviewer that wants a class promoted argues it against the floor with a reaching input, in the finding, and the owner rules; the table records the @@ -168,12 +168,38 @@ or test path, CI workflows, credential, state-root and the paths below). commit's `.gstack/generated.json` registers. Docs, tests and release metadata are never runtime; a `.tmpl` edit plus its regenerated `SKILL.md` counts the template once. +**Data files no code names are docs.** A changed or deleted JSON, JSONL, NDJSON, CSV, +TSV or raster image (PNG, JPEG, GIF, WebP, BMP, ICO, AVIF) counts as docs when no +non-docs file names its basename at the head, at the merge base or, for live /ship, in +the working tree with untracked files; a data file named only by another data file +follows that file, four files deep. Precedence: agent prose (`.gstack/**`), a test or +fixture path, a gate path, a config file (`.github/**`, anything under a `config/` +directory, dotfiles, `*.config.*`, `package.json`, lockfiles, `tsconfig*.json`, +`manifest.json` and the other tool manifests `lib/review-depth.ts` lists) and a named +consumer each keep the path code, and YAML, TOML and SVG are always code. More than +2,000 data paths keep every one code; a `git grep` failure is `consumer_scan_failed` +and fails the depth closed. Only `classifyDiff` applies the rule (`lib/review-collect.ts` +finds the consumers, `gstack-ship-preflight`'s `RUNTIME_LINES` reads the same +function); every path-only caller (the receipt's gate-scan decision, the Step 16 +post-review route, /autoplan's docs-only scope) keeps data files code. A numeric claim +in a data file still schedules the outside voice. + | Depth | When | Pass 1 dispatches | |---|---|---| | **light**, runtime 0 | docs and tests only, or only registered outputs | the core checklist only; the outside voice when the diff publishes numbers | -| **light**, runtime 1–50 | `SMALL_DIFF_LINES` or fewer runtime lines | core, one **batched** specialist (testing, maintainability and security checklists, the specialists scope selects and the Red Team prompt in one context), the native adversarial pass (kept: backtest inconclusive, GSTA-147 D5) and the outside voice on numbers | -| **standard** | more than 50 runtime lines | the code set with every threshold (specialists at 50, simplification at 100, Red Team at 200) read on runtime lines; specialists leave docs and registered outputs out of their diff, and every specialist but Testing leaves tests out too; adversarial and outside voice read everything | -| **full** | gate class, or any classifier error | the gate set, unchanged: every reviewer on the whole diff, the Red Team always, no hit-rate gating | +| **light**, runtime 1–50 | `SMALL_DIFF_LINES` or fewer runtime lines | core, one **batched** specialist (the batched checklists below plus the Red Team prompt in one context), the native adversarial pass (kept: backtest inconclusive, GSTA-147 D5) and the outside voice on numbers | +| **standard** | more than 50 runtime lines | one batched specialist at `medium` and the Red Team at 200 runtime lines (or on a CRITICAL); the batched reader leaves docs and registered outputs out of its diff; adversarial and outside voice read everything | +| **full** | gate class, or any classifier error | one batched specialist on the whole diff, the Red Team always, no hit-rate gating | + +**Batched checklists (the lean ensemble, GSTA-167 part 2).** One specialist +subagent reads Testing and Security on every code or gate diff, plus +State-machines, Data-migration, API-contract and Design when scope asks (a +forced checklist always joins). Maintainability, Performance and Simplification +are read only when forced (`SKIP:` names them). The Red Team reads the batched +reader's merged findings. A free re-score of saved C and D findings lost 0 of +the 29 blockers (handoffs/GSTA-167/lean-ensemble-rescore.md); the live round 5 +replay passed (L∘G1 found 24 of 29 blockers against the control's 21, +handoffs/GSTA-167/noloss5-results.md). A fixed blocking finding, a CRITICAL or an open blocker raises a light diff to standard for the next step (`step=standard`): its specialists read at least 50 lines @@ -226,15 +252,20 @@ passes `--adversarial-ran` so a repeat lists them no more. Later full passes rer specialists on the range only. Step 11.5 binds a planned skip through the Step 9 record's `CURRENT` freshness instead of a native record. -**Effort.** Every reviewer that can block a ship (the core checklist, -specialists, the Red Team, the native adversarial pass, fix-checks) runs at -`full` effort. Work that cannot block (the Step 7 coverage audit, the Step 8 +**Effort.** The core checklist, the Red Team, the native adversarial pass and +fix-checks run at `full` effort (the lane's reasoning); specialists run at +`medium` (GSTA-167 part 3: the no-loss controls ran them there), except light's +batched specialist, which carries the Red Team prompt and stays `full`. The +outside voice and the native adversarial pass carry the floor sentence and end +each finding with `Severity:` and `Blocks: yes|no`; a finding is CRITICAL when +its label rule says so (native FIXABLE, outside Critical or High) or it says +`Blocks: yes`, and `Blocks: no` never lowers one (`gstack-review-dispatch collect`). Work that cannot block (the Step 7 coverage audit, the Step 8 plan-completion audit, the Step 14.5 docs audit, decision-trail reviewers, watchers) runs at `light`: the same model at a lower reasoning effort, never a cheaper model family. The outside voice keeps its own provider and effort (`codex_effort`). A host that cannot set a subagent's effort runs it at its default. `gstack-config set review_effort_tiers off` (or `--no-tiers`) runs -the audits and watchers at the host default too +the specialists, audits and watchers at the host default too ([model-policy.md](model-policy.md#reasoning-effort)). ### Behavior changes @@ -244,10 +275,15 @@ the audits and watchers at the host default too Team and the native adversarial pass on the range instead of the specialist army. - A tests-only diff (runtime 0) no longer dispatches the Testing specialist: the core checklist and the gate scan review it. +- Every depth dispatches one batched specialist instead of one reader per + checklist; Maintainability, Performance and Simplification run only when forced. - Light no longer runs a separate Red Team: the batched specialist carries its prompt. At runtime 0 light runs no adversarial pass either. - Standard reads thresholds on runtime lines, not the whole diff, so a test-heavy diff with few runtime lines dispatches fewer specialists. +- A JSON, CSV, TSV or raster data file no code names is docs, not runtime (GSTA-167 + 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. ## What the receipt records @@ -375,10 +411,15 @@ ones, and the arm lost blockers. `bin/gstack-review-dispatch` closes both ends: prompt and runs `sent` on what it passed: a hash that differs from the rendered one is `BRIEF_MISMATCH`, and that reader's review does not count. 3. `collect` hashes each reader's raw output and appends its findings to - `merged.jsonl` with the label exactly as written (a JSON line's `severity`, or a - prose line's Critical, High, Medium, Low, P0-P3, FIXABLE or INVESTIGATE wherever it - stands, headings and table rows included; a top-level list item under a label-only - heading takes that label). A phantom finding costs a fold; a lost one costs the gate. + `merged.jsonl` with the label as written (a JSON line's `severity`, or the most + severe of a prose line's Critical, High, Medium, Low, P0-P3, FIXABLE or INVESTIGATE + wherever they stand, headings and table rows included; a top-level list item under a + label-only heading takes the stronger of the two). A `Severity:` or `Blocks:` line + continues the finding above it until a list item, heading, bold paragraph opener or a + second line for the same field starts another: `Severity` raises its label, + `Blocks: yes` makes it CRITICAL and `Blocks: no` never lowers it; an inline + `Blocks: yes` is CRITICAL too, and with no finding above it is one. The native + adversarial voice's FIXABLE collects as CRITICAL (normalizer v2, GSTA-167). A phantom finding costs a fold; a lost one costs the gate. 4. Lead judgment writes `judged.jsonl`; `judge` refuses a set that drops a merged finding, lowers its severity, folds it into a less severe duplicate, or adds or promotes one without a reason (`JUDGMENT_REJECTED`). A merged file missing any id diff --git a/docs/skills.md b/docs/skills.md index 405d690e8..8e760a90c 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -733,7 +733,7 @@ Every `/ship` run builds a code path map from your diff, searches for correspond ### Review gate -`/ship` displays historical review readiness in the [Review Readiness Dashboard](#review-readiness-dashboard) during preflight. A missing Eng Review is reported without an extra question; it does not replace or waive the current pre-landing review. Step 9 still runs the checklist, applicable specialists and bounded exploratory QA, with its existing approval and completion gates. +`/ship` displays historical review readiness in the [Review Readiness Dashboard](#review-readiness-dashboard) during preflight. A missing Eng Review is reported without an extra question; it does not replace or waive the current pre-landing review. Step 9 still runs the checklist, one batched specialist (Testing and Security, plus the checklists scope selects) and bounded exploratory QA, with its existing approval and completion gates. A lot of branches die when the interesting work is done and only the boring release work is left. Humans procrastinate that part. AI should not. diff --git a/lib/review-brief.ts b/lib/review-brief.ts index b3e3063c2..65e207403 100644 --- a/lib/review-brief.ts +++ b/lib/review-brief.ts @@ -117,7 +117,7 @@ function renderRole(role: BriefRole, slots: Slots, t: SectionTexts): string { return fill(t.native, [['DIFF_BASE=$(git merge-base origin/ HEAD)', `DIFF_BASE=${diffBase}`]], role); case 'outside-voice': { const body = fill(t.outside, [['DIFF_BASE=$(git merge-base origin/ HEAD)', `DIFF_BASE=${diffBase}`]], role); - return [body, '', 'Give each finding a severity: Critical, High, Medium or Low.', '', `## Branch diff (git diff ${diffBase})`, FENCE + 'diff', (slots.diff ?? '').trim(), FENCE].join('\n'); + return [body, '', `## Branch diff (git diff ${diffBase})`, FENCE + 'diff', (slots.diff ?? '').trim(), FENCE].join('\n'); } 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 3846df0f3..1cfc02384 100644 --- a/lib/review-collect.ts +++ b/lib/review-collect.ts @@ -12,7 +12,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { GENERATED_PATH, parseRegistry } from './regen'; import { RESULT_CODES } from './result-codes'; -import type { ClassInput, DepthError, PathLines, RegistryInfo } from './review-depth'; +import { classifyPath, dataCandidate, type ClassInput, type DepthError, type PathLines, type RegistryInfo } from './review-depth'; export interface Collected extends ClassInput { perPath: PathLines[]; @@ -25,11 +25,12 @@ export interface Collected extends ClassInput { diffLines: number; mergeBase: string | null; untracked: string[]; + consumed: string[] | 'all'; } -function git(root: string, args: string[]): { ok: boolean; out: string; err: string } { +function git(root: string, args: string[]): { ok: boolean; status: number | null; out: string; err: string } { const r = spawnSync('git', ['-c', 'core.quotePath=false', ...args], { cwd: root, encoding: 'utf8', timeout: 60_000, maxBuffer: 256 * 1024 * 1024 }); - return { ok: r.status === 0, out: r.stdout ?? '', err: (r.stderr ?? '').trim() }; + return { ok: r.status === 0, status: r.status, out: r.stdout ?? '', err: (r.stderr ?? '').trim() }; } const scratch = (p: string) => p.startsWith('.gstack/tmp/'); const oneLine = (s: string) => s.replace(/[\0-\x1f]+/g, ' ').trim().slice(0, 200); @@ -88,7 +89,60 @@ function readDiff(root: string, range: string[], errors: DepthError[]): Pick, extra: Pick): Collected { +/** More data candidates than this keep every data file code: the consumer scan is not run. */ +export const CONSUMER_SCAN_CAP = 2000; +/** How many data files deep a chain of data naming data is followed (a manifest naming a data file) before the rest count as consumed. */ +export const CONSUMER_SCAN_DEPTH = 4; + +/** + * The data candidates (lib/review-depth.ts dataCandidate) among the changed and deleted paths that a non-docs file names + * by basename, at each rev and, with `live`, in the working tree with untracked files. The merge base is one of the revs, + * so a consumer the change removes still counts. A data file named only by another data file is consumed when that one + * is, followed CONSUMER_SCAN_DEPTH files deep; anything still open after that counts as consumed. A git grep failure is + * a collector error, which fails the depth closed. + */ +export function scanConsumers(root: string, paths: readonly string[], revs: readonly string[], live: boolean, classRoot: string | readonly string[], errors: DepthError[]): string[] | 'all' { + const candidates = [...new Set(paths)].filter(p => dataCandidate(p, classRoot)).sort(); + if (candidates.length > CONSUMER_SCAN_CAP) return 'all'; + const base = (p: string) => p.slice(p.lastIndexOf('/') + 1); + const named = new Map>(); + const direct = new Set(); + const probed = new Set(); + let probe = candidates; + for (let depth = 0; probe.length && depth < CONSUMER_SCAN_DEPTH; depth++) { + const patterns = [...new Set(probe.map(base))].flatMap(b => ['-e', b]); + const runs = [...revs.map(rev => ({ at: rev, args: [rev, '--'] })), ...(live ? [{ at: '', args: ['--untracked', '--'] }] : [])]; + const next = new Set(); + for (const run of runs) { + const r = git(root, ['grep', '-I', '-F', '-o', '--null', '--no-line-number', '--no-column', '--no-color', ...patterns, ...run.args]); + if (r.status !== 0 && r.status !== 1) { errors.push({ code: 'consumer_scan_failed', detail: oneLine(r.err || `git grep exited ${r.status}`), input: `git grep -F ${run.at || '--untracked'}`, repair: 'run the git grep by hand and fix what git reports' }); return 'all'; } + for (const line of r.out.split('\n')) { + const parts = line.split('\0'); + const where = parts[0]; + const match = parts.length > 1 ? parts[parts.length - 1] : undefined; + if (!where || !match) continue; + const file = run.at ? where.slice(run.at.length + 1) : where; + if (scratch(file) || classifyPath(file, classRoot) === 'docs') continue; + for (const p of probe) { + if (file === p || !match.includes(base(p))) continue; + if (!dataCandidate(file, classRoot)) { direct.add(p); continue; } + (named.get(file) ?? named.set(file, new Set()).get(file)!).add(p); + if (!probed.has(file) && !probe.includes(file)) next.add(file); + } + } + } + for (const p of probe) probed.add(p); + probe = [...next]; + } + const consumed = new Set([...direct, ...probe]); + for (let grew = true; grew;) { + grew = false; + for (const [file, targets] of named) if (consumed.has(file)) for (const p of targets) if (!consumed.has(p)) { consumed.add(p); grew = true; } + } + return candidates.filter(p => consumed.has(p)); +} + +function result(diff: ReturnType, extra: Pick): Collected { const paths = diff.perPath.map(e => e.path).sort(); return { ...diff, ...extra, paths, gateTagged: null, diffLines: diff.perPath.reduce((n, e) => n + e.lines, 0) }; } @@ -100,13 +154,14 @@ 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: [] }); + return result({ perPath: [], deleted: [], stampOnly: [], added: {} }, { root: [], registry, errors, mergeBase: null, untracked: [], consumed: 'all' }); } const mergeBase = mb.out.trim(); const diff = readDiff(root, [mergeBase, headSha], errors); const tree = git(root, ['ls-tree', '-r', '--name-only', '-z', headSha]); const skillDirs = [...new Set(tree.out.split('\0').flatMap(p => /^([^/]+)\/SKILL\.md(\.tmpl)?$/.exec(p)?.[1] ?? []))].sort(); - return result(diff, { root: skillDirs, registry, errors, mergeBase, untracked: [] }); + const consumed = scanConsumers(root, [...diff.perPath.map(e => e.path), ...diff.deleted], [headSha, mergeBase], false, skillDirs, errors); + return result(diff, { root: skillDirs, registry, errors, mergeBase, untracked: [], consumed }); } /** The working tree against the merge base with origin/ (or ), untracked files counted per path; the registry is the base ref's commit. */ @@ -117,7 +172,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: [] }); + return result({ perPath: [], deleted: [], stampOnly: [], added: {} }, { root, registry, errors, mergeBase: null, untracked: [], consumed: 'all' }); } const mergeBase = mb.out.trim(); const diff = readDiff(root, [mergeBase], errors); @@ -133,5 +188,6 @@ export function collectLive(root: string, base: string): Collected { diff.perPath.push({ path: p, lines, binary }); if (!binary) diff.added[p] = text.split('\n'); } - return result(diff, { root, registry, errors, mergeBase, untracked }); + const consumed = scanConsumers(root, [...diff.perPath.map(e => e.path), ...diff.deleted], [mergeBase], true, root, errors); + return result(diff, { root, registry, errors, mergeBase, untracked, consumed }); } diff --git a/lib/review-depth.ts b/lib/review-depth.ts index bce1fffef..a7af650d3 100644 --- a/lib/review-depth.ts +++ b/lib/review-depth.ts @@ -17,7 +17,7 @@ import { matchesAny } from './ship-policy'; export type DiffClass = 'docs' | 'tests' | 'code' | 'gate'; export type PathClass = 'docs' | 'tests' | 'code'; export type Depth = 'light' | 'standard' | 'full'; -export type Effort = 'light' | 'full' | 'host'; +export type Effort = 'light' | 'medium' | 'full' | 'host'; export type Role = 'specialist' | 'red-team' | 'adversarial-native' | 'outside-voice' | 'fix-check'; export type Step = 'full' | 'scoped' | 'fix-check' | 'done' | 'stop'; @@ -29,8 +29,16 @@ export const RED_TEAM_LINES = 200; export const LARGE_DIFF_LINES = 100; /** The largest fix range, in runtime lines, a pass 2 after a blocking fix reviews as a scoped pass (GSTA-152 D6); a starting value. */ export const SCOPED_RANGE_LINES = 150; -/** The checklists light's batched specialist always reads, whatever the scope. */ -export const LIGHT_FIXED: readonly Specialist[] = ['testing', 'maintainability', 'security']; +/** The checklists the batched specialist always reads on code and gate class, whatever the depth or scope (GSTA-167 part 2). */ +export const LIGHT_FIXED: readonly Specialist[] = ['testing', 'security']; +/** The checklists the batched specialist adds when scope asks; maintainability, performance and simplification are read only when forced. */ +export const BATCH_SCOPED: readonly Specialist[] = ['data-migration', 'api-contract', 'design', 'state-machines']; +/** + * Each blocking role's effort (GSTA-167 part 3, CREW 30c): specialists at medium, which is how the no-loss controls ran + * 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; /** 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 }; @@ -48,10 +56,18 @@ const GATE_PATH = [ /^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$/, ]; +/** 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. */ +export const DATA_PATH = /\.(json|jsonl|ndjson|csv|tsv|png|jpe?g|gif|webp|bmp|ico|avif)$/i; +/** Data-shaped files tools read by convention, with no code naming them: always code. */ +const DATA_CONFIG = [ + /(^|\/)\.github\//, /(^|\/)config\//, /(^|\/)\.[^/]+$/, /(^|\/)[^/]+\.config\.[^/]+$/, + /(^|\/)(package|package-lock|npm-shrinkwrap|composer|deno|jsr|tsconfig[^/]*|jsconfig[^/]*|manifest|app|vercel|netlify|firebase|renovate|turbo|nx|lerna|biome|angular|nest-cli|lint-staged|components|pyrightconfig|cdk|serverless|appsettings[^/]*)\.json$/i, +]; + /** A hash of the constants and patterns that decide a depth, not of this file's bytes: a comment edit keeps it, a threshold edit changes it. */ export const DEPTH_RULES = createHash('sha256').update(JSON.stringify({ - SMALL_DIFF_LINES, RED_TEAM_LINES, LARGE_DIFF_LINES, FULL_PASS_CAP, LIGHT_FIXED, SCOPED_RANGE_LINES, - patterns: Object.entries({ GATE_PATH, AGENT_PROSE, TEST_PATH, RELEASE_METADATA, DOC_PATH: [DOC_PATH] }).map(([k, v]) => [k, v.map(r => `/${r.source}/${r.flags}`)]), + SMALL_DIFF_LINES, RED_TEAM_LINES, LARGE_DIFF_LINES, FULL_PASS_CAP, LIGHT_FIXED, BATCH_SCOPED, SCOPED_RANGE_LINES, ROLE_EFFORT, + patterns: Object.entries({ GATE_PATH, AGENT_PROSE, TEST_PATH, RELEASE_METADATA, DOC_PATH: [DOC_PATH], DATA_PATH: [DATA_PATH], DATA_CONFIG }).map(([k, v]) => [k, v.map(r => `/${r.source}/${r.flags}`)]), })).digest('hex').slice(0, 16); /** @@ -70,7 +86,21 @@ export function classifyPath(p: string, root?: string | readonly string[]): Path return root.includes(top) ? 'code' : 'docs'; } -export type DepthErrorCode = 'scope_error' | 'gate_scan_unavailable' | 'registry_unavailable' | 'registry_invalid' | 'numstat_failed' | 'binary_code_path'; +/** + * A code-class path the data rule may move to docs: a data extension, and not agent prose, a gate path or a config file. + * Only classifyDiff applies the rule, and only to a candidate its collector found no consumer for (ClassInput.consumed); + * classifyPath and every path-only caller keep data files code. + */ +export function dataCandidate(p: string, root?: string | readonly string[]): boolean { + return DATA_PATH.test(p) && classifyPath(p, root) === 'code' && ![...AGENT_PROSE, ...GATE_PATH, ...DATA_CONFIG].some(r => r.test(p)); +} + +/** Whether a range needs the gate scan: any path that is not docs by the path-only rule. The data rule never skips it. */ +export function gateScanNeeded(paths: readonly string[], root?: string | readonly string[]): boolean { + return paths.some(p => classifyPath(p, root) !== 'docs'); +} + +export type DepthErrorCode = 'scope_error' | 'gate_scan_unavailable' | 'registry_unavailable' | 'registry_invalid' | 'numstat_failed' | 'binary_code_path' | 'consumer_scan_failed'; /** Why a depth failed closed to full: the input it could not read, a sanitized cause and the repair; `result` is the result code behind it when one exists. */ export interface DepthError { code: DepthErrorCode; detail: string; input: string; repair: string; result?: ResultCodeName } export interface PathLines { path: string; lines: number; binary: boolean } @@ -94,12 +124,18 @@ export interface ClassInput { registry?: RegistryInfo; /** Collector failures (a diff git could not list): each fails the depth closed. */ errors?: readonly DepthError[]; + /** + * The data candidates (dataCandidate) a non-docs file names at the head, the merge base or in the working tree + * (lib/review-collect.ts). A candidate not listed counts as docs; 'all' or no scan keeps every data file code. + */ + consumed?: readonly string[] | 'all'; } export interface ClassResult { class: DiffClass; reasons: string[]; errors: DepthError[]; runtimeLines: number | null } const sanitize = (s: string) => s.replace(/[\0-\x1f]+/g, ' ').slice(0, 200); const isOutput = (i: ClassInput, p: string) => !!i.registry?.outputs.length && matchesAny(i.registry.outputs, p); -const pathClass = (i: ClassInput, p: string): PathClass => ((i.stampOnly ?? []).includes(p) ? 'docs' : classifyPath(p, i.root)); +const unconsumedData = (i: ClassInput, p: string) => Array.isArray(i.consumed) && !i.consumed.includes(p) && dataCandidate(p, i.root); +const pathClass = (i: ClassInput, p: string): PathClass => ((i.stampOnly ?? []).includes(p) || unconsumedData(i, p) ? 'docs' : classifyPath(p, i.root)); /** The diff's class, raised to gate by any gate signal; `errors` lists every input that fails the depth closed; `runtimeLines` counts code-class lines outside the base registry's outputs. */ export function classifyDiff(i: ClassInput): ClassResult { @@ -109,6 +145,8 @@ export function classifyDiff(i: ClassInput): ClassResult { const raise = (c: DiffClass, why: string) => { if (CLASS_RANK[c] > CLASS_RANK[cls]) cls = c; reasons.push(why); }; if (!i.paths.length) raise('docs', 'no changed paths'); for (const p of i.paths) { const c = pathClass(i, p); if (CLASS_RANK[c] > CLASS_RANK[cls]) raise(c, `${c}: ${p}`); } + const data = i.paths.filter(p => unconsumedData(i, p)); + if (data.length) reasons.push(`docs: ${data.length} data path(s) no code names`); const s = i.scope ?? {}; if (s.SCOPE_ERROR) { raise('code', `scope error: ${s.SCOPE_ERROR}`); @@ -251,8 +289,10 @@ export interface PlanInput extends ClassInput { reviewedNumbers?: string; /** --depth-floor: raises the depth, never lowers it. */ floor?: Depth; + /** false under --no-tiers or review_effort_tiers off: specialists run at the host's default effort. */ + tiers?: boolean; } -/** One reviewer. `exclude` holds git pathspec argv items the reviewer's diff leaves out; `checklists` names what light's batched specialist reads. */ +/** 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 } export interface ReviewPlan extends DepthResult { /** The depth this step runs at: light raised to standard by a fixed blocker, a CRITICAL or an open blocker. */ @@ -294,6 +334,29 @@ export function specialistsFor(cls: DiffClass, i: PlanInput, skipped: string[] = }); } +/** + * The checklists the one batched specialist reads (the lean ensemble, GSTA-167 part 2): the class sets the base (none for docs, + * testing for tests, LIGHT_FIXED for code and gate), BATCH_SCOPED joins when scope asks and hit-rate gating allows (gate class + * ignores gating), and a forced checklist always joins. + */ +export function batchChecklists(cls: DiffClass, i: PlanInput, skipped: string[] = []): Specialist[] { + const forced = forcedBy(i); + const codeLike = cls === 'code' || cls === 'gate'; + const base: readonly Specialist[] = codeLike ? LIGHT_FIXED : cls === 'tests' ? ['testing'] : []; + if (cls === 'docs') skipped.push('specialists (docs class)'); + if (cls === 'tests') skipped.push('specialists except testing (tests class)'); + const extra = SPECIALISTS.filter(n => { + if (base.includes(n)) return false; + if (forced(n)) return true; + if (!codeLike || !BATCH_SCOPED.includes(n) || !scopeWants[n]!(i.scope ?? {})) return false; + if (cls !== 'gate' && (i.gated ?? []).includes(n)) { skipped.push(`${n} (gated by hit rate)`); return false; } + return true; + }); + const dropped = SPECIALISTS.filter(n => codeLike && !BATCH_SCOPED.includes(n) && !base.includes(n) && !extra.includes(n)); + if (dropped.length) skipped.push(`${dropped.join(', ')} (lean ensemble: read only when forced)`); + return [...base, ...extra]; +} + /** A path as one git exclude pathspec; a glob character or a leading colon makes it literal. */ const excludeSpec = (p: string) => `:(exclude${/[*?[\]\\]/.test(p) || p.startsWith(':') ? ',literal' : ''})${p}`; const shellQuote = (s: string) => `'${s.replace(/'/g, "'\\''")}'`; @@ -317,18 +380,17 @@ export function planReview(i: PlanInput): ReviewPlan { const dispatches: Dispatch[] = []; const add = (role: Role, name: string, effort: Effort, exclude: string[] = [], extra: Partial = {}) => dispatches.push({ role, name, effort, exclude, ...extra }); const docsAndOutputs = i.paths.filter(p => pathClass(i, p) === 'docs' || isOutput(i, p)).map(excludeSpec); - const tests = i.paths.filter(p => pathClass(i, p) === 'tests' && !isOutput(i, p)).map(excludeSpec); const numbersChanged = i.numbers.publishes && i.reviewedNumbers !== undefined && i.reviewedNumbers !== i.numbers.fingerprint; const firstVoices = h.fullPasses === 0 || !i.adversarialRan; + const specialist: Effort = i.tiers === false ? 'host' : ROLE_EFFORT.specialist; if (next.step === 'full' && stepDepth === 'light') { const forced = forcedBy(i); if (runtimeLines > 0) { - const scoped = SPECIALISTS.filter(n => !LIGHT_FIXED.includes(n) && (forced(n) || (scopeWants[n]?.(i.scope ?? {}) && !(i.gated ?? []).includes(n)))); - add('specialist', 'batched', 'full', docsAndOutputs, { checklists: [...LIGHT_FIXED, ...scoped, 'red-team'] }); + add('specialist', 'batched', ROLE_EFFORT['red-team'], docsAndOutputs, { checklists: [...batchChecklists('code', i, skipped), 'red-team'] }); skipped.push('red-team (light depth: the batched specialist carries the Red Team prompt)'); if (firstVoices) add('adversarial-native', 'adversarial', 'full', [], { reason: LIGHT_ADVERSARIAL_REASON }); } else { - for (const n of SPECIALISTS.filter(forced)) add('specialist', n, 'full'); + for (const n of SPECIALISTS.filter(forced)) add('specialist', n, specialist); skipped.push('specialists, red-team and adversarial-native (light depth, runtime 0)'); } if (h.fullPasses === 0 && i.numbers.publishes) add('outside-voice', 'outside-voice', 'host'); @@ -339,7 +401,8 @@ export function planReview(i: PlanInput): ReviewPlan { const dcls: DiffClass = full ? 'gate' : cls; const raised = !full && runtimeLines <= SMALL_DIFF_LINES; const lines = full ? i.diffLines : Math.max(runtimeLines, raised ? SMALL_DIFF_LINES : 0); - for (const n of specialistsFor(dcls, { ...i, diffLines: lines }, skipped)) add('specialist', n, 'full', full ? [] : n === 'testing' ? docsAndOutputs : [...docsAndOutputs, ...tests]); + const checklists = batchChecklists(dcls, i, skipped); + if (checklists.length) add('specialist', 'batched', specialist, full ? [] : docsAndOutputs, { checklists }); const redTeam = dcls === 'gate' || (dcls === 'code' && (lines > RED_TEAM_LINES || !!i.criticalFound || raised)); if (redTeam) add('red-team', 'red-team', 'full'); else skipped.push(`red-team (${dcls} class${dcls === 'code' ? `, ${lines} runtime lines, no critical` : ''})`); @@ -356,7 +419,7 @@ export function planReview(i: PlanInput): ReviewPlan { } else if (next.step === 'scoped') { add('fix-check', 'fix-check', 'full'); const forced = SPECIALISTS.filter(forcedBy(i)); - for (const n of forced) add('specialist', n, 'full'); + for (const n of forced) add('specialist', n, specialist); skipped.push(forced.length ? 'specialists (scoped pass; forced ones dispatched)' : 'specialists (scoped pass)'); add('red-team', 'red-team', 'full'); const stepElevenPending = !i.adversarialRan && cls === 'code'; diff --git a/lib/review-ledger.ts b/lib/review-ledger.ts index defc5208a..e8a5633a6 100644 --- a/lib/review-ledger.ts +++ b/lib/review-ledger.ts @@ -8,7 +8,9 @@ * judgment each lead-judgment change: an added or promoted finding, with its reason * judge the verdict on the parent's judged set (ok, or the dropped and downgraded ids) * - * Findings reach merged.jsonl only through collect, with each voice's label exactly as written. + * Findings reach merged.jsonl only through collect, with each voice's label as written (a prose line's most severe one, + * raised by the Severity line below it), except that a prose voice's `Blocks: yes` and the native voice's FIXABLE collect + * as CRITICAL (GSTA-167 normalizer v2). * The parent's judged set may add or promote a finding and may fold a duplicate into one at least * as severe; it never drops or downgrades one. verify replays every check from the ledger alone. */ @@ -53,31 +55,81 @@ const LABEL_HEADING = new RegExp(`^(?:#{1,6}\\s*|\\*\\*)(${LABELS})(?:\\s+(?:fin const HEADING = /^(?:#{1,6}\s|\*\*[^*]+\*\*:?$)/; const LIST_ITEM = /^(?:[-*+]|\d+[.)])\s+\S/; const stronger = (a: string, b: string) => ((rank(a) ?? 0) >= (rank(b) ?? 0) ? a : b); +/** A prose voice's `Blocks: yes` (GSTA-167 normalizer v2): the finding is CRITICAL whatever its label; `Blocks: no` never lowers one. */ +const BLOCKS_YES = /(? { - const out: Array<{ label: string; raw: string }> = []; + type Finding = { label: string; raw: string; fields: Set }; + const out: Finding[] = []; let section: string | null = null; + let current: Finding | null = null; + let head = ''; + let gap = false; + const role = reader.split('/')[0]; + const named = (l: string) => (role === 'adversarial-native' && l.toUpperCase() === 'FIXABLE' ? 'CRITICAL' : l); + const strongest = (t: string) => [...t.matchAll(ALL_LABELS)].map(m => named(m[1]!)).reduce((a, b) => (a === undefined ? b : stronger(a, b)), undefined); + const push = (label: string, raw: string, fields: string[] = []): Finding => { const f = { label, raw, fields: new Set(fields) }; out.push(f); return f; }; for (const line of output.replace(/\r\n/g, '\n').split('\n')) { const t = line.trim(); - if (!t || /^NO FINDINGS\.?$/.test(t) || /^Recommendation:/i.test(t)) continue; + if (!t) { gap = true; continue; } + const afterGap = gap; + gap = false; + if (/^NO FINDINGS\.?$/.test(t) || /^Recommendation:/i.test(t)) { current = null; continue; } + const indented = line !== line.trimStart(); + const label = strongest(t); + const blocks = BLOCKS_YES.test(t); + const listItem = !indented && LIST_ITEM.test(t); + const locus = LOCUS.test(t); + const meta = listItem && locus ? null : META.exec(t); + if (meta) { + const field = meta[1]!.toLowerCase(); + if (current && !current.fields.has(field)) { + current.fields.add(field); + if (blocks) current.label = 'CRITICAL'; + else if (label) current.label = stronger(current.label, label); + current.raw += ` | ${t}`; + } else if (blocks || label) current = push(blocks ? 'CRITICAL' : label!, head ? `${head} | ${t}` : t, [field]); + else current = null; + continue; + } const heading = LABEL_HEADING.exec(t); - if (heading) { section = heading[1]!; continue; } - if (line === line.trimStart() && HEADING.test(t)) section = null; + if (heading) { section = named(heading[1]!); current = null; head = ''; continue; } + if (!indented && HEADING.test(t)) { section = null; current = null; } if (t.startsWith('{')) { try { const o = JSON.parse(t) as Record; - if (typeof o.severity === 'string') { out.push({ label: o.severity, raw: t }); continue; } + if (typeof o.severity === 'string') { current = push(o.severity, t); continue; } } catch { /* prose that starts with a brace */ } } - const inline = ANY_LABEL.exec(t)?.[1]; - const inherited = section && line === line.trimStart() && LIST_ITEM.test(t) ? section : undefined; - const label = inline && inherited ? stronger(inherited, inline) : inline ?? inherited; - if (label) out.push({ label, raw: t }); + const fresh = listItem || (!indented && (t.startsWith('**') || afterGap)) || (!!current?.fields.size && locus); + if (fresh) { current = null; head = t; } + const inherited = section && listItem ? section : undefined; + const found = label && inherited ? stronger(inherited, label) : label ?? inherited; + if (found) { current = push(blocks ? 'CRITICAL' : found, t); if (!indented) head = t; continue; } + if (blocks && !t.startsWith('{')) { + if (current) { current.label = 'CRITICAL'; current.raw += ` | ${t}`; continue; } + current = push('CRITICAL', head && head !== t ? `${head} | ${t}` : t); + continue; + } + if (!fresh && !current && (!LIST_ITEM.test(head) || locus)) head = t; } - return out; + return out.map(({ label, raw }) => ({ label, raw })); } /** collect: hash the raw output, append its findings to merged.jsonl and record both. */ diff --git a/lib/review-scope.ts b/lib/review-scope.ts index 97e0e3e19..b514dd557 100644 --- a/lib/review-scope.ts +++ b/lib/review-scope.ts @@ -14,6 +14,7 @@ * Pure functions here; bin/gstack-review-scope runs git and gh. */ import { SPECIALISTS } from './review-depth'; +import { parseSpecialistOutput } from './state-matrix'; export type Locus = 'diff' | 'fix-range' | 'outside'; export type Kind = 'defect' | 'test-gap' | 'advisory'; @@ -130,13 +131,47 @@ function fingerprintLine(fp: unknown): number | undefined { return Number.isInteger(n) && n > 0 ? n : undefined; } +/** The blocking floor in Garry's words (docs/review-policy.md, CREW rule 23); every reviewer prompt carries it verbatim. */ +export const BLOCKING_FLOOR = 'would fail a test, break production, or lose data or secrets'; + /** The categories a reviewer uses for a test finding; `testing` is what the Testing specialist's schema emits. */ export const TEST_GAP_CATEGORIES: readonly string[] = ['testing', 'tests', 'test-gap', 'test-coverage', 'coverage', 'missing-test', 'untested']; -/** A finding's kind: optional advice, a test finding (a gap, a weak or flaky test), or a product defect. */ +/** What mapStateMatrixOutput stamps on an untested state-matrix cell: a test gap, below the floor (docs/review-policy.md). */ +export const UNTESTED_CELL = 'untested-cell'; + +const cellKey = (f: Record): string | null => (f.cell && typeof f.cell === 'object' ? `${String(f.cell.kind ?? '').trim().toLowerCase()}\u0000${String(f.cell.action ?? '').trim().toLowerCase()}` : null); + +/** + * A reviewer output (JSON lines) with each untested state-matrix cell mapped below the floor, per matrix cell and before + * Step 9.3's fingerprint merge: a `state-matrix` finding the checklist's schema writes as "untested cell: …", whose `cell` is a + * cell with `"test": null` in a matrix record of the same output, and the only finding on that cell, becomes INFORMATIONAL with `state_matrix: "untested-cell"` and its + * `raw_severity`. A second finding on the cell (a wrong row, collapsed kinds) keeps both as they are, so a merge can never + * fold a defect into a demoted record; a cell no matrix lists as untested changes nothing. Every other line, matrix records + * included, is returned byte for byte; `gstack-state-matrix check` reads the raw output, never this one. + */ +export function mapStateMatrixOutput(text: string): { text: string; mapped: number } { + const { matrices, findings } = parseSpecialistOutput(text); + const untested = new Set(matrices.flatMap(m => m.cells.filter(c => c.test === null).map(c => cellKey({ cell: c })!))); + const perCell = new Map(); + for (const f of findings) { const k = cellKey(f); if (k) perCell.set(k, (perCell.get(k) ?? 0) + 1); } + let mapped = 0; + const lines = text.split('\n').map(line => { + let f: Record; + try { f = JSON.parse(line.trim()); } catch { return line; } + const k = f && typeof f === 'object' && !f.matrix && f.category === 'state-matrix' && /^untested cell\b/i.test(String(f.summary ?? '')) ? cellKey(f) : null; + if (!k || !untested.has(k) || perCell.get(k) !== 1 || f.state_matrix === UNTESTED_CELL) return line; + mapped += 1; + return JSON.stringify({ ...f, severity: 'INFORMATIONAL', raw_severity: f.severity ?? null, state_matrix: UNTESTED_CELL }); + }); + return { text: lines.join('\n'), mapped }; +} + +/** A finding's kind: optional advice, a test finding (a gap, a weak or flaky test, an untested state-matrix cell still below CRITICAL), or a product defect. */ export function kindOf(finding: ScopeFinding): Kind { if (finding.advisory === true) return 'advisory'; if (TEST_GAP_CATEGORIES.includes(String(finding.category ?? '').toLowerCase())) return 'test-gap'; + if (finding.state_matrix === UNTESTED_CELL && String(finding.severity ?? '').toUpperCase() !== 'CRITICAL') return 'test-gap'; return 'defect'; } @@ -153,6 +188,8 @@ export interface ScopeInput { policy: ReviewScopePolicy; pass: number; diffHunks /** * The scope decision per finding. `all`: fix everything (today's loop). + * An untested state-matrix cell mapped below the floor is a note from pass 1 on + * (docs/review-policy.md, the test-gap ruling) under the other policies. * `frozen` pass 1: fix the diff, defer blocking findings in unchanged code, * note the rest. `frozen` pass 2+: fix blocking findings in the range since * the previous pass or in the diff; defer blocking findings outside; note @@ -170,6 +207,7 @@ export function scopeFindings(findings: readonly ScopeFinding[], input: ScopeInp const placed = typeof finding.path === 'string' || fingerprintPath(finding.fingerprint) !== undefined; let scope: ScopeAction; if (input.policy === 'all') scope = 'fix'; + else if (kind === 'test-gap' && finding.state_matrix === UNTESTED_CELL) scope = 'note'; else if (locus === 'outside') scope = !blocking ? 'note' : input.policy === 'diff-plus-blocking' || !placed ? 'fix' : 'defer'; else if (input.pass <= 1 && !AFTER_PASS_RAISERS.includes(raised)) scope = 'fix'; else scope = blocking ? 'fix' : 'note'; diff --git a/lib/ship-preflight.ts b/lib/ship-preflight.ts index e66d8dbbd..cca22b3ca 100644 --- a/lib/ship-preflight.ts +++ b/lib/ship-preflight.ts @@ -7,14 +7,10 @@ */ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { classifyPath } from './review-depth'; +import { classifyDiff } from './review-depth'; import { audit } from './ship-audit'; -import { matchesAny } from './ship-policy'; import { bin, firstLine, flagReader, gh, git, hostedOrigin, laneLine, lanes, Report, runLanes, shipPolicy, short } from './ship-bin-common'; -/** Data files: code-class paths with no runtime behavior, left out of RUNTIME_LINES. */ -const DATA_PATH = /\.(json|jsonl|ndjson|ya?ml|toml|csv|tsv|svg|png|jpe?g|gif|webp|ico|pdf)$/i; - export const PREFLIGHT_HELP = `gstack-ship-preflight — /ship Steps 1-3 and Step 5's pregate and lanes in one call (ship/SKILL.md) gstack-ship-preflight --base [--lane