Files
gstack/bin/gstack-state-matrix
T
Garry Tanandcapy-ai[bot] 8966f3620c 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>
2026-10-31 05:06:03 +00:00

118 lines
7.0 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* gstack-state-matrix — the round-trip matrix for proposal, hold and approval state machines (field list 2, W3).
*
* 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
* the lines the branch adds since its merge base with origin/<base>.
* `check` reads the state-machines specialist's output and prints
* `STATE_MATRIX_GAP:` per gap and `STATE_MATRIX_CHECK: ok | incomplete gaps=<n>`
* 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. `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';
import * as fs from 'node:fs';
import * as path from 'node:path';
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
detect [--base <ref>] [--diff-base <sha>] SCOPE_STATE_MACHINE=true|false for the branch's added code lines
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()}`;
function flag(args: string[], name: string): string | undefined {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
}
function git(args: string[], cwd?: string): { ok: boolean; out: string } {
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 120_000, maxBuffer: 256 * 1024 * 1024 });
return { ok: r.status === 0, out: r.stdout ?? '' };
}
function detect(rest: string[]): number {
const root = git(['rev-parse', '--show-toplevel']);
if (!root.ok) { process.stdout.write('SCOPE_STATE_MACHINE=false\n# not inside a git repository\n'); return EXIT.usage; }
const repoRoot = root.out.trim();
const base = resolveBaseBranch(repoRoot, flag(rest, '--base'));
const mb = flag(rest, '--diff-base') ?? git(['merge-base', `origin/${base}`, 'HEAD'], repoRoot).out.trim();
const diff = mb ? git(['-c', 'core.quotePath=false', 'diff', '--no-ext-diff', '--no-textconv', '--unified=0', '--no-color', mb], repoRoot) : { ok: false, out: '' };
if (!diff.ok) { process.stdout.write(`SCOPE_STATE_MACHINE=false\n# no merge base with origin/${base}; run: git fetch origin ${base}\n`); return EXIT.usage; }
const { detected, hits } = detectStateMachine(addedLines(diff.out));
const lines = [`SCOPE_STATE_MACHINE=${detected}`, ...hits.slice(0, 12).map(h => `# state-machine: ${h.file}:${h.line} ${[...h.subject, ...h.action].join(',')}`)];
process.stdout.write(lines.join('\n') + '\n');
return EXIT.ok;
}
function check(rest: string[]): number {
const input = rest.find((a, i) => !a.startsWith('--') && !['--repo', '--expect'].includes(rest[i - 1] ?? ''));
if (!input) { process.stderr.write(`gstack-state-matrix check: a specialist output file (or -) is required\n${HELP}\n`); return EXIT.usage; }
const text = input === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(input, 'utf8');
const repoRoot = flag(rest, '--repo') ?? (git(['rev-parse', '--show-toplevel']).out.trim() || process.cwd());
const expectFile = flag(rest, '--expect');
const expect = expectFile ? JSON.parse(fs.readFileSync(expectFile, 'utf8')) as Expectation : undefined;
const result = checkStateMatrix(text, repoRoot, expect);
process.stdout.write(rest.includes('--json') ? JSON.stringify(result) + '\n' : renderStateMatrixCheck(result));
if (result.gaps.length) { process.stderr.write(resultLine('gstack-state-matrix check', 'STATE_MATRIX_INCOMPLETE', `${result.gaps.length} gap(s)`) + '\n'); return EXIT.fail; }
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']);
if (!root.ok || !exclude.ok) { process.stderr.write('gstack-state-matrix outfile: not inside a git repository\n'); return EXIT.usage; }
const dir = path.join(root.out.trim(), '.gstack', 'tmp');
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
fs.chmodSync(dir, 0o700);
const excludeFile = path.resolve(exclude.out.trim());
fs.mkdirSync(path.dirname(excludeFile), { recursive: true });
const excluded = fs.existsSync(excludeFile) ? fs.readFileSync(excludeFile, 'utf8') : '';
if (!excluded.split('\n').includes('/.gstack/tmp/')) fs.appendFileSync(excludeFile, `${excluded && !excluded.endsWith('\n') ? '\n' : ''}/.gstack/tmp/\n`);
const file = path.join(dir, `state-machines.${randomBytes(6).toString('hex')}.md`);
fs.closeSync(fs.openSync(file, 'wx', 0o600));
process.stdout.write(file + '\n');
return EXIT.ok;
}
function main(argv: string[]): number {
const [cmd, ...rest] = argv;
if (!cmd || cmd === '--help' || cmd === '-h') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; }
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;
}
process.exitCode = main(process.argv.slice(2));