Files
gstack/bin/gstack-review-packet
T
Garry Tanandcapy-ai[bot] 5358a3b384 v1.91.326.0 feat(ship): reviewers read one packet; the parent reads a compact findings record (GSTA-167) (#3621)
* feat(ship): reviewers read one packet; the parent reads only a compact findings record (GSTA-167)

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

* fix(GSTA-167): keep the pinned CODEX_BOUNDARY literal in the resolver; a test holds the packet boundary equal to it

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

* fix(GSTA-167): a Step 7/8 audit is one packet, never sharded

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

* fix(GSTA-167): packets survive the replay: base as given, budget roles, read= ranges, data summaries, no stale partial manifest

- --base origin/main no longer renders origin/origin/main (resolveBaseRef shared by build and audits)
- PACKET lines carry budget_role and read= ranges under 30,000 bytes; build prints BUDGET_NEXT for gstack-ship-budget --next
- each packet ends with END OF PACKET; the brief reads it with the read tool in parallel ranges, about 3 calls
- .json/.jsonl/.ndjson/.csv/.tsv additions past 400 changed lines reach reviewers as a DATA SUMMARY
- a partial build keeps other reviewers only at the same head, base and diff content
- collect: a matrix counts only from a state-machines reviewer; a CRITICAL line failing validation is invalid

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

* fix(GSTA-167): pass-2 review fixes: data summaries read only the diff, a stale Red Team build refuses, any-case CRITICAL lines

- topLevelKeys takes keys and columns from the diff's own lines, never the working tree, which a symlink can point at a private file (outside voice CRITICAL)
- a --findings build whose head, diff base or content differs from the manifest refuses with STALE_MANIFEST instead of dropping the specialists' coverage (adversarial + Red Team)
- collect: a CRITICAL line in any case, or one that fails to parse, makes its reviewer invalid (Red Team)
- docs and the misplaced Value header in the review-packet tests

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

* docs(GSTA-167): CHANGELOG entry for reviewer packets; ship-budget names BUDGET_NEXT as the --next value

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-30 01:19:31 +00:00

150 lines
10 KiB
TypeScript
Executable File

#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* gstack-review-packet — one packet file per /ship reviewer, and the parent's compact read of
* their results (docs/review-policy.md#reviewer-packets).
*
* gstack-review-packet build --base <ref> --out-dir <dir> [--range <sha>] [--plan-file <path>] [--only a,b]
* [--findings <merged.jsonl>] [--fix-list <f> --tests <f> [--callers <f>]] [plan flags]
* gstack-review-packet build --audit test-coverage|plan-completion --base <ref> --out-dir <dir> [--plan-file <path>]
* [--context <file>] [--generation allowed|audit-only] [--passes-used <n>]
* gstack-review-packet collect --dir <dir>
*
* build runs the plan gstack-review-plan prints (lib/review-plan-live.ts, same flags) and writes
* one packet per DISPATCH line; collect reads every packet's output file. Logic: lib/review-packet.ts,
* lib/review-packet-collect.ts.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
import { runScriptSync } from '../lib/script-invocation';
import { reviewPlanLine } from '../lib/review-depth';
import { flagReader, livePlan, parsePlanFlags, planPass, PLAN_FLAGS_HELP } from '../lib/review-plan-live';
import { AUDITS, auditDraft, budgetRole, contentKey, draftFor, packetLine, PACKET_CAP_BYTES, READ_CHUNK_BYTES, readManifest, writeManifest, writePackets, type AuditName, type BuildContext, type PacketEntry } from '../lib/review-packet';
import { resolveBaseRef } from '../lib/review-collect';
import { collect } from '../lib/review-packet-collect';
const BIN = 'gstack-review-packet';
const HELP = `${BIN} — one packet file per /ship reviewer; collect reads their results (docs/review-policy.md#reviewer-packets)
build --base <ref> --out-dir <dir> write one packet per DISPATCH line of the plan gstack-review-plan prints
--range <sha> the diff base from pass 2 on (RANGE_BASE; fix-check: PASS_HEAD); default the merge base
--plan-file <path> add at most 8 KB of the plan to each packet (the plan-completion audit gets it whole)
--only a,b only these reviewers (names or roles: adversarial, outside-voice, red-team, ...)
--findings <merged.jsonl> collect's merged findings: builds the red-team packet (deferred without it)
--fix-list <f> --tests <f> [--callers <f>] the fix-check packet's inputs (its hunks are the --range diff)
--audit test-coverage|plan-completion the Step 7/8 audit packet instead of the plan's reviewers
--context <file> --generation allowed|audit-only --passes-used <n> the audit's parent-supplied inputs
plan flags (same as gstack-review-plan):
${PLAN_FLAGS_HELP}
collect --dir <dir> read every packet's output: one REVIEWER: line each, every CRITICAL on one
line, INFORMATIONAL counts; writes merged.jsonl and informational.md in <dir>
build prints PACKET: <role> <name> effort=<e> bytes=<n> tokens~=<n/4> budget_role=<r> read=<from-to,...> file=<path> out=<path>
per packet (a reviewer past ${PACKET_CAP_BYTES} bytes is sharded by path as <name>.1ofK, never truncated; read= are the line
ranges, each under ${READ_CHUNK_BYTES} bytes, a reviewer reads in parallel), PACKETS: n=<k> total_bytes=<n> fingerprint=<plan fingerprint>
and BUDGET_NEXT: dispatch:<one budget_role per packet> for gstack-ship-budget check --next. collect exits 3 with
REVIEW_COVERAGE_INCOMPLETE when any packet's output is missing or unusable.
${exitTableHelp()}`;
const args = process.argv.slice(2);
const { flag, has, list } = flagReader(args);
const fail = (msg: string, code: number = EXIT.usage) => { process.stderr.write(`${BIN}: ${msg}\n`); return code; };
const readFlag = (n: string): string | null | undefined => { const f = flag(n); if (f === undefined) return undefined; try { return fs.readFileSync(f, 'utf8'); } catch { return null; } };
function learningsFor(binDir: string) {
return (domain: string): string => {
const r = runScriptSync(path.join(binDir, 'gstack-learnings-search'), ['--type', 'pitfall', '--query', domain, '--limit', '5'], { encoding: 'utf8', timeout: 20_000 });
if (r.status !== 0) return 'unavailable';
return Buffer.from((r.stdout ?? '').trim()).subarray(0, 2048).toString('utf8');
};
}
function build(): number {
const outDir = flag('--out-dir');
if (!outDir) return fail('build needs --out-dir <dir>');
const audit = flag('--audit') as AuditName | undefined;
if (has('--audit') && !AUDITS.includes(audit as AuditName)) return fail(`--audit must be ${AUDITS.join(' or ')}`);
const flags = parsePlanFlags(args, BIN);
if ('usage' in flags) return fail(flags.usage.replace(`${BIN}: `, ''));
const planText = readFlag('--plan-file');
if (planText === null) return fail(`--plan-file ${flag('--plan-file')} is not readable`);
const binDir = path.dirname(Bun.fileURLToPath(import.meta.url));
const absOut = path.resolve(outDir);
const live = livePlan(flags, binDir, BIN);
if ('errors' in live) { process.stderr.write(live.errors.map(e => e + '\n').join('')); return EXIT.fail; }
const { plan, collected, head, root } = live;
const range = flag('--range');
if (range !== undefined && spawnSync('git', ['rev-parse', '--verify', '-q', `${range}^{commit}`], { cwd: root, encoding: 'utf8', timeout: 15_000 }).status !== 0) return fail(`--range ${range} is not a commit`);
const diffBase = range ?? collected.mergeBase!;
fs.mkdirSync(absOut, { recursive: true, mode: 0o700 });
const ctx: BuildContext = { root, skillRoot: path.resolve(binDir, '..'), binDir, outDir: absOut, head, diffBase, baseRef: resolveBaseRef(root, flags.base), fingerprint: plan.numbers.fingerprint, learnings: learningsFor(binDir),
...(planText !== undefined ? { plan: { path: flag('--plan-file')!, text: planText } } : {}) };
const content = contentKey(ctx);
const old = has('--findings') ? readManifest(absOut) : null;
const stale = old && ([['head', head], ['diff_base', diffBase], ['content', content]] as const).find(([k, v]) => old[k] !== v)?.[0];
if (stale) { process.stdout.write(`STALE_MANIFEST: ${stale} changed since the build in ${outDir} that produced --findings; the Red Team would review another tree than the specialists. Rebuild that pass's packets first.\n`); return EXIT.refused; }
const out: string[] = [];
let entries: PacketEntry[] = [];
if (audit) {
const context = readFlag('--context');
if (context === null) return fail(`--context ${flag('--context')} is not readable`);
if (audit === 'plan-completion' && planText === undefined) return fail('--audit plan-completion needs --plan-file <the bound plan>');
let draft = auditDraft(audit, { ...ctx, ...(context ? { context } : {}) }, flags.base, live.auditEffort);
const gen = flag('--generation'), used = flag('--passes-used');
if (gen || used) draft = { ...draft, head: draft.head.map(s => s.replace('<allowed|audit-only>', gen ?? '<allowed|audit-only>').replace('passes used: <N>', `passes used: ${used ?? '<N>'}`)) };
entries = writePackets(draft, ctx);
} else {
const only = list('--only');
const wanted = (role: string, key: string) => !only.length || only.includes(key) || only.includes(role);
const findingsText = readFlag('--findings');
if (findingsText === null) return fail(`--findings ${flag('--findings')} is not readable`);
for (const d of plan.dispatches) {
const key = d.name.split(' ')[0]!;
if (!wanted(d.role, key)) continue;
if (d.role === 'red-team' && findingsText === undefined) {
out.push(`PACKET_DEFERRED: red-team (after collect: build --only red-team --findings ${path.join(outDir, 'merged.jsonl')} with these flags)`);
entries.push({ role: 'red-team', name: 'red-team', reviewer: 'red-team', effort: d.effort, file: '', out: '', bytes: 0, checklists: ['red-team'], fixtures: 'summary', shard: null, deferred: true });
continue;
}
let dctx = ctx;
if (d.role === 'red-team') {
const findings = findingsText!.split('\n').filter(Boolean).flatMap(l => { try { const f = JSON.parse(l); return [`[${f.severity}] ${f.path}${f.line ? `:${f.line}` : ''} (${(f.sources ?? [f.specialist]).join(' + ')}) ${String(f.summary).replace(/\s+/g, ' ')}`.slice(0, 300)]; } catch { return []; } });
const specialists = new Set((readManifest(absOut)?.packets ?? []).filter(p => p.role === 'specialist').map(p => p.reviewer)).size;
dctx = { ...ctx, findings, specialists };
}
if (d.role === 'fix-check') {
const [fixList, tests, callers] = [readFlag('--fix-list'), readFlag('--tests'), readFlag('--callers')];
if (!fixList || !tests || callers === null) return fail('the plan dispatches fix-check: pass readable --fix-list and --tests files (--callers optional)');
dctx = { ...ctx, fixCheck: { fixList, tests, callers: callers ?? '' } };
}
entries.push(...writePackets(draftFor(d, dctx), dctx));
}
out.unshift(reviewPlanLine(plan, planPass(plan, flags.pass)));
}
writeManifest(absOut, { version: 1, head, diff_base: diffBase, fingerprint: plan.numbers.fingerprint, content }, entries, has('--only'));
const built = entries.filter(e => !e.deferred);
out.push(...built.map(packetLine), `PACKETS: n=${built.length} total_bytes=${built.reduce((n, e) => n + e.bytes, 0)} fingerprint=${plan.numbers.fingerprint}`);
if (built.length) out.push(`BUDGET_NEXT: dispatch:${built.map(e => budgetRole(e.role)).join(',')}`);
process.stdout.write(out.join('\n') + '\n');
return EXIT.ok;
}
function collectCmd(): number {
const dir = flag('--dir');
if (!dir) return fail('collect needs --dir <dir>');
const r = collect(path.resolve(dir));
if ('error' in r) return fail(r.error, EXIT.fail);
process.stdout.write(r.lines.join('\n') + '\n');
return r.complete ? EXIT.ok : EXIT.refused;
}
function main(): number {
if (has('--help') || has('-h') || !args.length) { process.stdout.write(HELP + '\n'); return args.length ? EXIT.ok : EXIT.usage; }
if (args[0] === 'build') return build();
if (args[0] === 'collect') return collectCmd();
return fail(`unknown command ${args[0]}; run ${BIN} --help`);
}
process.exit(main());