v1.91.350.0 feat(review): GSTA-167 variant G reviewer stack: blocking floor in both voices, one batched specialist, data-file classifier (#3660)

* 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/<name>.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>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-31 05:06:03 +00:00
1 parent b3b08d0108
commit 8966f3620c
40 files changed
+955 -165

No files matched your search

+19
View File
@@ -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.
+1 -1
View File
@@ -1 +1 @@
1.91.349.0
1.91.350.0
+1 -1
View File
@@ -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,
+4 -2
View File
@@ -24,7 +24,7 @@ const HELP = `gstack-review-dispatch — verbatim reviewer briefs and loss-free
roles: ${BRIEF_ROLES.join(', ')}
slots: --diff-base <sha> (every role but fix-check) --exclude '<DIFF_CMD tail>' --stack <s> --test-fw <s>
--learnings-file <f> --checklist <path> (default <root>/review/specialists/<name>.md; red-team.md for red-team)
--checklists <a,b> (specialist-batched) --findings-file <f> --specialists <n> (red-team)
--checklists <a,b> (specialist-batched: the plan's checklists= names, or paths) --findings-file <f> --specialists <n> (red-team)
--fix-list <f> --hunks <f> --tests <f> [--callers <f>] (fix-check)
writes <out>/<role>[-<name>].prompt.md, records it in the ledger (default <out>/review-ledger.jsonl) and prints
BRIEF: reader=<role/name> sha256=<h> bytes=<n> file=<path>. 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'),
};
+7 -5
View File
@@ -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 <n> 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 <fp> 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 <sha> 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 <d> 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;
}
+17 -1
View File
@@ -5,6 +5,7 @@
* gstack-state-matrix detect [--base <ref>] [--diff-base <sha>]
* gstack-state-matrix check <specialist-output|-> [--repo <dir>] [--expect <expectation.json>] [--json]
* gstack-state-matrix outfile
* gstack-state-matrix findings <reviewer-output|->
*
* `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 <file|-> [--repo <dir>] [--expect <json>] [--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 <file|-> 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;
}
+1 -1
View File
@@ -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 <n>`. /ship reads `cost.tokens` and `cost.credits` from `tools.thread.get({ thread_id: "<your own jam 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 <n> --credits <c>`, 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 <base>` with the pass's flags (its `DISPATCH:` lines); (2) `gstack-review-log --start <skill>` once per row the step logs (under ship/SKILL.md's admission mode, add `--reservation <id>` 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 <n> --reading <AFTER> --credits <c> --dispatched-at <BEFORE> --dispatched-credits <c> --readers <n>`; (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 <base>` with the pass's flags (its `DISPATCH:` lines); (2) `gstack-review-log --start <skill>` once per row the step logs (under ship/SKILL.md's admission mode, add `--reservation <id>` 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 <n> --reading <AFTER> --credits <c> --dispatched-at <BEFORE> --dispatched-credits <c> --readers <n>`; (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
+7 -6
View File
@@ -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?
+53 -12
View File
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -117,7 +117,7 @@ function renderRole(role: BriefRole, slots: Slots, t: SectionTexts): string {
return fill(t.native, [['DIFF_BASE=$(git merge-base origin/<base> HEAD)', `DIFF_BASE=${diffBase}`]], role);
case 'outside-voice': {
const body = fill(t.outside, [['DIFF_BASE=$(git merge-base origin/<base> 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 ?? '' });
+64 -8
View File
@@ -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<Col
return { perPath, deleted, stampOnly, added };
}
function result(diff: ReturnType<typeof readDiff>, extra: Pick<Collected, 'root' | 'registry' | 'errors' | 'mergeBase' | 'untracked'>): 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<string, Set<string>>();
const direct = new Set<string>();
const probed = new Set<string>();
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<string>();
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 <data basenames> ${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<typeof readDiff>, extra: Pick<Collected, 'root' | 'registry' | 'errors' | 'mergeBase' | 'untracked' | 'consumed'>): 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 <base>) 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/<base> (or <base>), 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 });
}
+77 -14
View File
@@ -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<Role, Effort>;
/** 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<DiffClass, number> = { 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<Dispatch> = {}) => 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';
+64 -12
View File
@@ -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 = /(?<![A-Za-z0-9])Blocks:?\**:?\s*\**\s*yes(?![A-Za-z0-9])/i;
const ALL_LABELS = new RegExp(ANY_LABEL.source, 'g');
/** A file:line locus ("auth.ts:9"): prose naming one after a finding's fields starts the next finding. */
const LOCUS = /[\w./-]+\.\w+:\d+/;
/** A line that is only a finding's `Severity` or `Blocks` field ("Severity: High", "- Blocks: yes", "Severity High"). */
const META = new RegExp(`^(?:[-*+]\\s+)?\\**\\s*(Severity|Blocks)\\**\\s*(?::|\\s+\\**(?:${LABELS}|yes|no)(?![A-Za-z0-9]))`, 'i');
export interface MergedLine { id: string; reader: string; label: string; raw: string; output_sha256: string }
/** One reader's findings as written: a JSON object per line (severity kept as written); else each prose line carrying a severity or FIXABLE/INVESTIGATE label, and each top-level list item under a label-only heading (the stronger of the two labels). */
/**
* One reader's findings as written: a JSON object per line (severity kept as written); else each prose line carrying a severity or
* FIXABLE/INVESTIGATE label, at the most severe label on it, and each top-level list item under a label-only heading (the stronger
* of the two labels). A `Severity` or `Blocks` field line continues the open finding, once per field: `Severity` raises its label,
* `Blocks: yes` makes it CRITICAL, `Blocks: no` never lowers it; with no finding open it is one, carrying the paragraph's subject.
* A list item, a heading, a bold or post-blank paragraph at the margin, or a line naming a file:line after the open finding's fields
* starts the next finding. An inline `Blocks: yes` is CRITICAL too. The native voice's FIXABLE collects as CRITICAL; other labels
* stay as written for lead judgment. When in doubt the parser merges rather than drops: a lost blocker costs the gate.
*/
export function parseFindings(reader: string, output: string): Array<{ label: string; raw: string }> {
const out: Array<{ label: string; raw: string }> = [];
type Finding = { label: string; raw: string; fields: Set<string> };
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<string | undefined>((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<string, unknown>;
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. */
+39 -1
View File
@@ -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, any>): 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<string, number>();
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<string, any>;
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';
+3 -9
View File
@@ -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 <base> [--lane <label>=<command>]... Steps 1-3; with lanes also Step 5
@@ -97,10 +93,8 @@ function merge(cwd: string, base: string, r: Report): boolean {
let runtime = 'unknown';
try {
const j = JSON.parse(plan.stdout);
const outputs: string[] = j.registry?.outputs ?? [];
const counted = (j.per_path as { path: string; lines: number; binary: boolean }[]).filter(e => !e.binary && !(j.stamp_only ?? []).includes(e.path)
&& !(outputs.length && matchesAny(outputs, e.path)) && classifyPath(e.path, cwd) === 'code' && !DATA_PATH.test(e.path));
runtime = String(counted.reduce((n, e) => n + e.lines, 0));
const c = classifyDiff({ paths: j.paths, perPath: j.per_path, stampOnly: j.stamp_only, registry: j.registry, consumed: j.consumed, root: cwd, gateTagged: 0 });
runtime = String(c.runtimeLines ?? 'unknown');
} catch { /* unknown */ }
r.add(`RUNTIME_LINES: ${runtime} (gstack-ship-budget check --runtime-lines at the Step 9 and 12 boundaries)`);
return true;
+2 -2
View File
@@ -18,7 +18,7 @@ import { spawnSync } from 'node:child_process';
import * as path from 'node:path';
import type { ShipReceipt } from './headless-artifacts';
import { collectCommit } from './review-collect';
import { classifyPath, depthOfCommitInput, type Depth } from './review-depth';
import { depthOfCommitInput, gateScanNeeded, type Depth } from './review-depth';
import { runScriptSync } from './script-invocation';
import { bindReceipt, type ReceiptBindingCode } from './ship-receipt';
import { docsExceptionLine, parseReceiptDocs } from './ship-receipt-docs';
@@ -59,7 +59,7 @@ export function receiptDepthAtHead(repoRoot: string, base: string, binDir: strin
const c = collectCommit(repoRoot, baseRev, head);
const scope = scopeOfRange(repoRoot, binDir, baseRev, head);
let tagged: number | null = 0;
if (c.paths.some(p => classifyPath(p, c.root) !== 'docs')) {
if (gateScanNeeded(c.paths, c.root)) {
const atHead = git(repoRoot, ['rev-parse', 'HEAD']).out === head && git(repoRoot, ['diff', '--quiet', 'HEAD', '--']).ok;
const g = atHead ? runScriptSync(path.join(binDir, 'gstack-gate-diff'), [baseRev], { cwd: repoRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 }) : null;
const m = /GATE_SUMMARY: .*?tagged=\{([^}]*)\}/.exec(g?.stdout ?? '');
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.349",
"version": "1.91.350",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+4 -4
View File
@@ -96,9 +96,9 @@ Subagent prompt:
Read the diff for this branch. First list changed files: `DIFF_BASE=$(git merge-base origin/<base> HEAD) && git diff --name-status "$DIFF_BASE"`. For NON-fixture source code, read full content: `git diff "$DIFF_BASE" -- . ':(exclude)*test*' ':(exclude)*fixture*' ':(exclude)*.spec.*'`. For fixture/test files, review in SUMMARY mode only (`git diff --stat "$DIFF_BASE" -- '*test*' '*fixture*' '*.spec.*'`) — note that they changed and what they cover, but do not pull their raw payload bytes into adversarial reasoning. State explicitly in your output that fixtures were reviewed in summary mode so the coverage reduction is visible, not silent.
Think like an attacker and a chaos engineer. Your job is to find ways this code will fail in production. Look for: edge cases, race conditions, security holes, resource leaks, failure modes, silent data corruption, logic errors that produce wrong results silently, error handling that swallows failures, and trust boundary violations. No compliments — just the problems. For each finding, classify as FIXABLE (you know how to fix it) or INVESTIGATE (needs human judgment). After listing findings, end your output with ONE line in the canonical format `Recommendation: <action> because <one-line reason naming the most exploitable finding>` — examples: `Recommendation: Fix the unbounded retry at queue.ts:78 because it'll DoS the worker pool under sustained 429s` or `Recommendation: Ship as-is because the strongest finding is a theoretical race that requires conditions we can't trigger in production`. The reason must point to a specific finding (or no-fix rationale). Generic reasons like 'because it's safer' do not qualify."
Think like an attacker and a chaos engineer. Your job is to find ways this code will fail in production. Look for: edge cases, race conditions, security holes, resource leaks, failure modes, silent data corruption, logic errors that produce wrong results silently, error handling that swallows failures, and trust boundary violations. No compliments — just the problems. For each finding, classify as FIXABLE (you know how to fix it) or INVESTIGATE (needs human judgment). A finding blocks only if it would fail a test, break production, or lose data or secrets; end each finding with `Severity: Critical|High|Medium|Low` and `Blocks: yes|no`. After listing findings, end your output with ONE line in the canonical format `Recommendation: <action> because <one-line reason naming the most exploitable finding>` — examples: `Recommendation: Fix the unbounded retry at queue.ts:78 because it'll DoS the worker pool under sustained 429s` or `Recommendation: Ship as-is because the strongest finding is a theoretical race that requires conditions we can't trigger in production`. The reason must point to a specific finding (or no-fix rationale). Generic reasons like 'because it's safer' do not qualify."
Present findings under an `ADVERSARIAL REVIEW (Claude subagent):` header. **FIXABLE findings** are queued for the parent's Fix-First handling at Step 5; do not edit during Step 4.8. **INVESTIGATE findings** are presented as informational.
Present findings under an `ADVERSARIAL REVIEW (Claude subagent):` header. **FIXABLE or `Blocks: yes` findings** are CRITICAL and are queued for the parent's Fix-First handling at Step 5; do not edit during Step 4.8. **Other INVESTIGATE findings** are informational; `Blocks: no` never lowers a FIXABLE one.
If the subagent fails or times out, record native coverage as incomplete. Continue independent passes and persistence, not release.
@@ -110,7 +110,7 @@ If `CODEX_MODE` is `ready` or `unverified`:
Outside prompt (supply repository context from the parent):
"Filesystem boundary: do not read or execute any files under ~/.claude/, ~/.agents/, .claude/skills/, or agents/. They hold skill definitions, not repository code to review. Do not invoke any installed skill (Codex home skills/, .agents/), hook, or tool instruction; answer directly. Do not modify agents/openai.yaml. Review only the repository code.\n\nReview the changes on this branch against the base branch. Use the supplied branch diff. If it was not supplied and you have repository tools, run DIFF_BASE=$(git merge-base origin/<base> HEAD) && git diff "$DIFF_BASE". Your job is to find ways this code will fail in production. Think like an attacker and a chaos engineer. Find edge cases, race conditions, security holes, resource leaks, failure modes, and silent data corruption paths. Be adversarial. Be thorough. No compliments — just the problems. End your output with ONE line in the canonical format `Recommendation: <action> because <one-line reason naming the most exploitable finding>`. Generic reasons like 'because it's safer' do not qualify; the reason must point to a specific finding or no-fix rationale."
"Filesystem boundary: do not read or execute any files under ~/.claude/, ~/.agents/, .claude/skills/, or agents/. They hold skill definitions, not repository code to review. Do not invoke any installed skill (Codex home skills/, .agents/), hook, or tool instruction; answer directly. Do not modify agents/openai.yaml. Review only the repository code.\n\nReview the changes on this branch against the base branch. Use the supplied branch diff. If it was not supplied and you have repository tools, run DIFF_BASE=$(git merge-base origin/<base> HEAD) && git diff "$DIFF_BASE". Your job is to find ways this code will fail in production. Think like an attacker and a chaos engineer. Find edge cases, race conditions, security holes, resource leaks, failure modes, and silent data corruption paths. Be adversarial. Be thorough. No compliments — just the problems. A finding blocks only if it would fail a test, break production, or lose data or secrets; end each finding with `Severity: Critical|High|Medium|Low` and `Blocks: yes|no`. End your output with ONE line in the canonical format `Recommendation: <action> because <one-line reason naming the most exploitable finding>`. Generic reasons like 'because it's safer' do not qualify; the reason must point to a specific finding or no-fix rationale."
Write the **complete prompt and context**, including actual plan/spec/source, to a private file. Substitute its shell-quoted path for `<prepared-prompt-file>`; never interpolate user text into shell source. Request a severity (Critical, High, Medium or Low) per finding and a final Recommendation: <action> because <specific reason> line, including an explicit no-findings rationale.
@@ -155,7 +155,7 @@ echo 'OUTSIDE_STATUS: completed provider=codex host=claude'
Use Bash `timeout: 600000`; show the full response in a `tool-output` fence. Require successful execution and valid markers. Refusal, empty/malformed output, missing score/severity/completion markers, timeout or CLI failure means `outside_status: unavailable`. P0/P1 findings block like native ones; `OUTSIDE_STATUS: unverified` is missing coverage. Retain the required native pass without duplicating it; it cannot complete outside coverage. After either outcome, delete only your private prompt; scratch cleanup is automatic.
Present the full output verbatim. This outside challenge is informational; supported findings still enter Step 5 Fix-First, whose approval and convergence gates apply.
Present the full output verbatim. A Critical, High or `Blocks: yes` finding is CRITICAL; `Blocks: no` never lowers one, and only a named policy ground drops it. This outside challenge is informational; supported findings still enter Step 5 Fix-First, whose approval and convergence gates apply.
**Error handling:** Only this optional outside adversarial pass is non-blocking; native completion and structured-review decisions still apply.
- **Auth failure:** If stderr contains "auth", "login", "unauthorized", or "API key": "Codex authentication failed. Run \`codex login\` to authenticate."
+5 -4
View File
@@ -17,6 +17,7 @@ import { CC_BACKGROUND_DEFAULT_SINCE, FOREGROUND_IF_AVAILABLE, BACKGROUND_RECOVE
import { outsideVoiceFailurePolicy, outsideVoiceFor, outsideVoiceInvocation, outsideVoicePreflight, outsideVoiceProvenance } from './outside-voice';
import { runtimeRootPrelude } from './runtime-root';
import { DEFENSIVE_REVIEW_FRAMING, FIXTURE_SUMMARY_MODE } from './defensive-review';
import { BLOCKING_FLOOR } from '../../lib/review-scope';
const CODEX_BOUNDARY = 'Filesystem boundary: do not read or execute any files under ~/.claude/, ~/.agents/, .claude/skills/, or agents/. They hold skill definitions, not repository code to review. Do not invoke any installed skill (Codex home skills/, .agents/), hook, or tool instruction; answer directly. Do not modify agents/openai.yaml. Review only the repository code.\\n\\n';
@@ -133,9 +134,9 @@ Subagent prompt:
Read the diff for this branch. First list changed files: \`DIFF_BASE=$(git merge-base origin/<base> HEAD) && git diff --name-status "$DIFF_BASE"\`. ${FIXTURE_SUMMARY_MODE}${isShip ? ' From Step 9\'s pass 2 on, \`DIFF_BASE=$RANGE_BASE\` applies here too; \`NO FINDINGS\` is the expected result for a clean range.' : ''}
Think like an attacker and a chaos engineer. Your job is to find ways this code will fail in production. Look for: edge cases, race conditions, security holes, resource leaks, failure modes, silent data corruption, logic errors that produce wrong results silently, error handling that swallows failures, and trust boundary violations. No compliments — just the problems. For each finding, classify as FIXABLE (you know how to fix it) or INVESTIGATE (needs human judgment). After listing findings, end your output with ONE line in the canonical format \`Recommendation: <action> because <one-line reason naming the most exploitable finding>\` — examples: \`Recommendation: Fix the unbounded retry at queue.ts:78 because it'll DoS the worker pool under sustained 429s\` or \`Recommendation: Ship as-is because the strongest finding is a theoretical race that requires conditions we can't trigger in production\`. The reason must point to a specific finding (or no-fix rationale). Generic reasons like 'because it's safer' do not qualify."
Think like an attacker and a chaos engineer. Your job is to find ways this code will fail in production. Look for: edge cases, race conditions, security holes, resource leaks, failure modes, silent data corruption, logic errors that produce wrong results silently, error handling that swallows failures, and trust boundary violations. No compliments — just the problems. For each finding, classify as FIXABLE (you know how to fix it) or INVESTIGATE (needs human judgment). A finding blocks only if it ${BLOCKING_FLOOR}; end each finding with \`Severity: Critical|High|Medium|Low\` and \`Blocks: yes|no\`. After listing findings, end your output with ONE line in the canonical format \`Recommendation: <action> because <one-line reason naming the most exploitable finding>\` — examples: \`Recommendation: Fix the unbounded retry at queue.ts:78 because it'll DoS the worker pool under sustained 429s\` or \`Recommendation: Ship as-is because the strongest finding is a theoretical race that requires conditions we can't trigger in production\`. The reason must point to a specific finding (or no-fix rationale). Generic reasons like 'because it's safer' do not qualify."
Present findings under an \`ADVERSARIAL REVIEW (${outsideVoiceFor(ctx).nativeLabel} subagent):\` header. **FIXABLE findings** ${isShip ? 'are queued for the parent; do not edit during Step 11' : "are queued for the parent's Fix-First handling at Step 5; do not edit during Step 4.8"}. **INVESTIGATE findings** are presented as informational.
Present findings under an \`ADVERSARIAL REVIEW (${outsideVoiceFor(ctx).nativeLabel} subagent):\` header. **FIXABLE or \`Blocks: yes\` findings** are CRITICAL and ${isShip ? 'are queued for the parent; do not edit during Step 11' : "are queued for the parent's Fix-First handling at Step 5; do not edit during Step 4.8"}. **Other INVESTIGATE findings** are informational; \`Blocks: no\` never lowers a FIXABLE one.
If the subagent fails or times out, record native coverage as incomplete. Continue independent passes and persistence, not release.
@@ -149,11 +150,11 @@ If \`CODEX_MODE\` is \`ready\` or \`unverified\`:
Outside prompt (supply repository context from the parent):
"${CODEX_BOUNDARY}Review the changes on this branch against the base branch. Use the supplied branch diff. If it was not supplied and you have repository tools, run DIFF_BASE=$(git merge-base origin/<base> HEAD) && git diff "$DIFF_BASE". Your job is to find ways this code will fail in production. Think like an attacker and a chaos engineer. Find edge cases, race conditions, security holes, resource leaks, failure modes, and silent data corruption paths. Be adversarial. Be thorough. No compliments — just the problems. End your output with ONE line in the canonical format \`Recommendation: <action> because <one-line reason naming the most exploitable finding>\`. Generic reasons like 'because it's safer' do not qualify; the reason must point to a specific finding or no-fix rationale."
"${CODEX_BOUNDARY}Review the changes on this branch against the base branch. Use the supplied branch diff. If it was not supplied and you have repository tools, run DIFF_BASE=$(git merge-base origin/<base> HEAD) && git diff "$DIFF_BASE". Your job is to find ways this code will fail in production. Think like an attacker and a chaos engineer. Find edge cases, race conditions, security holes, resource leaks, failure modes, and silent data corruption paths. Be adversarial. Be thorough. No compliments — just the problems. A finding blocks only if it ${BLOCKING_FLOOR}; end each finding with \`Severity: Critical|High|Medium|Low\` and \`Blocks: yes|no\`. End your output with ONE line in the canonical format \`Recommendation: <action> because <one-line reason naming the most exploitable finding>\`. Generic reasons like 'because it's safer' do not qualify; the reason must point to a specific finding or no-fix rationale."
${outsideVoiceInvocation(ctx, { timeoutMs: 540000, nativeAlreadyRequired: true, diffCommand: 'DIFF_BASE=$(git merge-base origin/<base> HEAD) && git diff "$DIFF_BASE"' })}
Present the full output verbatim. ${isShip ? 'An unavailable outside challenge does not block shipping by itself; supported findings still enter Step 11, and the structured P0/P1 and non-convergence gates still apply.' : 'This outside challenge is informational; supported findings still enter Step 5 Fix-First, whose approval and convergence gates apply.'}
Present the full output verbatim. A Critical, High or \`Blocks: yes\` finding is CRITICAL; \`Blocks: no\` never lowers one, and only a named policy ground drops it. ${isShip ? 'An unavailable outside challenge does not block shipping by itself; supported findings still enter Step 11, and the structured P0/P1 and non-convergence gates still apply.' : 'This outside challenge is informational; supported findings still enter Step 5 Fix-First, whose approval and convergence gates apply.'}
**Error handling:** Only this optional outside adversarial pass is non-blocking; native completion and structured-review decisions still apply.
${outsideVoiceFailurePolicy(ctx, { timeoutMinutes: 9, onTimeout: 'missing-coverage', stderrOnEmpty: true, fallback: 'none', escape: 1 })}
+6 -1
View File
@@ -186,7 +186,12 @@ their source labels: specialist scoring is not the final review's defect count.
After specialist attempts settle, collect their outputs, tagged by actual source.
\`NO FINDINGS\` is a completed, clean result, never a failed reviewer. Otherwise parse
each JSON line and skip invalid lines. Missing or unusable output is incomplete
coverage, not an empty success. Keep each specialist's returned findings for activity stats.
coverage, not an empty success. Keep each specialist's returned findings for activity stats.${isShip ? `
An output that carries a state-machine matrix record (State Machines, or a batched
reader that read its checklist) is parsed from \`${ctx.paths.binDir}/gstack-state-matrix findings <file>\`,
after \`check\` ran on the raw file: an untested cell that is the only finding on its
cell comes back INFORMATIONAL (\`"state_matrix":"untested-cell"\`), the test-gap ruling
in \`docs/review-policy.md\`; a wrong row or collapsed kinds stays CRITICAL.` : ''}
#### 2. Validate severity
+1 -1
View File
@@ -35,7 +35,7 @@ export interface Fixture { name: string; commit: string; paths: string[]; delete
/** clean: pass 1 finds nothing; nits: pass 1 fixes below-floor findings; blocker: pass 1 fixes a blocker; adversarial-fix: Step 11 finds a should that is fixed; fix-regression: a pass-1 fix introduces a blocker the next step finds. */
type Scenario = 'clean' | 'nits' | 'blocker' | 'adversarial-fix' | 'fix-regression';
const SCENARIOS: Scenario[] = ['clean', 'nits', 'blocker', 'adversarial-fix', 'fix-regression'];
interface Call { role: string; name?: string; effort: 'full' | 'light' | 'host'; diffBytes: number }
interface Call { role: string; name?: string; effort: 'full' | 'medium' | 'light' | 'host'; diffBytes: number }
const fileBytes = (rel: string) => { try { return fs.statSync(path.join(ROOT, rel)).size; } catch { return 0; } };
function promptBytes(role: string, name: string): number {
+4 -4
View File
@@ -103,9 +103,9 @@ Subagent prompt:
Read the diff for this branch. First list changed files: `DIFF_BASE=$(git merge-base origin/<base> HEAD) && git diff --name-status "$DIFF_BASE"`. For NON-fixture source code, read full content: `git diff "$DIFF_BASE" -- . ':(exclude)*test*' ':(exclude)*fixture*' ':(exclude)*.spec.*'`. For fixture/test files, review in SUMMARY mode only (`git diff --stat "$DIFF_BASE" -- '*test*' '*fixture*' '*.spec.*'`) — note that they changed and what they cover, but do not pull their raw payload bytes into adversarial reasoning. State explicitly in your output that fixtures were reviewed in summary mode so the coverage reduction is visible, not silent. From Step 9's pass 2 on, `DIFF_BASE=$RANGE_BASE` applies here too; `NO FINDINGS` is the expected result for a clean range.
Think like an attacker and a chaos engineer. Your job is to find ways this code will fail in production. Look for: edge cases, race conditions, security holes, resource leaks, failure modes, silent data corruption, logic errors that produce wrong results silently, error handling that swallows failures, and trust boundary violations. No compliments — just the problems. For each finding, classify as FIXABLE (you know how to fix it) or INVESTIGATE (needs human judgment). After listing findings, end your output with ONE line in the canonical format `Recommendation: <action> because <one-line reason naming the most exploitable finding>` — examples: `Recommendation: Fix the unbounded retry at queue.ts:78 because it'll DoS the worker pool under sustained 429s` or `Recommendation: Ship as-is because the strongest finding is a theoretical race that requires conditions we can't trigger in production`. The reason must point to a specific finding (or no-fix rationale). Generic reasons like 'because it's safer' do not qualify."
Think like an attacker and a chaos engineer. Your job is to find ways this code will fail in production. Look for: edge cases, race conditions, security holes, resource leaks, failure modes, silent data corruption, logic errors that produce wrong results silently, error handling that swallows failures, and trust boundary violations. No compliments — just the problems. For each finding, classify as FIXABLE (you know how to fix it) or INVESTIGATE (needs human judgment). A finding blocks only if it would fail a test, break production, or lose data or secrets; end each finding with `Severity: Critical|High|Medium|Low` and `Blocks: yes|no`. After listing findings, end your output with ONE line in the canonical format `Recommendation: <action> because <one-line reason naming the most exploitable finding>` — examples: `Recommendation: Fix the unbounded retry at queue.ts:78 because it'll DoS the worker pool under sustained 429s` or `Recommendation: Ship as-is because the strongest finding is a theoretical race that requires conditions we can't trigger in production`. The reason must point to a specific finding (or no-fix rationale). Generic reasons like 'because it's safer' do not qualify."
Present findings under an `ADVERSARIAL REVIEW (Claude subagent):` header. **FIXABLE findings** are queued for the parent; do not edit during Step 11. **INVESTIGATE findings** are presented as informational.
Present findings under an `ADVERSARIAL REVIEW (Claude subagent):` header. **FIXABLE or `Blocks: yes` findings** are CRITICAL and are queued for the parent; do not edit during Step 11. **Other INVESTIGATE findings** are informational; `Blocks: no` never lowers a FIXABLE one.
If the subagent fails or times out, record native coverage as incomplete. Continue independent passes and persistence, not release.
@@ -117,7 +117,7 @@ If `CODEX_MODE` is `ready` or `unverified`:
Outside prompt (supply repository context from the parent):
"Filesystem boundary: do not read or execute any files under ~/.claude/, ~/.agents/, .claude/skills/, or agents/. They hold skill definitions, not repository code to review. Do not invoke any installed skill (Codex home skills/, .agents/), hook, or tool instruction; answer directly. Do not modify agents/openai.yaml. Review only the repository code.\n\nReview the changes on this branch against the base branch. Use the supplied branch diff. If it was not supplied and you have repository tools, run DIFF_BASE=$(git merge-base origin/<base> HEAD) && git diff "$DIFF_BASE". Your job is to find ways this code will fail in production. Think like an attacker and a chaos engineer. Find edge cases, race conditions, security holes, resource leaks, failure modes, and silent data corruption paths. Be adversarial. Be thorough. No compliments — just the problems. End your output with ONE line in the canonical format `Recommendation: <action> because <one-line reason naming the most exploitable finding>`. Generic reasons like 'because it's safer' do not qualify; the reason must point to a specific finding or no-fix rationale."
"Filesystem boundary: do not read or execute any files under ~/.claude/, ~/.agents/, .claude/skills/, or agents/. They hold skill definitions, not repository code to review. Do not invoke any installed skill (Codex home skills/, .agents/), hook, or tool instruction; answer directly. Do not modify agents/openai.yaml. Review only the repository code.\n\nReview the changes on this branch against the base branch. Use the supplied branch diff. If it was not supplied and you have repository tools, run DIFF_BASE=$(git merge-base origin/<base> HEAD) && git diff "$DIFF_BASE". Your job is to find ways this code will fail in production. Think like an attacker and a chaos engineer. Find edge cases, race conditions, security holes, resource leaks, failure modes, and silent data corruption paths. Be adversarial. Be thorough. No compliments — just the problems. A finding blocks only if it would fail a test, break production, or lose data or secrets; end each finding with `Severity: Critical|High|Medium|Low` and `Blocks: yes|no`. End your output with ONE line in the canonical format `Recommendation: <action> because <one-line reason naming the most exploitable finding>`. Generic reasons like 'because it's safer' do not qualify; the reason must point to a specific finding or no-fix rationale."
Write the **complete prompt and context**, including actual plan/spec/source, to a private file. Substitute its shell-quoted path for `<prepared-prompt-file>`; never interpolate user text into shell source. Request a severity (Critical, High, Medium or Low) per finding and a final Recommendation: <action> because <specific reason> line, including an explicit no-findings rationale.
@@ -162,7 +162,7 @@ echo 'OUTSIDE_STATUS: completed provider=codex host=claude'
Use Bash `timeout: 600000`; show the full response in a `tool-output` fence. Require successful execution and valid markers. Refusal, empty/malformed output, missing score/severity/completion markers, timeout or CLI failure means `outside_status: unavailable`. P0/P1 findings block like native ones; `OUTSIDE_STATUS: unverified` is missing coverage. Retain the required native pass without duplicating it; it cannot complete outside coverage. After either outcome, delete only your private prompt; scratch cleanup is automatic.
Present the full output verbatim. An unavailable outside challenge does not block shipping by itself; supported findings still enter Step 11, and the structured P0/P1 and non-convergence gates still apply.
Present the full output verbatim. A Critical, High or `Blocks: yes` finding is CRITICAL; `Blocks: no` never lowers one, and only a named policy ground drops it. An unavailable outside challenge does not block shipping by itself; supported findings still enter Step 11, and the structured P0/P1 and non-convergence gates still apply.
**Error handling:** Only this optional outside adversarial pass is non-blocking; native completion and structured-review decisions still apply.
- **Auth failure:** If stderr contains "auth", "login", "unauthorized", or "API key": "Codex authentication failed. Run \`codex login\` to authenticate."
+9 -3
View File
@@ -21,9 +21,10 @@ reviewer and prompt with `git diff $RANGE_BASE` as the hunks, inside this pass.
Save its `REVIEW_PLAN:` line and fingerprint, `REVIEW_DEPTH:` (depth,
`runtime_lines`, `rules`) and `GATE_SCAN:`. Dispatch exactly its `DISPATCH:` lines
(specialists, Red Team, Step 11's voices), each at its `effort=` on the same model
family and reading its `DIFF_CMD:`, in place of 9.1's and 9.2's own selection rules;
`specialist batched` (light depth) is one subagent reading every checklist its
`checklists=` names, Red Team included, tagging each finding with its checklist.
family (`full` is the lane's reasoning, `medium` reasoning medium, `light` low; a
host that cannot set it runs its default) and reading its `DIFF_CMD:`, in place of 9.1's and 9.2's own selection rules;
`specialist batched` is one subagent reading every checklist its
`checklists=` names (light adds Red Team), tagging each finding with its checklist.
A `SKIP:` reviewer logs `{"dispatched":false,"reason":"scope"}`. Audits run at
`AUDIT_EFFORT:`. The core checklist, gate scan and QA always run. Item 6's row adds
`"dispatches":[{"role":R,"prompt_bytes":N,"diff_bytes":N}]`, `"depth"`,
@@ -442,6 +443,11 @@ After specialist attempts settle, collect their outputs, tagged by actual source
`NO FINDINGS` is a completed, clean result, never a failed reviewer. Otherwise parse
each JSON line and skip invalid lines. Missing or unusable output is incomplete
coverage, not an empty success. Keep each specialist's returned findings for activity stats.
An output that carries a state-machine matrix record (State Machines, or a batched
reader that read its checklist) is parsed from `~/.claude/skills/gstack/bin/gstack-state-matrix findings <file>`,
after `check` ran on the raw file: an untested cell that is the only finding on its
cell comes back INFORMATIONAL (`"state_matrix":"untested-cell"`), the test-gap ruling
in `docs/review-policy.md`; a wrong row or collapsed kinds stays CRITICAL.
#### 2. Validate severity
+4 -3
View File
@@ -19,9 +19,10 @@ reviewer and prompt with `git diff $RANGE_BASE` as the hunks, inside this pass.
Save its `REVIEW_PLAN:` line and fingerprint, `REVIEW_DEPTH:` (depth,
`runtime_lines`, `rules`) and `GATE_SCAN:`. Dispatch exactly its `DISPATCH:` lines
(specialists, Red Team, Step 11's voices), each at its `effort=` on the same model
family and reading its `DIFF_CMD:`, in place of 9.1's and 9.2's own selection rules;
`specialist batched` (light depth) is one subagent reading every checklist its
`checklists=` names, Red Team included, tagging each finding with its checklist.
family (`full` is the lane's reasoning, `medium` reasoning medium, `light` low; a
host that cannot set it runs its default) and reading its `DIFF_CMD:`, in place of 9.1's and 9.2's own selection rules;
`specialist batched` is one subagent reading every checklist its
`checklists=` names (light adds Red Team), tagging each finding with its checklist.
A `SKIP:` reviewer logs `{"dispatched":false,"reason":"scope"}`. Audits run at
`AUDIT_EFFORT:`. The core checklist, gate scan and QA always run. Item 6's row adds
`"dispatches":[{"role":R,"prompt_bytes":N,"diff_bytes":N}]`, `"depth"`,
+8 -8
View File
@@ -6,29 +6,29 @@
"agy/gstack-plan-design-review": 110044,
"agy/gstack-plan-devex-review": 116496,
"agy/gstack-plan-eng-review": 144907,
"agy/gstack-review": 103743,
"agy/gstack-review": 104280,
"codex/gstack-autoplan": 108553,
"codex/gstack-land-and-deploy": 100284,
"codex/gstack-plan-design-review": 111116,
"codex/gstack-plan-devex-review": 118004,
"codex/gstack-plan-eng-review": 146072,
"codex/gstack-review": 103266,
"codex/gstack-review": 103803,
"copilot/gstack-autoplan": 109253,
"copilot/gstack-plan-design-review": 110355,
"copilot/gstack-plan-devex-review": 115958,
"copilot/gstack-plan-eng-review": 145072,
"copilot/gstack-review": 103324,
"copilot/gstack-review": 103861,
"cursor/gstack-autoplan": 108485,
"cursor/gstack-plan-design-review": 108900,
"cursor/gstack-plan-devex-review": 115228,
"cursor/gstack-plan-eng-review": 143662,
"cursor/gstack-review": 121614,
"cursor/gstack-review": 122151,
"factory/gstack-autoplan": 110763,
"factory/gstack-land-and-deploy": 100105,
"factory/gstack-plan-design-review": 111675,
"factory/gstack-plan-devex-review": 118591,
"factory/gstack-plan-eng-review": 146770,
"factory/gstack-review": 125387,
"factory/gstack-review": 125924,
"gbrain/gstack-autoplan": 108892,
"gbrain/gstack-plan-eng-review": 126289,
"hermes/gstack-autoplan": 108891,
@@ -37,18 +37,18 @@
"kiro/gstack-plan-design-review": 108752,
"kiro/gstack-plan-devex-review": 115080,
"kiro/gstack-plan-eng-review": 143524,
"kiro/gstack-review": 121438,
"kiro/gstack-review": 121975,
"openclaw/gstack-autoplan": 108715,
"openclaw/gstack-plan-eng-review": 124804,
"opencode/gstack-autoplan": 109234,
"opencode/gstack-plan-design-review": 110187,
"opencode/gstack-plan-devex-review": 115836,
"opencode/gstack-plan-eng-review": 144931,
"opencode/gstack-review": 122236,
"opencode/gstack-review": 122773,
"slate/gstack-autoplan": 108372,
"slate/gstack-plan-design-review": 108826,
"slate/gstack-plan-devex-review": 115154,
"slate/gstack-plan-eng-review": 143593,
"slate/gstack-review": 121526
"slate/gstack-review": 122063
}
}
+1 -1
View File
@@ -493,7 +493,7 @@ do not launch the downstream skill or open a browser.`,
maxSkeletonBytes: 59_073, // GSTA-96 hoist (2026-10-26): lowered from 80_900 by the shared preamble that moved to sections/preamble.md, keeping the pre-hoist headroom of 319 bytes; measured 58,754. Before: Shared-code identity/skip/action rules + critical-severity validation; measured 74,493 (2026-09-17). + v1.91.12.0 merge of #2999 (review clarity repairs: await reads, research alongside dispatch, /review deadline and setup authority, findings sources) with #3002 (guarded state-root lines, plan-check checkpoints); each fit alone; measured 74,881 (2026-10-01). + Oct 8 wave: shared voice directive bans "load-bearing" and adds the reply-language rule, offset by tightening the bounded closer (net +17 bytes per tier-2+ skill); measured 74,884 (2026-10-08). + gate integrity (HONEST_WORK_GATE_INTEGRITY W2): Step 3.5 Diff scans with the shared GATE_SCAN_BLOCK, the Step 4 carry, the four-option `review-gate-disposition` question in 5c, gate record fields in 5.8 and the Gate edits report line; measured 80,054 (2026-10-10). + Oct 10 multi-agent wave PR D2 (sideways review): Step 4's enum-only paragraph becomes the sideways sweep with the forced-CRITICAL list, plus the flake-fix number sentence in the Gate Integrity paragraph; measured skeleton 80,812 (2026-10-10).
minUnionBytes: 89_000, // Phase 4 wave 1; measured union 93,357
mustContain: ['confidence', 'P1', 'P2', 'Review Army', 'adversarial'],
maxSizeRatio: 1.3136, // GSTA-96 hoist merged with GSTA-92 phase 2 (2026-10-26): both owner-approved raises together, measured 142,550 / 108,523 = 1.3135. Before: Shared-code feature + critical-severity validation: 128,042 union bytes / 108,523 baseline = 1.1799; preserves content floors. + v1.91.12.0 merge of #2999 (above, plus plan-completion fallback intent and specialist checklist-by-path) with #3002; measured 1.1843 (2026-10-01). + v1.91.19.0 wave: verdict-form Codex callers with sandbox preflight (INV-1/B1), P0/P1 structured gate (B1b), bound-plan discovery and data-not-instructions guard (F1/D3), visible LEARNINGS failures (B5); measured 129,941 / 108,523 = 1.1974 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 130,025 / 108,523 = 1.1981 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051); measured 130,359 / 108,523 = 1.2012 (2026-10-06). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 131,314 / 108,523 = 1.2100 (2026-10-06). Combined Oct 6 wave head (A and B): measured 131,966 / 108,523 = 1.2160 (2026-10-06). + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; measured 132,199 / 108,523 = 1.2182 (2026-10-07). + Oct 7 wave CEO-28/DX-11: review prompts ask for a severity per finding and design proposals use the proposal gate; measured 132,258 / 108,523 = 1.2187 (2026-10-07). + Oct 7 wave guard (CEO-20): conditional `run_in_background` request plus the background-run recovery at each foreground dispatch site; measured 132,233 / 108,523 = 1.2185 (2026-10-07). Merged with the guard area: measured 132,522 / 108,523 = 1.2211 (2026-10-07). + Oct 8 wave: the defensive-security framing and fixture summary mode reach the security specialist and the Red Team (stated once in the dispatch, referenced by the Red Team); measured 133,902 / 108,523 = 1.2339 (2026-10-08). + gate integrity W2 (above); measured 1.282 (2026-10-10). + Oct 10 multi-agent wave PR D2 (sideways review): sideways sweep and flake-fix number in Step 4; `Diff read` and `Undeclared behavior changes` in the review-mode Scope Check (plan-completion section); measured 140,497 / 108,523 = 1.2946 (2026-10-10). + harness lane E (2026-10-26): the lead-judgment stage in the finding merge and the `cause` anchor in the specialist prompts, shared with /ship through scripts/resolvers/review-army.ts; measured 141,949 / 108,523 = 1.3080.
maxSizeRatio: 1.3180, // + GSTA-167 variant G part 1 (plan approved by Garry 2026-10-30, autoplan-20261030161754 "Do it", which names about +0.3k prompt tokens per voice): the native adversarial and outside-voice prompts carry the blocking floor and the Severity and Blocks lines, +537 bytes; measured 1.3180 (2026-10-30). Before: 1.3136. GSTA-96 hoist merged with GSTA-92 phase 2 (2026-10-26): both owner-approved raises together, measured 142,550 / 108,523 = 1.3135. Before: Shared-code feature + critical-severity validation: 128,042 union bytes / 108,523 baseline = 1.1799; preserves content floors. + v1.91.12.0 merge of #2999 (above, plus plan-completion fallback intent and specialist checklist-by-path) with #3002; measured 1.1843 (2026-10-01). + v1.91.19.0 wave: verdict-form Codex callers with sandbox preflight (INV-1/B1), P0/P1 structured gate (B1b), bound-plan discovery and data-not-instructions guard (F1/D3), visible LEARNINGS failures (B5); measured 129,941 / 108,523 = 1.1974 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 130,025 / 108,523 = 1.1981 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051); measured 130,359 / 108,523 = 1.2012 (2026-10-06). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 131,314 / 108,523 = 1.2100 (2026-10-06). Combined Oct 6 wave head (A and B): measured 131,966 / 108,523 = 1.2160 (2026-10-06). + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; measured 132,199 / 108,523 = 1.2182 (2026-10-07). + Oct 7 wave CEO-28/DX-11: review prompts ask for a severity per finding and design proposals use the proposal gate; measured 132,258 / 108,523 = 1.2187 (2026-10-07). + Oct 7 wave guard (CEO-20): conditional `run_in_background` request plus the background-run recovery at each foreground dispatch site; measured 132,233 / 108,523 = 1.2185 (2026-10-07). Merged with the guard area: measured 132,522 / 108,523 = 1.2211 (2026-10-07). + Oct 8 wave: the defensive-security framing and fixture summary mode reach the security specialist and the Red Team (stated once in the dispatch, referenced by the Red Team); measured 133,902 / 108,523 = 1.2339 (2026-10-08). + gate integrity W2 (above); measured 1.282 (2026-10-10). + Oct 10 multi-agent wave PR D2 (sideways review): sideways sweep and flake-fix number in Step 4; `Diff read` and `Undeclared behavior changes` in the review-mode Scope Check (plan-completion section); measured 140,497 / 108,523 = 1.2946 (2026-10-10). + harness lane E (2026-10-26): the lead-judgment stage in the finding merge and the `cause` anchor in the specialist prompts, shared with /ship through scripts/resolvers/review-army.ts; measured 141,949 / 108,523 = 1.3080.
},
codex: {
skill: 'codex',
+71
View File
@@ -0,0 +1,71 @@
/**
* GSTA-167 variant G part 1: both prose voices carry the blocking floor verbatim and end each
* finding with Severity and Blocks, and the Step 11 rule is additive (normalizer v2): a finding is
* CRITICAL when its label rule says so or it says Blocks: yes; Blocks: no never lowers one.
*/
import { describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { parseFindings, rank } from '../lib/review-ledger';
import { BLOCKING_FLOOR } from '../lib/review-scope';
import { generateAdversarialStep } from '../scripts/resolvers/outside-voice-steps';
import { HOST_PATHS } from '../scripts/resolvers/types';
const ROOT = path.join(import.meta.dir, '..');
const render = (skillName: 'ship' | 'review', host: 'claude' | 'codex') => generateAdversarialStep({ skillName, tmplPath: '', host, paths: HOST_PATHS[host] } as any);
const prompt = (text: string, opener: string) => {
const start = text.indexOf(opener);
expect(start).toBeGreaterThan(-1);
return text.slice(start, text.indexOf('"\n', start));
};
const BLOCKS = `A finding blocks only if it ${BLOCKING_FLOOR}; end each finding with \`Severity: Critical|High|Medium|Low\` and \`Blocks: yes|no\`.`;
describe('prose voices carry the blocking floor (GSTA-167 part 1)', () => {
// Value: protects=the outside voice and the native adversarial pass rate against the floor the policy states, so a real
// blocker labeled Medium or INVESTIGATE can still say Blocks: yes; fails_when=either prompt loses the floor sentence or the
// Blocks line on any host or skill; seam=generateAdversarialStep
for (const skill of ['ship', 'review'] as const) for (const host of ['claude', 'codex'] as const) {
test(`${skill} on ${host}: both prompts end with the floor, Severity and Blocks; the structured review prompt is unchanged`, () => {
const text = render(skill, host);
const native = prompt(text, 'Think like an attacker and a chaos engineer. Your job');
const outside = prompt(text, 'Review the changes on this branch against the base branch.');
for (const p of [native, outside]) expect(p).toContain(BLOCKS);
expect(native).toContain('classify as FIXABLE (you know how to fix it) or INVESTIGATE (needs human judgment).');
expect(text).toContain('Prepare a structured review prompt requesting severity-tagged findings ([P0]-[P3]) or an explicit NO_FINDINGS conclusion.');
expect(text).toContain('**FIXABLE or `Blocks: yes` findings** are CRITICAL');
expect(text).toContain('A Critical, High or `Blocks: yes` finding is CRITICAL; `Blocks: no` never lowers one');
});
}
// Value: protects=F10, every prose copy of the floor stays the same words (wrapped copies compared with whitespace
// collapsed); fails_when=a copy drifts from BLOCKING_FLOOR; seam=none
test('every prose copy of the floor matches BLOCKING_FLOOR once whitespace is collapsed', () => {
const files = ['scripts/resolvers/fix-check.ts', 'review/SKILL.md.tmpl', 'ship/SKILL.md.tmpl', 'capy/skill/gstack/factory.md', 'capy/README.md', 'docs/review-policy.md'];
const drift = files.filter(f => !fs.readFileSync(path.join(ROOT, f), 'utf8').replace(/\s+/g, ' ').includes(BLOCKING_FLOOR));
expect(drift).toEqual([]);
});
});
describe('normalizer v2 as collected: Blocks: yes is CRITICAL, Blocks: no never lowers a label', () => {
// Value: protects=the rule round 5's G1 was scored under, on the path production runs (gstack-review-dispatch collect);
// fails_when=Blocks: yes collects below CRITICAL or Blocks: no lowers a label; seam=parseFindings
test.each([
['FIXABLE: race in a.ts:3\nSeverity: Medium\nBlocks: no', 'CRITICAL'],
['INVESTIGATE: odd lock in b.ts:9\nSeverity: Low\nBlocks: yes', 'CRITICAL'],
['INVESTIGATE: odd lock in b.ts:9\nBlocks: no', 'INVESTIGATE'],
['FIXABLE: race in a.ts:3. Severity: Medium. Blocks: no', 'CRITICAL'],
['1. FIXABLE: race in a.ts:3 — Severity: Low, Blocks: no', 'CRITICAL'],
['- FIXABLE: race in a.ts:3\n- Severity: Medium\n- Blocks: no', 'CRITICAL'],
] as const)('native %j -> %s', (output, want) => expect(parseFindings('adversarial-native', output).map(g => g.label)).toEqual([want]));
test.each([
['1. Drops the last row (q.ts:7). Severity: Critical. Blocks: no', 'Critical'],
['1. Cache evicts sessions (c.ts:4)\n Severity: High', 'High'],
['1. Retry storm (r.ts:2)\n Severity: Medium\n Blocks: yes', 'CRITICAL'],
['1. Index drift (i.ts:5). Severity: Low. Blocks: Yes, it loses the row', 'CRITICAL'],
['1. Naming (n.ts:1)\n Severity: Low\n Blocks: no', 'Low'],
] as const)('%j -> %s', (output, want) => {
const got = parseFindings('outside-voice', output);
expect(got.map(g => g.label)).toEqual([want]);
expect(rank(got[0]!.label)).not.toBeNull();
});
});
+61 -1
View File
@@ -3,7 +3,7 @@ import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { collectCommit, collectLive, loadRegistryAt } from '../lib/review-collect';
import { collectCommit, collectLive, CONSUMER_SCAN_CAP, loadRegistryAt } from '../lib/review-collect';
import { depthOfCommitInput } from '../lib/review-depth';
const dirs: string[] = [];
@@ -117,3 +117,63 @@ describe('collectLive: the working tree', () => {
expect(collectLive(r.dir, 'no-such-branch').errors.map(e => e.code)).toEqual(['numstat_failed']);
});
});
describe('GSTA-167 part 4a: which data files code names (scanConsumers)', () => {
const rows = Array.from({ length: 80 }, (_, i) => `{"i":${i}}`).join('\n') + '\n';
// Value: protects=a data file a code file names keeps review, one nothing names drops to docs, and markdown mentions do not
// count; fails_when=the consumer search misses a literal reader or counts prose; seam=git grep at head and merge base
test('a JSON code names stays code; one only markdown names is docs', () => {
const r = repo({ 'src/load.ts': "export const rows = () => require('./data/rows.json');\n", 'docs/wiki.md': 'see wiki/post/data.json\n' });
const head = r.commit({ 'data/rows.json': rows, 'wiki/post/data.json': rows });
const c = collectCommit(r.dir, r.baseSha, head);
expect([c.consumed, c.errors]).toEqual([['data/rows.json'], []]);
expect(depthOfCommitInput({ ...c, gateTagged: 0 })).toMatchObject({ class: 'code', runtimeLines: 80 });
});
test('a consumer the change deletes still counts (merge base); a data file named only by another data file follows it', () => {
const r = repo({ 'src/old.ts': "read('rules.json')\n", 'src/manifest.ts': "load('index.json')\n" });
const head = r.commit({ 'src/old.ts': null, 'assets/rules.json': rows, 'assets/index.json': '["post.json"]\n', 'assets/post.json': rows, 'orphan/list.json': '["lone.json"]\n', 'orphan/lone.json': rows });
const c = collectCommit(r.dir, r.baseSha, head);
expect(c.consumed).toEqual(['assets/index.json', 'assets/post.json', 'assets/rules.json']);
});
test('a deleted data file head code still names keeps the deletion guarded (gate class)', () => {
const r = repo({ 'src/a.ts': "read('table.csv')\n", 'data/table.csv': 'a,b\n' });
const head = r.commit({ 'data/table.csv': null, 'src/a.ts': "read('table.csv'); // moved\n" });
const c = collectCommit(r.dir, r.baseSha, head);
expect(c.consumed).toEqual(['data/table.csv']);
expect(depthOfCommitInput({ ...c, gateTagged: 0 }).class).toBe('gate');
});
// Value: protects=the live plan (gstack-review-plan) and the queue recompute (collectCommit) agree on a committed tree, and
// the live plan sees an untracked consumer; fails_when=a receipt and the queue disagree on depth; seam=collectLive vs collectCommit
test('collectLive and collectCommit compute the same consumed set on a clean tree; live counts an untracked consumer', () => {
const r = repo({ 'src/load.ts': "read('rows.json')\n" });
const head = r.commit({ 'data/rows.json': rows, 'wiki/post/data.json': rows });
expect(collectLive(r.dir, 'main').consumed).toEqual(collectCommit(r.dir, r.baseSha, head).consumed);
r.write({ 'src/new.ts': "read('post/data.json')\n" });
expect(collectLive(r.dir, 'main').consumed).toEqual(['data/rows.json', 'wiki/post/data.json']);
});
// Value: protects=the scan reads git grep the same way under any user grep config, and a failed grep keeps every data file
// code; fails_when=grep.lineNumber/grep.column shift the NUL fields so no consumer matches, or a grep failure drops data to
// docs silently; seam=git grep output and exit status
test('grep.lineNumber, grep.column and color.grep in git config do not change which files count as consumers', () => {
const r = repo({ 'src/load.ts': "export const rows = () => require('./data/rows.json');\n" });
r.git('config', 'grep.lineNumber', 'true'); r.git('config', 'grep.column', 'true'); r.git('config', 'color.grep', 'always');
const head = r.commit({ 'data/rows.json': rows, 'docs/notes.md': 'see rows.json and orphan.json\n', 'data/orphan.json': rows });
expect([collectCommit(r.dir, r.baseSha, head).consumed, collectLive(r.dir, 'main').consumed]).toEqual([['data/rows.json'], ['data/rows.json']]);
});
test('a git grep failure keeps every data file code and records consumer_scan_failed', () => {
const r = repo({ 'src/load.ts': "read('rows.json')\n" });
const head = r.commit({ 'data/rows.json': rows });
r.git('config', 'grep.lineNumber', 'not-a-bool');
const c = collectCommit(r.dir, r.baseSha, head);
expect(c.consumed).toBe('all');
expect(c.errors.map(e => e.code)).toEqual(['consumer_scan_failed']);
expect(depthOfCommitInput({ ...c, gateTagged: 0 }).class).toBe('code');
});
test('past CONSUMER_SCAN_CAP data paths every one stays code', () => {
const r = repo({});
const head = r.commit(Object.fromEntries(Array.from({ length: CONSUMER_SCAN_CAP + 1 }, (_, i) => [`d/${i}.json`, '{}\n'])));
const c = collectCommit(r.dir, r.baseSha, head);
expect(c.consumed).toBe('all');
expect(depthOfCommitInput({ ...c, gateTagged: 0 }).class).toBe('code');
}, 60_000);
});
+130 -22
View File
@@ -3,13 +3,14 @@ import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { classifyDiff, classifyPath, depthFor, depthOfCommitInput, diffCommand, nextStep, planReview, reviewPlanLine, scanNumbers, DEPTH_RULES, FULL_PASS_CAP, SCOPED_RANGE_LINES, type DepthError, type PlanInput } from '../lib/review-depth';
import { classifyDiff, classifyPath, dataCandidate, depthFor, gateScanNeeded, depthOfCommitInput, diffCommand, nextStep, planReview, reviewPlanLine, scanNumbers, DEPTH_RULES, FULL_PASS_CAP, SCOPED_RANGE_LINES, type DepthError, type PlanInput } from '../lib/review-depth';
import { summarizeReviewLoop } from '../lib/review-scope';
const ROOT = path.join(import.meta.dir, '..');
const none = scanNumbers({});
const base = (o: Partial<PlanInput>): PlanInput => ({ paths: ['src/app.ts'], scope: { SCOPE_BACKEND: 'true', SCOPE_STATE_MACHINE: 'false' }, gateTagged: 0, diffLines: 120, history: { fullPasses: 0, last: 'none' }, numbers: none, ...o });
const roles = (p: ReturnType<typeof planReview>) => p.dispatches.map(d => `${d.role}:${d.name}:${d.effort}`);
const batch = (p: ReturnType<typeof planReview>) => p.dispatches.find(d => d.role === 'specialist' && d.name === 'batched')?.checklists;
describe('classifyPath: agent-read markdown is code', () => {
test.each([
@@ -82,17 +83,23 @@ describe('planReview', () => {
expect(p.depth).toBe('light');
expect(roles(p)).toEqual([]);
});
test('code class pass 1: every reviewer at full effort, the outside voice at its own', () => {
test('code class pass 1: specialists at medium, the native adversarial at full, the outside voice at its own', () => {
const p = planReview(base({}));
expect(roles(p)).toEqual(['specialist:testing:full', 'specialist:maintainability:full', 'specialist:security:full', 'specialist:performance:full', 'specialist:simplification:full', 'adversarial-native:adversarial:full', 'outside-voice:outside-voice:host']);
expect(roles(p)).toEqual(['specialist:batched:medium', 'adversarial-native:adversarial:full', 'outside-voice:outside-voice:host']);
expect(batch(p)).toEqual(['testing', 'security']);
expect(p.skipped).toContain('maintainability, performance, simplification (lean ensemble: read only when forced)');
});
test('gate class forces red team and ignores hit-rate gating', () => {
const p = planReview(base({ gateTagged: 1, gated: ['security'] }));
const p = planReview(base({ gateTagged: 1, gated: ['security', 'state-machines'], scope: { SCOPE_BACKEND: 'true' } }));
expect(roles(p)).toContain('red-team:red-team:full');
expect(roles(p)).toContain('specialist:security:full');
expect(batch(p)).toEqual(['testing', 'security', 'state-machines']);
});
test('code class honours hit-rate gating and small-diff skip', () => {
expect(roles(planReview(base({ gated: ['simplification'] })))).not.toContain('specialist:simplification:full');
const sm = { SCOPE_BACKEND: 'true', SCOPE_STATE_MACHINE: 'true' };
expect(batch(planReview(base({ scope: sm })))).toContain('state-machines');
const gated = planReview(base({ scope: sm, gated: ['state-machines'] }));
expect(batch(gated)).not.toContain('state-machines');
expect(gated.skipped).toContain('state-machines (gated by hit rate)');
expect(planReview(base({ diffLines: 20 })).dispatches.filter(d => d.role === 'specialist').map(d => d.name)).toEqual(['batched']);
});
test('pass 2 reruns the army on the range but not the adversarial or outside voice once Step 11 ran', () => {
@@ -113,9 +120,21 @@ describe('planReview', () => {
const p = planReview(base({ numbers, reviewedNumbers: 'old', history: { fullPasses: 1, last: 'full', fixedBelow: true } }));
expect(roles(p)).toEqual(['fix-check:fix-check:full', 'outside-voice:outside-voice (numbers changed):host']);
});
test('no reviewer runs below full effort (GSTA-131 eval: a light fix-check missed a fail-open a full one caught)', () => {
for (const h of [{ fullPasses: 0, last: 'none' }, { fullPasses: 1, last: 'full', fixedBelow: true }, { fullPasses: 1, last: 'full', fixedBlocking: true }] as const)
expect(planReview(base({ gateTagged: 1, history: h })).dispatches.filter(d => d.role !== 'outside-voice').every(d => d.effort === 'full')).toBe(true);
// Value: protects=GSTA-167 part 3 (specialists at medium, CREW 30c) without moving the GSTA-131 lesson (a light fix-check missed
// a fail-open a full one caught): Red Team, native adversarial and fix-check stay full; fails_when=any of those drops below
// full, a specialist leaves medium, or --no-tiers leaves a specialist off the host default; seam=planReview
test('specialists run at medium; the Red Team, native adversarial and fix-check stay full; --no-tiers sends specialists to host', () => {
for (const h of [{ fullPasses: 0, last: 'none' }, { fullPasses: 1, last: 'full', fixedBelow: true }, { fullPasses: 1, last: 'full', fixedBlocking: true }] as const) {
const d = planReview(base({ gateTagged: 1, history: h })).dispatches;
expect(d.filter(x => x.role === 'specialist').every(x => x.effort === 'medium')).toBe(true);
expect(d.filter(x => ['red-team', 'adversarial-native', 'fix-check'].includes(x.role)).every(x => x.effort === 'full')).toBe(true);
expect(d.filter(x => x.role === 'outside-voice').every(x => x.effort === 'host')).toBe(true);
}
const gate = planReview(base({ gateTagged: 1 })).dispatches;
expect(gate.map(x => x.role)).toEqual(expect.arrayContaining(['specialist', 'red-team', 'adversarial-native']));
const host = planReview(base({ gateTagged: 1, tiers: false })).dispatches;
expect(host.filter(x => x.role === 'specialist').every(x => x.effort === 'host')).toBe(true);
expect(host.filter(x => x.role === 'red-team' || x.role === 'adversarial-native').every(x => x.effort === 'full')).toBe(true);
});
test('done dispatches nothing', () => expect(planReview(base({ history: { fullPasses: 1, last: 'full' } })).dispatches).toEqual([]));
});
@@ -143,7 +162,9 @@ describe('GSTA-131 pass-1 review repairs', () => {
expect(scanNumbers({ 'test/fixtures/bench.json': ['{ "ratio": 0.74 }'] }).publishes).toBe(true);
});
test('--all-specialists forces every specialist; a code diff past 200 lines or with a CRITICAL adds the Red Team', () => {
expect(planReview(base({ force: 'all' })).dispatches.filter(d => d.role === 'specialist').length).toBe(9);
const all = planReview(base({ force: 'all' }));
expect(all.dispatches.filter(d => d.role === 'specialist').length).toBe(1);
expect([...batch(all)!].sort()).toEqual(['api-contract', 'data-migration', 'design', 'maintainability', 'performance', 'security', 'simplification', 'state-machines', 'testing']);
const has = (o: Partial<PlanInput>) => roles(planReview(base(o))).some(r => r.startsWith('red-team'));
expect(has({ diffLines: 120 })).toBe(false);
expect(has({ diffLines: 201 })).toBe(true);
@@ -160,12 +181,14 @@ describe('GSTA-131 coverage: Red Team and specialist arms', () => {
expect(roles(planReview(base({ diffLines: 20, gateTagged: 1 })))).toContain('red-team:red-team');
});
test('scope arms select their specialist, state machines default on when scope is unknown, and --force all reaches docs class', () => {
const picked = (scope: Record<string, string>) => roles(planReview(base({ scope: { SCOPE_STATE_MACHINE: 'false', ...scope } })));
expect(picked({ SCOPE_MIGRATIONS: 'true' })).toContain('specialist:data-migration');
expect(picked({ SCOPE_API: 'true' })).toContain('specialist:api-contract');
expect(picked({ SCOPE_FRONTEND: 'true' })).toContain('specialist:design');
expect(roles(planReview(base({ scope: {} })))).toContain('specialist:state-machines');
expect(roles(planReview(base({ scope: { SCOPE_STATE_MACHINE: 'false' } })))).not.toContain('specialist:state-machines');
const picked = (scope: Record<string, string>) => batch(planReview(base({ scope: { SCOPE_STATE_MACHINE: 'false', ...scope } })));
expect(picked({ SCOPE_MIGRATIONS: 'true' })).toContain('data-migration');
expect(picked({ SCOPE_API: 'true' })).toContain('api-contract');
expect(picked({ SCOPE_FRONTEND: 'true' })).toEqual(['testing', 'security', 'design']);
expect(batch(planReview(base({ scope: {} })))).toContain('state-machines');
expect(batch(planReview(base({ scope: { SCOPE_STATE_MACHINE: 'false' } })))).not.toContain('state-machines');
expect(batch(planReview(base({ force: ['maintainability'] })))).toEqual(['testing', 'security', 'maintainability']);
expect(batch(planReview(base({ gated: ['design'], scope: { SCOPE_FRONTEND: 'true', SCOPE_STATE_MACHINE: 'false' } })))).toEqual(['testing', 'security']);
const docsAll = planReview(base({ paths: ['docs/a.md'], force: 'all' }));
expect(docsAll.class).toBe('docs');
expect(docsAll.dispatches.filter(d => d.role === 'specialist').length).toBeGreaterThan(5);
@@ -186,7 +209,7 @@ describe('review depth: the matrix', () => {
const p = planReview(mixed({ perPath: per({ 'src/app.ts': 48, 'test/a.test.ts': 190, 'docs/a.md': 9 }), diffLines: 247 }));
expect([p.depth, p.runtimeLines]).toEqual(['light', 48]);
expect(p.dispatches).toEqual([
{ role: 'specialist', name: 'batched', effort: 'full', exclude: [':(exclude)docs/a.md'], checklists: ['testing', 'maintainability', 'security', 'performance', 'red-team'] },
{ role: 'specialist', name: 'batched', effort: 'full', exclude: [':(exclude)docs/a.md'], checklists: ['testing', 'security', 'red-team'] },
{ role: 'adversarial-native', name: 'adversarial', effort: 'full', exclude: [], reason: 'backtest inconclusive (GSTA-147 D5)' },
]);
expect(p.skipped.join('; ')).toContain('red-team (light depth');
@@ -195,10 +218,10 @@ describe('review depth: the matrix', () => {
test('runtime 51 is standard: thresholds read runtime lines, reviewers read their own hunks', () => {
const p = planReview(mixed({ perPath: per({ 'src/app.ts': 51, 'test/a.test.ts': 400, 'README.md': 3 }), diffLines: 454 }));
expect([p.depth, p.runtimeLines]).toEqual(['standard', 51]);
expect(roles(p)).toEqual(['specialist:testing:full', 'specialist:maintainability:full', 'specialist:performance:full', 'adversarial-native:adversarial:full', 'outside-voice:outside-voice:host']);
expect(roles(p)).toEqual(['specialist:batched:medium', 'adversarial-native:adversarial:full', 'outside-voice:outside-voice:host']);
expect(batch(p)).toEqual(['testing', 'security']);
const ex = Object.fromEntries(p.dispatches.map(d => [d.name, d.exclude]));
expect(ex.testing).toEqual([':(exclude)README.md']);
expect(ex.maintainability).toEqual([':(exclude)README.md', ':(exclude)test/a.test.ts']);
expect(ex.batched).toEqual([':(exclude)README.md']);
expect([ex.adversarial, ex['outside-voice']]).toEqual([[], []]);
});
test.each([
@@ -245,7 +268,7 @@ describe('review depth: the matrix', () => {
test('a fixed blocker in light raises the next full step to the standard set, Red Team included', () => {
const p = planReview(mixed({ perPath: per({ 'src/app.ts': 30 }), history: { fullPasses: 1, last: 'full', fixedBlocking: true } }));
expect([p.depth, p.stepDepth]).toEqual(['light', 'standard']);
expect(roles(p)).toEqual(['specialist:testing:full', 'specialist:maintainability:full', 'specialist:performance:full', 'red-team:red-team:full', 'adversarial-native:adversarial:full', 'outside-voice:outside-voice:host']);
expect(roles(p)).toEqual(['specialist:batched:medium', 'red-team:red-team:full', 'adversarial-native:adversarial:full', 'outside-voice:outside-voice:host']);
});
test('--depth-floor raises and never lowers', () => {
expect(depthFor({ cls: 'code', runtimeLines: 10, errors: [], floor: 'full' })).toBe('full');
@@ -299,7 +322,7 @@ describe('D6: the scoped second pass after a blocking fix (GSTA-152 PR B)', () =
test('the #19 shape plans one fix-check, the Red Team and the native adversarial pass, not the specialist army', () => {
const before = planReview(n19({ history: { fullPasses: 1, last: 'full', fixedBlocking: true } }));
expect(before.next.step).toBe('full');
expect(before.dispatches.length).toBeGreaterThanOrEqual(6);
expect(roles(before)).toEqual(['specialist:batched:medium', 'red-team:red-team:full']);
const p = planReview(n19());
expect(p.next.step).toBe('scoped');
expect(p.next.reason).toContain('24 runtime lines');
@@ -391,3 +414,88 @@ describe('D6: the scoped second pass after a blocking fix (GSTA-152 PR B)', () =
expect(policy).toContain('| a full pass that fixed a blocking finding, below the cap, on a diff below full depth with a fix range of at most 150 runtime lines | scoped pass on the range |');
});
});
describe('GSTA-167 part 4a: data files no code names are docs (classifyDiff only)', () => {
const json = (n: number) => ({ path: 'wiki/post/data.json', lines: n, binary: false });
// Value: protects=a data-only docs PR (gstack-evals #34: 6,687 lines of wiki JSON) plans light with no specialists; fails_when=an
// unconsumed JSON counts as runtime lines; seam=ClassInput.consumed from lib/review-collect.ts
test('wiki JSON no code names is docs and a 6,687-line diff plans light; a numbers-bearing JSON keeps the outside voice', () => {
const input = { paths: ['wiki/post/data.json'], perPath: [json(6687)], consumed: [] as string[], gateTagged: 0 };
const c = classifyDiff(input);
expect([c.class, c.runtimeLines]).toEqual(['docs', 0]);
expect(c.reasons).toContain('docs: 1 data path(s) no code names');
const plan = planReview(base({ ...input, scope: {}, diffLines: 6687, numbers: scanNumbers({ 'wiki/post/data.json': ['{"recall": "19/29"}'] }) }));
expect([plan.depth, roles(plan)]).toEqual(['light', ['outside-voice:outside-voice:host']]);
expect(planReview(base({ ...input, scope: {}, diffLines: 6687 })).dispatches).toEqual([]);
});
test('a consumed data file, an unscanned diff and an overflowing scan all keep the file code', () => {
for (const consumed of [['wiki/post/data.json'], undefined, 'all' as const]) {
const c = classifyDiff({ paths: ['wiki/post/data.json'], perPath: [json(80)], consumed, gateTagged: 0 });
expect([c.class, c.runtimeLines]).toEqual(['code', 80]);
}
});
test.each([
'config/runtime.json', 'package.json', 'package-lock.json', 'tsconfig.build.json', 'app/.eslintrc.json', 'vite.config.json', 'public/manifest.json',
'.gstack/ship-policy.json', '.gstack/generated.json', '.github/workflows/ci.yml', 'fly.toml', 'wrangler.toml', 'docker-compose.yml', 'k8s/deploy.yaml',
'openapi.yaml', 'public/icon.svg', 'lib/gate-thresholds.json',
])('%s with no consumer stays code (config, agent prose, gate, YAML, TOML, SVG)', p => {
expect(dataCandidate(p)).toBe(false);
expect(classifyDiff({ paths: [p], perPath: [{ path: p, lines: 3, binary: false }], consumed: [], gateTagged: 0 }).class).not.toBe('docs');
});
test.each(['data/rows.csv', 'data/rows.tsv', 'logs/events.jsonl', 'assets/hero.png', 'assets/photo.JPEG', 'wiki/index.json'])('%s is a data candidate', p => expect(dataCandidate(p)).toBe(true));
test('test fixtures stay tests and markdown stays docs: the rule never touches a non-code class', () => {
expect(classifyDiff({ paths: ['test/fixtures/a.json'], consumed: [], gateTagged: 0 }).class).toBe('tests');
expect(dataCandidate('test/fixtures/a.json')).toBe(false);
});
// Value: protects=F17, the behavior change is named: an image no code names is docs, not a binary_code_path error; one code names
// still fails closed; fails_when=either direction flips; seam=none
test('a binary PNG no code names is docs with no error; one code names is still binary_code_path (full)', () => {
const png = { path: 'assets/hero.png', lines: 0, binary: true };
const free = classifyDiff({ paths: ['assets/hero.png', 'src/app.ts'], perPath: [png, { path: 'src/app.ts', lines: 4, binary: false }], consumed: [], gateTagged: 0 });
expect([free.errors, free.runtimeLines]).toEqual([[], 4]);
const named = classifyDiff({ paths: ['assets/hero.png'], perPath: [png], consumed: ['assets/hero.png'], gateTagged: 0 });
expect(named.errors.map(e => e.code)).toEqual(['binary_code_path']);
});
// Value: protects=F1, the receipt's gate scan cannot be skipped through the data rule; fails_when=a data-only range reads as
// needing no scan, so tagged=0 is recorded unscanned; seam=lib/ship-receipt-depth.ts receiptDepthAtHead
test('gateScanNeeded is path-only: a data-only range still needs the scan, a markdown-only range does not', () => {
expect(gateScanNeeded(['wiki/post/data.json'])).toBe(true);
expect(gateScanNeeded(['assets/hero.png', 'README.md'])).toBe(true);
expect(gateScanNeeded(['README.md', 'docs/a.md'])).toBe(false);
expect(classifyPath('wiki/post/data.json')).toBe('code');
});
test('a deleted data file stays guarded: a code diff that deletes one is gate class', () => {
expect(classifyDiff({ paths: ['src/app.ts', 'wiki/old.json'], deleted: ['wiki/old.json'], consumed: [], gateTagged: 0 }).class).toBe('gate');
});
});
describe('lean ensemble: one batched specialist at standard and full depth (GSTA-167 part 2)', () => {
const fixture = (name: string): PlanInput => {
const x = JSON.parse(fs.readFileSync(path.join(ROOT, 'test/fixtures/review-depth', `${name}.json`), 'utf8'));
return { paths: x.paths, deleted: x.deleted, stampOnly: x.stamp_only ?? [], scope: x.scope, gateTagged: x.gate_tagged, diffLines: x.diff_lines, numbers: x.numbers, perPath: x.per_path, registry: x.registry_outputs ? { sha: null, outputs: x.registry_outputs } : undefined, history: { fullPasses: 0, last: 'none' } };
};
const specialists = (p: ReturnType<typeof planReview>) => p.dispatches.filter(d => d.role === 'specialist');
// Value: protects=the lean ensemble's reader set (testing and security always, the four scoped checklists when scope asks,
// maintainability, performance and simplification only when forced) on captured diffs; fails_when=a plan dispatches more than
// one specialist reader at standard or full, drops testing or security, or the Red Team or voices change; seam=planReview
test('the captured gate fixture plans one batched reader, the Red Team and both voices', () => {
const p = planReview(fixture('code'));
expect([p.class, p.depth]).toEqual(['gate', 'full']);
expect(roles(p)).toEqual(['specialist:batched:medium', 'red-team:red-team:full', 'adversarial-native:adversarial:full', 'outside-voice:outside-voice:host']);
expect(batch(p)).toEqual(['testing', 'security', 'state-machines']);
expect(batch(planReview({ ...fixture('code'), scope: { ...fixture('code').scope, SCOPE_MIGRATIONS: 'true', SCOPE_API: 'true', SCOPE_FRONTEND: 'true', SCOPE_STATE_MACHINE: 'false' } }))).toEqual(['testing', 'security', 'data-migration', 'api-contract', 'design']);
});
test('the captured docs fixture dispatches no reader at standard and one batched reader when floored to full', () => {
expect(specialists(planReview({ ...fixture('docs-only'), floor: 'standard' }))).toEqual([]);
const full = planReview({ ...fixture('docs-only'), floor: 'full' });
expect(specialists(full).map(d => [d.name, d.effort, d.checklists])).toEqual([['batched', 'medium', ['testing', 'security', 'state-machines']]]);
});
test('a standard code plan reads one batched reader on its own hunks, and a forced checklist joins it', () => {
const p = planReview(base({ diffLines: 400, scope: { SCOPE_BACKEND: 'true', SCOPE_STATE_MACHINE: 'false' } }));
expect(specialists(p).map(d => [d.name, d.checklists])).toEqual([['batched', ['testing', 'security']]]);
expect(roles(p)).toContain('red-team:red-team:full');
expect(batch(planReview(base({ force: ['performance', 'simplification'] })))).toEqual(['testing', 'security', 'performance', 'simplification']);
expect(specialists(planReview(base({ tiers: false })))[0]!.effort).toBe('host');
});
});
+110
View File
@@ -73,6 +73,19 @@ describe('gstack-review-dispatch render: briefs from the sections, slots only',
expect(fs.readFileSync(path.join(out, 'review-ledger.jsonl'), 'utf8')).toContain('"event":"rendered"');
expect(run(['render', '--role', 'specialist', '--name', 'no-such-specialist', '--out', out, '--diff-base', SHA]).stderr).toContain('BRIEF_RENDER_FAILED: checklist not found');
});
// Value: protects=the lean ensemble's one batched reader (GSTA-167 part 2) gets checklist paths when the parent passes the plan's
// checklists= names as printed; fails_when=the brief names bare checklist words a reader cannot open, or an unknown name
// renders; seam=gstack-review-dispatch render
test('specialist-batched takes the DISPATCH checklists= names and writes each checklist path into the brief', () => {
const out = tmp();
const r = run(['render', '--role', 'specialist-batched', '--out', out, '--diff-base', SHA, '--checklists', 'testing,security,state-machines']);
expect(r.status, r.stderr).toBe(0);
const root = path.join(import.meta.dir, '..');
const lists = ['testing', 'security', 'state-machines'].map(n => path.join(root, 'review/specialists', `${n}.md`)).join(', ');
expect(fs.readFileSync(field(r.stdout, 'file'), 'utf8')).toContain(`Read each checklist at ${lists} (tag each finding's`);
expect(run(['render', '--role', 'specialist-batched', '--out', out, '--diff-base', SHA, '--checklists', 'testing,maintenance']).stderr).toContain('BRIEF_RENDER_FAILED: checklist not found');
});
});
describe('gstack-review-dispatch sent, collect, judge and verify: nothing changed on the way in or out', () => {
@@ -214,6 +227,103 @@ describe('gstack-review-dispatch sent, collect, judge and verify: nothing change
expect(judge(m, [{ id: 'r#1', severity: 'INFORMATIONAL', reason: 'a real defect' }]).ok).toBe(true);
});
// Value: protects=normalizer v2 in production (GSTA-167 part 1, certified by round 5's G1): a prose voice's Blocks: yes reaches
// merged.jsonl as CRITICAL, so judge refuses a lead that keeps it below the floor; Blocks: no never lowers a label;
// fails_when=a Blocks: yes finding is collected at its Medium or INVESTIGATE label, a Blocks line with no finding before it is
// lost, or Blocks: no demotes; seam=parseFindings, judge
test('a prose voice\'s Blocks: yes collects as CRITICAL, on its own line or the finding above it; Blocks: no never lowers', () => {
const out = [
'1. Retry loop drops the last row (queue.ts:78). Severity: Medium · Blocks: yes',
'2. INVESTIGATE: lock order in pool.ts:12',
' Blocks: yes',
'3. FIXABLE: stale cache key (cache.ts:4)',
' Severity: High',
' Blocks: no',
'4. Token never expires (auth.ts:9)',
' Fix: check exp',
' **Blocks:** yes',
'Recommendation: Fix the retry loop because it loses rows',
].join('\n');
const got = parseFindings('outside-voice', out);
expect(got.map(g => g.label)).toEqual(['CRITICAL', 'CRITICAL', 'High', 'CRITICAL']);
expect(got[1]!.raw).toBe('2. INVESTIGATE: lock order in pool.ts:12 | Blocks: yes');
expect(got[2]!.raw).toBe('3. FIXABLE: stale cache key (cache.ts:4) | Severity: High | Blocks: no');
expect(got[3]!.raw).toBe('4. Token never expires (auth.ts:9) | **Blocks:** yes');
const m: MergedLine[] = got.map((g, i) => ({ id: `o#${i + 1}`, reader: 'outside-voice', label: g.label, raw: g.raw, output_sha256: 'h' }));
expect(judge(m, m.map(x => ({ id: x.id, severity: x.label === 'CRITICAL' ? 'Medium' : x.label }))).downgraded).toEqual(['o#1', 'o#2', 'o#4']);
});
// Value: protects=the prompt's own layout (a finding, then Severity, then Blocks lines) collects as ONE finding that keeps its
// locus, and an explicit Severity outranks a stray label word earlier on the line (#3660 Red Team and Testing findings);
// fails_when=the layout splits into a locus-less CRITICAL plus the finding at INVESTIGATE, or 'Low-level'/'[P2]' sets the label
// below an explicit Severity: Critical/High; seam=parseFindings
test('a finding with Severity and Blocks lines below it is one record at the strongest label; a stray label word never lowers an explicit Severity', () => {
const one = parseFindings('adversarial-native', ['1. INVESTIGATE: lock order in pool.ts:12', ' Severity: Medium', ' Blocks: yes'].join('\n'));
expect(one).toEqual([{ label: 'CRITICAL', raw: '1. INVESTIGATE: lock order in pool.ts:12 | Severity: Medium | Blocks: yes' }]);
const words = parseFindings('outside-voice', ['1. Low-level retry loop in queue.ts:78 drops writes on 429. Severity: Critical. Blocks: no', '- [P2] cache.ts:40 evicts live sessions. Severity: High. Blocks: no'].join('\n'));
expect(words.map(w => w.label)).toEqual(['Critical', 'High']);
const prose = parseFindings('outside-voice', ['1. FIXABLE: race in a.ts:3', 'Why it matters: two writers', 'Severity: Low', 'Blocks: yes', '2. Unlabeled item about b.ts:9', 'Severity: Medium'].join('\n'));
expect(prose.map(p => [p.label, p.raw])).toEqual([['CRITICAL', '1. FIXABLE: race in a.ts:3 | Severity: Low | Blocks: yes'], ['Medium', '2. Unlabeled item about b.ts:9 | Severity: Medium']]);
});
// Value: protects=a prose blocker whose only marker is an inline Blocks: yes still reaches merged.jsonl as CRITICAL (#3660 pass 2
// Testing finding); fails_when=an unlabeled line carrying Blocks: yes collects nothing; seam=parseFindings
test.each([
['1. Token never expires (auth.ts:9). Blocks: yes', '1. Token never expires (auth.ts:9). Blocks: yes'],
['Token never expires in auth.ts:9 — Blocks: yes', 'Token never expires in auth.ts:9 — Blocks: yes'],
['1. Token never expires (auth.ts:9)\nFix: check exp **Blocks:** yes', '1. Token never expires (auth.ts:9) | Fix: check exp **Blocks:** yes'],
])('an inline Blocks: yes with no label is one CRITICAL finding: %j', (output, raw) => {
expect(parseFindings('outside-voice', output)).toEqual([{ label: 'CRITICAL', raw }]);
});
// Value: protects=a top-level `- Severity:` bullet is its own finding and keeps its section's floor (#3660 pass 2 outside voice);
// fails_when=two such bullets merge into one id or a Critical heading's item collects as Medium; seam=parseFindings
test('top-level Severity bullets stay separate findings and keep the section label', () => {
const got = parseFindings('outside-voice', ['## Critical findings', '- Severity: Medium — missing owner check at auth.ts:14', '- Severity: Low — token logged at log.ts:3'].join('\n'));
expect(got.map(g => g.label)).toEqual(['Critical', 'Critical']);
});
// Value: protects=one finding per merged id however a voice lays findings out (#3660 pass 2 native adversarial); fails_when=
// paragraph findings or a sentence starting with "Blocks" fold into the finding above, or a numbered or quoted Blocks line
// collects nothing; seam=parseFindings
test('paragraph findings stay separate, Blocks needs a colon to continue a finding, and a bare Blocks line is its own finding', () => {
const bold = parseFindings('outside-voice', ['**Finding 1.** a.ts:3 drops rows', 'Severity: Low', 'Blocks: no', '**Finding 2.** b.ts:4 leaks', 'Severity: High', 'Blocks: yes'].join('\n'));
expect(bold.map(b => [b.label, b.raw])).toEqual([['Low', '**Finding 1.** a.ts:3 drops rows | Severity: Low | Blocks: no'], ['CRITICAL', '**Finding 2.** b.ts:4 leaks | Severity: High | Blocks: yes']]);
const plain = parseFindings('outside-voice', ['a.ts:3 drops rows', 'Severity: High', 'Blocks: yes', 'b.ts:4 leaks', 'Severity: High', 'Blocks: yes'].join('\n'));
expect(plain.map(p => p.raw)).toEqual(['a.ts:3 drops rows | Severity: High | Blocks: yes', 'b.ts:4 leaks | Severity: High | Blocks: yes']);
expect(parseFindings('outside-voice', ['1. Medium: cache key drift (c.ts:4)', ' Blocks the event loop on every miss - High'].join('\n')).map(f => f.label)).toEqual(['Medium', 'High']);
expect(parseFindings('outside-voice', '2. Blocks: yes').map(f => f.label)).toEqual(['CRITICAL']);
expect(parseFindings('outside-voice', '> Blocks: yes').map(f => f.label)).toEqual(['CRITICAL']);
});
// Value: protects=a later finding's Severity/Blocks line never lands on the finding before it, and an explicit Severity label
// decides its line (#3660 pass 2 Red Team); fails_when=the later blocker's text is lost and the earlier finding takes its
// CRITICAL, or 'Severity: Low (not High)' collects as High; seam=parseFindings
test('prose after a finding\'s field, a blank-line paragraph or an indented block starts the next finding; an explicit Severity label wins', () => {
const raws = (o: string) => parseFindings('outside-voice', o).map(f => [f.label, f.raw]);
const two = [['Low', 'A in a.ts:1. | Severity: Low'], ['CRITICAL', 'B in b.ts:2. | Blocks: yes']];
expect(raws('A in a.ts:1.\nSeverity: Low\n\nB in b.ts:2.\nBlocks: yes')).toEqual(two);
expect(raws('A in a.ts:1.\nSeverity: Low\nB in b.ts:2.\nBlocks: yes')).toEqual(two);
expect(raws('Findings:\n A in a.ts:1.\n Severity: Low\n B in b.ts:2.\n Blocks: yes')).toEqual(two);
});
// Value: protects=the fix-check's shapes (#3660): a blocker keeps its own id and its subject, field bullets stay on their finding,
// and no Severity label buries a stronger label on its line; fails_when=any of these collects below CRITICAL, merges a later
// blocker into an earlier finding, or loses the finding's file:line; seam=parseFindings
test.each([
['A in a.ts:1.\nSeverity: Low\nB in b.ts:2 drops rows. Blocks: yes', [['Low', 'A in a.ts:1. | Severity: Low'], ['CRITICAL', 'B in b.ts:2 drops rows. Blocks: yes']]],
['**Finding 1.** a.ts:3 races. Severity: Low\n**Finding 2.** b.ts:4 leaks. Blocks: yes', [['Low', '**Finding 1.** a.ts:3 races. Severity: Low'], ['CRITICAL', '**Finding 2.** b.ts:4 leaks. Blocks: yes']]],
['FIXABLE: race in a.ts:3\nSeverity: Medium\nWhy: the writer races\nBlocks: yes', [['CRITICAL', 'FIXABLE: race in a.ts:3 | Severity: Medium | Blocks: yes']]],
['- Unlabeled thing in a.ts:3\n- Severity: Medium\n- Blocks: yes', [['CRITICAL', '- Unlabeled thing in a.ts:3 | - Severity: Medium | - Blocks: yes']]],
['1. Token never expires (auth.ts:9)\nFix: check exp\nBlocks: yes', [['CRITICAL', '1. Token never expires (auth.ts:9) | Blocks: yes']]],
['1. Retry storm (r.ts:2)\nSeverity High', [['High', '1. Retry storm (r.ts:2) | Severity High']]],
['CRITICAL: x.ts:4 drops rows (pass 1 had Severity: Low)', [['CRITICAL', 'CRITICAL: x.ts:4 drops rows (pass 1 had Severity: Low)']]],
])('fix-check shape %j', (output, want) => expect(parseFindings('outside-voice', output).map(f => [f.label, f.raw])).toEqual(want));
test('the native voice\'s FIXABLE collects as CRITICAL under its role with or without a dispatch name', () => {
for (const reader of ['adversarial-native', 'adversarial-native/adversarial']) expect(parseFindings(reader, 'FIXABLE: race in a.ts:3\nSeverity: Medium\nBlocks: no').map(f => f.label)).toEqual(['CRITICAL']);
expect(parseFindings('outside-voice', 'FIXABLE: race in a.ts:3\nSeverity: Medium').map(f => f.label)).toEqual(['Medium']);
});
test('a non-prefix Blocks: yes under a labeled finding raises it to CRITICAL', () => {
expect(parseFindings('outside-voice', '1. Retry loses writes (queue.ts:78). Severity: Medium\n Fix: reject the batch. Blocks: yes')).toEqual([{ label: 'CRITICAL', raw: '1. Retry loses writes (queue.ts:78). Severity: Medium | Fix: reject the batch. Blocks: yes' }]);
});
test('verify on a ledger that kept no prompts says so and exits 3', () => {
const d = tmp();
const ledger = path.join(d, 'ledger.jsonl');
+39 -23
View File
@@ -45,38 +45,54 @@ describe('reviewer packets stay reverted (GSTA-167)', () => {
* specialist, Red Team, adversarial and outside-voice prompts, the checklists they read, the
* fix-check prompt and the depth rules that pick reviewers. A change to any of them changes what a
* reviewer sees or how its findings rate, so it merges only after its preregistered no-loss replay
* passes (GStack Minder crew rule, 2026-10-30). Repin with that replay's id in the commit message.
* passes (GStack Minder crew rule, 2026-10-30); a cost replay alone is not enough (#3621 passed its
* cost replay, then its no-loss replay found 11 of 30 blockers). Every pin names the replay that
* admits its bytes: repin with that replay's id here and in the commit message, and merge only on
* its passing result.
*/
const REVIEWER_BRIEFS: Record<string, string> = {
'ship/sections/review-army.md': '3d89708c2776b0d97fb2420c0461d37179b9f25ca0677ba038a6cff53dbf7465',
'ship/sections/adversarial.md': '3f361c1f42605c8329048c81162216e7bf9902bdbb2327811c39ee4e201bb95d',
'review/sections/review-army.md': '78329b64640260cd8831ca81a9189f3f8dcade9c406431631d3f2cd798cd3967',
'review/sections/adversarial.md': 'a402018055670f50b75322ab92a0b03d740c481cc5ef2f2ac72b281dae29ec7b',
'review/checklist.md': '607788afeb2c83f2dff1dccd58d212616ec72393d28cda2fa18690c1a83df699',
'review/design-checklist.md': 'b9205f88313cc64902c0b13e9685722ab8138d3dcc58916154f47274abda7d83',
'review/gate-integrity.md': 'f570db344c9a01df633bcaa281d6017f4f12420fd3e0aa3078c1ce06e0df8332',
'review/specialists/api-contract.md': '263d23ac119dd601d315c191dbdbd503d47c00264c9c0ca81959559ca11d4e95',
'review/specialists/data-migration.md': 'b6fd9eb229002ea598f8fe9ff53b1cd8821e3bd37a7aa7b07b5526556c71ebca',
'review/specialists/maintainability.md': 'f8015d549e70297f782f0aabf6fe3a9fe31ec5ed61c311ab854e4a858282ed9b',
'review/specialists/performance.md': '545c294ae53638b4c8524e8cde08246a4ce3b5c287ec7c44e27f5556a3b0e8cc',
'review/specialists/red-team.md': '9ea05149f5b13d6a19ecec26285142d09d259cf80a515af828abdd1f43320427',
'review/specialists/security.md': '17bfe813eb3de22835af048ec359770720f1d7f371a26460169f7e32b1bcac9f',
'review/specialists/simplification.md': 'f33846b1dcf2fdeef82d0744ab2ef48fb4a33b34a737dafc33dd0e891756689f',
'review/specialists/state-machines.md': '597215e4438d85d9cc71b48097c61eef7cba4f4b09dc14c92b4b94893b3d3ea2',
'review/specialists/testing.md': 'd6cc2bef7be562387d0c0a22fefe646dff9c295f1d34d8453a6cb496584150e4',
'scripts/resolvers/fix-check.ts': '712bf5b0c439e96bac32f19cbe25394dabb4c94aa66600100bfde9b100e15a08',
'lib/review-depth.ts': '66f4ff6b03c869d6892f6efdfb237d578c301cf4ea6f6ecb4b366cd286a0bcb5',
const NO_LOSS_REPLAYS = {
'#3638': 'no-loss round 3: main restored the pre-#3621 reviewers (v1.91.331.0)',
'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)",
} as const;
type Replay = keyof typeof NO_LOSS_REPLAYS;
const REVIEWER_BRIEFS: Record<string, { sha: string; replay: Replay }> = {
'ship/sections/review-army.md': { sha: 'b7ca291a1cc63750bfb9710a4c12fd971523b9598da2a32ef80fb258eb0eceab', replay: 'L' },
'ship/sections/adversarial.md': { sha: 'c48aa7717c7438941fa23418367e19a62e07edda7445d0d5a55924e172dd5d33', replay: 'G1' },
'review/sections/review-army.md': { sha: '78329b64640260cd8831ca81a9189f3f8dcade9c406431631d3f2cd798cd3967', replay: '#3638' },
'review/sections/adversarial.md': { sha: 'e6962fa25b78f362654f2954f79683e524d2ebd452d1494f8a2b099888e8e255', replay: 'G1' },
'review/checklist.md': { sha: '607788afeb2c83f2dff1dccd58d212616ec72393d28cda2fa18690c1a83df699', replay: '#3638' },
'review/design-checklist.md': { sha: 'b9205f88313cc64902c0b13e9685722ab8138d3dcc58916154f47274abda7d83', replay: '#3638' },
'review/gate-integrity.md': { sha: 'f570db344c9a01df633bcaa281d6017f4f12420fd3e0aa3078c1ce06e0df8332', replay: '#3638' },
'review/specialists/api-contract.md': { sha: '263d23ac119dd601d315c191dbdbd503d47c00264c9c0ca81959559ca11d4e95', replay: '#3638' },
'review/specialists/data-migration.md': { sha: 'b6fd9eb229002ea598f8fe9ff53b1cd8821e3bd37a7aa7b07b5526556c71ebca', replay: '#3638' },
'review/specialists/maintainability.md': { sha: 'f8015d549e70297f782f0aabf6fe3a9fe31ec5ed61c311ab854e4a858282ed9b', replay: '#3638' },
'review/specialists/performance.md': { sha: '545c294ae53638b4c8524e8cde08246a4ce3b5c287ec7c44e27f5556a3b0e8cc', replay: '#3638' },
'review/specialists/red-team.md': { sha: '9ea05149f5b13d6a19ecec26285142d09d259cf80a515af828abdd1f43320427', replay: '#3638' },
'review/specialists/security.md': { sha: '17bfe813eb3de22835af048ec359770720f1d7f371a26460169f7e32b1bcac9f', replay: '#3638' },
'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' },
'scripts/resolvers/fix-check.ts': { sha: '712bf5b0c439e96bac32f19cbe25394dabb4c94aa66600100bfde9b100e15a08', replay: '#3638' },
'lib/review-depth.ts': { sha: 'd4715a5f534d1470c63da879a3d66bb090a9b4929065d1f06c30acd97ef77bb0', replay: 'L' },
};
describe('reviewer briefs stay at their no-loss-tested bytes (GSTA-167)', () => {
test('every pinned reviewer brief, checklist and depth rule hashes to its pin', () => {
const drift = Object.entries(REVIEWER_BRIEFS).filter(([file, sha]) => {
const drift = Object.entries(REVIEWER_BRIEFS).filter(([file, pin]) => {
const text = fs.readFileSync(path.join(ROOT, file), 'utf8').replace(/\r\n/g, '\n');
return createHash('sha256').update(text).digest('hex') !== sha;
}).map(([file]) => file);
return createHash('sha256').update(text).digest('hex') !== pin.sha;
}).map(([file]) => `${file} (a repin needs a passing no-loss replay; the current pin names ${REVIEWER_BRIEFS[file]!.replay})`);
expect(drift).toEqual([]);
});
test('every pin names the preregistered no-loss replay that admits its bytes', () => {
const unnamed = Object.entries(REVIEWER_BRIEFS).filter(([, pin]) => !(pin.replay in NO_LOSS_REPLAYS)).map(([file]) => file);
expect(unnamed).toEqual([]);
for (const what of Object.values(NO_LOSS_REPLAYS)) expect(what).toMatch(/no-loss|re-score/);
});
test('the pins cover every specialist checklist', () => {
const specialists = fs.readdirSync(path.join(ROOT, 'review/specialists')).filter(f => f.endsWith('.md')).map(f => `review/specialists/${f}`);
expect(specialists.filter(f => !(f in REVIEWER_BRIEFS))).toEqual([]);
+16 -9
View File
@@ -33,13 +33,20 @@ describe('gstack-review-plan', () => {
const r = run(repo({ 'docs/bench.md': 'Median latency fell 42% to 180 ms.\n' }), []);
expect(r.stdout).toContain('DISPATCH: outside-voice outside-voice effort=host');
});
test('--json carries the class, the next step, every dispatch with its effort and the audit effort; --no-tiers renders audits at host effort', () => {
test('--json carries the class, the next step, every dispatch with its effort and the audit effort; --no-tiers renders specialists and audits at host effort', () => {
const r = repo({ 'src/a.ts': Array.from({ length: 80 }, (_, i) => `export const v${i} = ${i};`).join('\n') + '\n' });
const plan = JSON.parse(run(r, ['--json']).stdout);
expect(plan).toMatchObject({ class: 'code', next: { step: 'full' }, pass: 0 });
expect(plan.dispatches.find((d: any) => d.role === 'specialist').effort).toBe('full');
expect(plan.dispatches.filter((d: any) => d.role === 'specialist').map((d: any) => d.effort)).toEqual(expect.arrayContaining(['medium']));
expect(plan.dispatches.filter((d: any) => d.role === 'specialist').every((d: any) => d.effort === 'medium')).toBe(true);
expect(plan.dispatches.find((d: any) => d.role === 'adversarial-native').effort).toBe('full');
expect(plan.audit_effort).toBe('light');
expect(JSON.parse(run(r, ['--json', '--no-tiers']).stdout).audit_effort).toBe('host');
expect(run(r, []).stdout).toContain('DISPATCH: specialist batched effort=medium checklists=testing,security,state-machines');
const host = JSON.parse(run(r, ['--json', '--no-tiers']).stdout);
expect(host.audit_effort).toBe('host');
expect(host.dispatches.filter((d: any) => d.role === 'specialist').every((d: any) => d.effort === 'host')).toBe(true);
expect(host.dispatches.find((d: any) => d.role === 'adversarial-native').effort).toBe('full');
expect(run(r, ['--no-tiers']).stdout).toContain('DISPATCH: specialist batched effort=host checklists=testing,security,state-machines');
});
test('a below-floor fix after pass 1 plans one fix-check', () => {
const r = run(repo({ 'src/a.ts': 'export const a = 1;\n' }), ['--pass', '1', '--last', 'full', '--fixed-below']);
@@ -127,25 +134,25 @@ describe('gstack-review-plan: review depth', () => {
const head = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: r.dir, encoding: 'utf8', timeout: 10_000 }).stdout.trim();
expect(out).toMatch(/^REVIEW_DEPTH: light runtime_lines=10 rules=[0-9a-f]{16} reasons=runtime=10<=50 codes=none$/m);
expect(out).toContain(`GATE_SCAN: tagged=0 head=${head}`);
expect(out).toContain('DISPATCH: specialist batched effort=full checklists=testing,maintainability,security,performance,state-machines,red-team');
expect(out).toContain('DISPATCH: specialist batched effort=full checklists=testing,security,state-machines,red-team');
expect(out).toContain('DISPATCH: adversarial-native adversarial effort=full reason=backtest inconclusive (GSTA-147 D5)');
});
test('DIFF_CMD is shell-quoted per dispatch and excludes what the reviewer does not read; DIFF_EXCLUDE: none when empty', () => {
const r = repo({ 'src/a.ts': code(80), 'docs/my notes*.md': '# n\n', 'test/a.test.ts': code(3) });
const out = run(r, []).stdout;
expect(out).toContain("DIFF_CMD: testing git diff <diff-base> -- . ':(exclude,literal)docs/my notes*.md'");
expect(out).toContain("DIFF_CMD: maintainability git diff <diff-base> -- . ':(exclude,literal)docs/my notes*.md' ':(exclude)test/a.test.ts'");
expect(out).toContain("DIFF_CMD: batched git diff <diff-base> -- . ':(exclude,literal)docs/my notes*.md'\n");
expect(out).toContain('SKIP: maintainability, performance, simplification (lean ensemble: read only when forced)');
expect(out).toContain('DIFF_CMD: adversarial git diff <diff-base>\nDIFF_EXCLUDE: adversarial none');
const cmd = /^DIFF_CMD: maintainability (.*)$/m.exec(out)![1]!.replace('<diff-base>', 'main');
const cmd = /^DIFF_CMD: batched (.*)$/m.exec(out)![1]!.replace('<diff-base>', 'main');
const diff = spawnSync('bash', ['-c', cmd.replace('git diff main', 'git diff --name-only main')], { cwd: r.dir, encoding: 'utf8' });
expect(diff.stdout.trim().split('\n')).toEqual(['src/a.ts']);
expect(diff.stdout.trim().split('\n')).toEqual(['src/a.ts', 'test/a.test.ts']);
});
test('--json carries depth, runtime_lines, rules, errors, gate_scan and every dispatch\'s exclude array', () => {
const r = repo({ 'src/a.ts': code(80), 'README.md': '# y\n' });
const plan = JSON.parse(run(r, ['--json']).stdout);
expect(plan).toMatchObject({ depth: 'standard', step_depth: 'standard', runtime_lines: 80, errors: [], codes: [], gate_scan: { tagged: 0 } });
expect(plan.rules).toMatch(/^[0-9a-f]{16}$/);
expect(plan.dispatches.find((d: any) => d.name === 'maintainability').exclude).toEqual([':(exclude)README.md']);
expect(plan.dispatches.find((d: any) => d.name === 'batched').exclude).toEqual([':(exclude)README.md']);
expect(plan.dispatches.find((d: any) => d.role === 'outside-voice').exclude).toEqual([]);
});
test('--depth-floor full raises a light diff, never lowers a full one, and an invalid floor is a usage error', () => {
+62 -2
View File
@@ -7,12 +7,13 @@
* in the range since the previous pass (or the diff) and notes the rest.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import {
isBlocking, kindOf, locusOf, parseHunks, raisedBy, renderDeferredIssue, renderDeferredSection, reviewLoopLine, scopeFindings, scopeLine, summarizeReviewLoop,
type ScopedFinding,
isBlocking, kindOf, locusOf, mapStateMatrixOutput, parseHunks, raisedBy, renderDeferredIssue, renderDeferredSection, reviewLoopLine, scopeFindings, scopeLine, summarizeReviewLoop,
UNTESTED_CELL, type ScopedFinding, type ScopeFinding,
} from '../lib/review-scope';
import { cleanup, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/restamp-fixture';
@@ -452,3 +453,62 @@ describe('raised_by: which reviewer raised a classified finding (GSTA-152 D6)',
expect(summarizeReviewLoop([old], '2026-10-28T09:00:00Z')).toMatchObject({ passes: 1, blocking_found: 1, fixed: 1 });
});
});
describe('GSTA-167 part 4b: untested state-matrix cells map below the floor per matrix cell, before the merge', () => {
const FIXTURE = path.join(ROOT, 'test', 'fixtures', 'state-matrix-6792', 'captured-output.jsonl');
const parse = (text: string) => text.split('\n').filter(Boolean).map(l => JSON.parse(l)).filter(o => !o.matrix);
const scope = (findings: ScopeFinding[], policy: 'frozen' | 'all' = 'frozen') => scopeFindings(findings, { policy, pass: 1, diffHunks: { 'src/takes/proposals.ts': 'whole', 'src/m.ts': 'whole' }, rangeHunks: null });
const matrix = (test: string | null) => JSON.stringify({ specialist: 'state-machines', matrix: { machine: 'm (src/m.ts)', kinds: ['hold'], actions: ['release'], cells: [{ kind: 'hold', action: 'release', row: 'hold released', test }] } });
const untested = { severity: 'CRITICAL', confidence: 7, path: 'src/m.ts', line: 9, category: 'state-matrix', summary: 'untested cell: hold × release → hold released', fingerprint: 'src/m.ts:state-matrix:hold:release', specialist: 'state-machines', cell: { kind: 'hold', action: 'release' } };
// Value: protects=the captured #6792 review keeps its wrong-row and collapsed-kinds defects blocking while each untested cell
// alone on its cell becomes a pass-1 note; matrix records pass through byte for byte; fails_when=a defect is demoted or a
// pure test gap blocks; seam=mapStateMatrixOutput before Step 9.3's merge
test('#6792: four lone untested cells become notes; wrong rows, collapsed kinds and cells they share stay blocking', () => {
const raw = fs.readFileSync(FIXTURE, 'utf8');
const out = mapStateMatrixOutput(raw);
expect(out.mapped).toBe(4);
expect(out.text.split('\n')[0]).toBe(raw.split('\n')[0]);
const scoped = scope(parse(out.text));
const blocking = scoped.filter(f => f.blocking).map(f => f.summary!.split(':')[0]);
expect(blocking.sort()).toEqual(['collapsed kinds', 'untested cell', 'untested cell', 'wrong row', 'wrong row']);
const notes = scoped.filter(f => f.state_matrix === UNTESTED_CELL);
expect(notes.map(f => [f.kind, f.blocking, f.scope, f.severity, f.raw_severity])).toEqual(Array(4).fill(['test-gap', false, 'note', 'INFORMATIONAL', 'CRITICAL']));
});
// Value: protects=F3, a wrong-row finding that reuses the untested cell's fingerprint can never be folded into a demoted record;
// fails_when=the merge keeps the demoted summary and the data-loss row drops to Review notes; seam=the merge keeps CRITICAL if either
test('a wrong row sharing the untested cell and its fingerprint keeps both CRITICAL, before and after a merge', () => {
const wrong = { ...untested, summary: 'wrong row: hold × release deletes the hold', line: 12 };
const out = mapStateMatrixOutput([matrix(null), JSON.stringify(untested), JSON.stringify(wrong)].join('\n'));
expect(out.mapped).toBe(0);
const merged = { ...parse(out.text)[0], severity: 'CRITICAL' };
expect(scope([merged])[0]).toMatchObject({ kind: 'defect', blocking: true, scope: 'fix' });
const demoted = parse(mapStateMatrixOutput([matrix(null), JSON.stringify(untested)].join('\n')).text)[0];
expect(scope([{ ...demoted, severity: 'CRITICAL' }])[0]).toMatchObject({ kind: 'defect', blocking: true });
});
test('no matrix record, a cell the matrix maps to a test, or a lone wrong row on an untested cell: every one stays CRITICAL', () => {
const wrongAlone = { ...untested, summary: 'wrong row: hold × release deletes the hold' };
for (const text of [JSON.stringify(untested), [matrix('test/m.test.ts:4'), JSON.stringify(untested)].join('\n'), [matrix(null), JSON.stringify(wrongAlone)].join('\n')]) {
const out = mapStateMatrixOutput(text);
expect([out.mapped, out.text]).toEqual([0, text]);
expect(scope(parse(text))[0]!.blocking).toBe(true);
}
});
test('policy all fixes a mapped cell; other policies note it from pass 1 on, in or out of the diff', () => {
const [f] = parse(mapStateMatrixOutput([matrix(null), JSON.stringify(untested)].join('\n')).text);
expect(scope([f], 'all')[0]!.scope).toBe('fix');
expect(scope([f])[0]!.scope).toBe('note');
expect(scopeFindings([f], { policy: 'diff-plus-blocking', pass: 2, diffHunks: {}, rangeHunks: {} })[0]!.scope).toBe('note');
});
test('gstack-state-matrix findings prints the mapped output and the count on stderr; check still reads the raw file', () => {
const r = spawnSync('bun', ['--no-env-file', '--config=/dev/null', path.join(ROOT, 'bin', 'gstack-state-matrix'), 'findings', FIXTURE], { encoding: 'utf8', timeout: 60_000 });
expect(r.status).toBe(0);
expect(r.stderr).toContain('STATE_MATRIX_FINDINGS: mapped=4');
expect(r.stdout).toBe(mapStateMatrixOutput(fs.readFileSync(FIXTURE, 'utf8')).text);
expect(fs.readFileSync(path.join(ROOT, 'review', 'specialists', 'state-machines.md'), 'utf8')).toContain('- **Untested cell (CRITICAL; this rule\'s High tier).**');
});
});
+16
View File
@@ -192,3 +192,19 @@ describe('gstack-ship-preflight --audit (GSTA-167 variant G)', () => {
expect(f.run(PREFLIGHT, ['--audit', '--base', 'main']).status).toBe(2);
});
});
describe('GSTA-167 part 4a: RUNTIME_LINES and REVIEW_DEPTH share one owner', () => {
// Value: protects=preflight's RUNTIME_LINES equals gstack-review-plan's runtime_lines on a mixed diff (code, a data file code
// names, one nothing names, config JSON, YAML); fails_when=the two counts disagree, as preflight's own DATA_PATH did; seam=classifyDiff
test('a mixed diff counts the same runtime lines in preflight and the review plan', () => {
const f = fx({}, {
'src/b.ts': 'export const b = 2;\n', 'src/load.ts': "export const u = 'used.json';\n", 'data/used.json': '[1,\n2,\n3]\n',
'data/rows.json': Array.from({ length: 100 }, (_, i) => `{"i":${i}}`).join('\n') + '\n', 'config/app.json': '{"a":1}\n', 'deploy.yaml': 'a: 1\nb: 2\n',
});
const preflight = lineOf(f.run(PREFLIGHT, ['--base', 'main']).stdout, 'RUNTIME_LINES');
const plan = JSON.parse(f.run(path.join(path.dirname(PREFLIGHT), 'gstack-review-plan'), ['--base', 'main', '--json', '--gate-tagged', '0']).stdout);
expect(plan.runtime_lines).toBe(1 + 1 + 3 + 1 + 2);
expect(preflight).toMatch(new RegExp(`^RUNTIME_LINES: ${plan.runtime_lines} `));
expect(plan.consumed).toEqual(['data/used.json']);
});
});
+21 -1
View File
@@ -11,7 +11,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { extractReceipt, renderReceiptBlock } from '../lib/ship-receipt';
import { bindShippedReceipt, receiptDepthVerdict, reviewLoopExceptionLine } from '../lib/ship-receipt-depth';
import { bindShippedReceipt, receiptDepthAtHead, receiptDepthVerdict, reviewLoopExceptionLine } from '../lib/ship-receipt-depth';
import { cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture';
const repos: FixtureRepo[] = [];
@@ -221,3 +221,23 @@ describe('the queue and a scoped second pass (GSTA-152 D6)', () => {
expect(bound.ok && bound.depthLine).toContain('REVIEW_DEPTH: receipt=standard required=standard');
}, 60_000);
});
describe('GSTA-167 part 4a: the data rule never skips the receipt gate scan', () => {
// Value: protects=F1, receiptDepthAtHead decides the gate scan path-only, so a range whose only change is a data file is
// scanned and a failed scan records null, never an unscanned 0; fails_when=the scan is skipped through the data rule;
// seam=stub gstack-gate-diff that fails
test('a data-only range no code names is scanned (a failing scan records null); a markdown-only range is not', () => {
const run = (files: Record<string, string>) => {
const repo = makeFixtureRepo(); repos.push(repo);
const bin = path.join(repo.root, 'stub-bin');
fs.mkdirSync(bin);
fs.writeFileSync(path.join(bin, 'gstack-diff-scope'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
fs.writeFileSync(path.join(bin, 'gstack-gate-diff'), '#!/bin/sh\nexit 1\n', { mode: 0o755 });
return receiptDepthAtHead(repo.work, 'main', bin, commit(repo, files));
};
const data = run({ 'wiki/post/data.json': '{"a": 1}\n' });
expect([data.class, data.runtime_lines, data.gate_scan.tagged]).toEqual(['docs', 0, null]);
const prose = run({ 'notes/a.md': '# a\n' });
expect([prose.class, prose.gate_scan.tagged]).toEqual(['docs', 0]);
}, 60_000);
});
+13
View File
@@ -208,3 +208,16 @@ describe('gstack-ship-release push: branch names', () => {
expect(fs.existsSync(path.join(f.work, 'PWNED'))).toBe(false);
});
});
describe('GSTA-167 part 4a: the post-review route stays path-only', () => {
// Value: protects=F1/F2, a data file edited after review sends the ship back through review even when no code names it,
// because the route never applies the data rule; fails_when=a JSON edit after review takes the docs-rechecked route; seam=classifyPath
test('a JSON edited after review is a behavior change, not docs', () => {
const f = fx();
const reviewed = wtree(f);
const post = snapshot(f);
f.write({ 'wiki/post/data.json': '{"a": 1}\n' }); f.commit('late data');
const r = f.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', post, '--lane', LANE]);
expect(r.stdout).toContain('STOP: behavior, tests or build inputs changed since review (wiki/post/data.json)');
});
});
+4
View File
@@ -0,0 +1,4 @@
{
"bytes": 442,
"why": "GSTA-167 variant G part 4b: Step 9.2 parses state-machine output through gstack-state-matrix findings (untested cells below the floor per matrix cell)"
}
+4
View File
@@ -0,0 +1,4 @@
{
"bytes": 654,
"why": "GSTA-167 variant G parts 1 and 3: both prose voices carry the blocking floor and end each finding with Severity and Blocks; specialists at effort medium"
}
+4
View File
@@ -0,0 +1,4 @@
{
"bytes": -11,
"why": "GSTA-167 variant G part 2: the batched specialist line covers every depth (light adds Red Team)"
}