#!/usr/bin/env -S bun --no-env-file --config=/dev/null /** * gstack-review-plan — how much review the next /ship step dispatches (docs/review-policy.md#review-depth). * * gstack-review-plan --base [--pass ] [--last none|full|fix-check] [--fixed-blocking] [--fixed-below] * [--blocking-open] [--touched-gate] [--critical-found] [--adversarial-ran] [--gate-tagged ] [--gated a,b] [--force a,b|all] * [--reviewed-numbers ] [--no-tiers] [--json] * gstack-review-plan --fix-check-prompt --fix-list --hunks --tests [--callers ] * * Reads the diff against the merge base with (committed, working tree and * untracked), runs gstack-diff-scope and, unless --gate-tagged is given, * gstack-gate-diff; prints REVIEW_CLASS:, REVIEW_PLAN:, one DISPATCH: line per * reviewer and one SKIP: line per reviewer left out. Logic: lib/review-depth.ts. */ import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { EXIT, exitTableHelp } from '../lib/headless-artifacts'; import { runScriptSync } from '../lib/script-invocation'; import { planReview, reviewPlanLine, scanNumbers, type History } from '../lib/review-depth'; import { renderFixCheckPrompt } from '../scripts/resolvers/fix-check'; const HELP = `gstack-review-plan — the review depth of the next /ship step (docs/review-policy.md#review-depth) --base the base branch (merge base with HEAD is the diff base); required --pass full review passes already run in this invocation (default 0) --last none|full|fix-check the step that just finished (default none: plan pass 1) --fixed-blocking the finished step fixed a blocking finding --fixed-below the finished step fixed findings below the floor --blocking-open a blocking finding is still open in fix scope --touched-gate a fix touched a gate-class path --critical-found a specialist reported a CRITICAL finding (Red Team on code class) --adversarial-ran Step 11's native adversarial pass already ran (a repeat skips it) --gate-tagged gate hunks the gate scan tagged (default: run gstack-gate-diff; failure means unavailable) --gated a,b specialists the hit-rate stats tag GATE_CANDIDATE --force a,b|all specialists forced by prompt flags (--all-specialists is all) --reviewed-numbers the numeric-claim fingerprint the outside voice already reviewed --no-tiers audits and watchers at the host's default effort too (as gstack-config review_effort_tiers off does) --json the plan as JSON --fix-check-prompt print the fix-check reviewer's prompt from --fix-list, --hunks, --tests and --callers files ${exitTableHelp()}`; const args = process.argv.slice(2); const flag = (n: string) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : undefined; }; const has = (n: string) => args.includes(n); const list = (n: string) => (flag(n) ?? '').split(',').map(s => s.trim()).filter(Boolean); const git = (a: string[]) => { const r = spawnSync('git', ['-c', 'core.quotePath=false', ...a], { encoding: 'utf8', timeout: 60_000, maxBuffer: 64 * 1024 * 1024 }); return r.status === 0 ? r.stdout : null; }; function main(): number { if (has('--help') || has('-h')) { process.stdout.write(HELP + '\n'); return EXIT.ok; } if (has('--fix-check-prompt')) { const read = (n: string, required: boolean) => { const f = flag(n); if (!f) return required ? null : ''; try { return fs.readFileSync(f, 'utf8'); } catch { return null; } }; const [fixList, touchedHunks, testResults, callers] = [read('--fix-list', true), read('--hunks', true), read('--tests', true), read('--callers', false)]; if (fixList === null || touchedHunks === null || testResults === null || callers === null) { process.stderr.write('gstack-review-plan: --fix-check-prompt needs readable --fix-list, --hunks and --tests files (--callers optional)\n'); return EXIT.usage; } process.stdout.write(renderFixCheckPrompt({ fixList, touchedHunks, testResults, callers })); return EXIT.ok; } const base = flag('--base'); if (!base) { process.stderr.write(`gstack-review-plan: --base is required\n${HELP}\n`); return EXIT.usage; } const last = (flag('--last') ?? 'none') as History['last']; if (!['none', 'full', 'fix-check'].includes(last)) { process.stderr.write('gstack-review-plan: --last must be none, full or fix-check\n'); return EXIT.usage; } const pass = Number(flag('--pass') ?? '0'); if (!Number.isInteger(pass) || pass < 0) { process.stderr.write('gstack-review-plan: --pass needs an integer >= 0\n'); return EXIT.usage; } const root = git(['rev-parse', '--show-toplevel'])?.trim(); if (!root) { process.stderr.write('gstack-review-plan: run it inside a git checkout\n'); return EXIT.fail; } process.chdir(root); const ref = git(['rev-parse', '--verify', '-q', `origin/${base}`]) !== null ? `origin/${base}` : base; const mergeBase = git(['merge-base', ref, 'HEAD'])?.trim(); if (!mergeBase) { process.stderr.write(`gstack-review-plan: no merge base with ${ref} (fetch the base first)\n`); return EXIT.fail; } const nameStatusRaw = git(['diff', '--name-status', '--no-renames', mergeBase]); const untrackedRaw = git(['ls-files', '--others', '--exclude-standard']); const numstatRaw = git(['diff', '--numstat', mergeBase]); if (nameStatusRaw === null || untrackedRaw === null || numstatRaw === null) { process.stderr.write('gstack-review-plan: git could not list the diff; no plan is safer than a plan for an empty diff\n'); return EXIT.fail; } const scratch = (p: string) => p.startsWith('.gstack/tmp/'); const nameStatus = nameStatusRaw.split('\n').filter(Boolean).map(l => l.split('\t')).filter(c => !scratch(c[1] ?? '')); const untracked = untrackedRaw.split('\n').filter(Boolean).filter(p => !scratch(p)); const paths = [...new Set([...nameStatus.map(c => c[1]!).filter(Boolean), ...untracked])].sort(); const deleted = nameStatus.filter(c => c[0] === 'D').map(c => c[1]!); const stampOnly = paths.filter(p => p.endsWith('package.json') && !untracked.includes(p)).filter(p => { const changed = (git(['diff', '-U0', '--no-color', mergeBase, '--', p]) ?? '').split('\n').filter(l => /^[+-]/.test(l) && !/^(\+\+\+|---) /.test(l)); return changed.length > 0 && changed.every(l => /^[+-]\s*"version"\s*:/.test(l)); }); const diffLines = numstatRaw.split('\n').reduce((n, l) => { const [a, d] = l.split('\t'); return n + (Number(a) || 0) + (Number(d) || 0); }, 0) + untracked.reduce((n, p) => { try { return n + fs.readFileSync(p, 'utf8').split('\n').length; } catch { return n; } }, 0); const added: Record = {}; let current = ''; for (const line of (git(['diff', '-U0', '--no-color', mergeBase]) ?? '').split('\n')) { if (line.startsWith('+++ b/')) { current = line.slice(6); continue; } if (line.startsWith('+') && !line.startsWith('+++') && current) (added[current] ??= []).push(line.slice(1)); } for (const p of untracked) { try { added[p] = fs.readFileSync(p, 'utf8').split('\n'); } catch { /* unreadable: no claims */ } } const binDir = path.dirname(Bun.fileURLToPath(import.meta.url)); const scopeRun = runScriptSync(path.join(binDir, 'gstack-diff-scope'), [base], { encoding: 'utf8', timeout: 60_000 }); const scope: Record = {}; for (const l of (scopeRun.stdout ?? '').split('\n')) { const m = /^(SCOPE_[A-Z_]+)=(.*)$/.exec(l); if (m) scope[m[1]!] = m[2]!; } if (scopeRun.status !== 0 && !scope.SCOPE_ERROR) scope.SCOPE_ERROR = `exit ${scopeRun.status}`; let gateTagged: number | null = null; if (flag('--gate-tagged') !== undefined) gateTagged = Number(flag('--gate-tagged')); else { const g = runScriptSync(path.join(binDir, 'gstack-gate-diff'), [base], { encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 }); const m = /GATE_SUMMARY: .*?tagged=\{([^}]*)\}/.exec(g.stdout ?? ''); gateTagged = g.status === 0 && m ? m[1]!.split(',').reduce((n, kv) => n + (Number(kv.split(':')[1]) || 0), 0) : null; } if (gateTagged !== null && !Number.isFinite(gateTagged)) gateTagged = null; const force = flag('--force') === 'all' ? 'all' as const : list('--force'); const tiersSetting = runScriptSync(path.join(binDir, 'gstack-config'), ['get', 'review_effort_tiers'], { encoding: 'utf8', timeout: 15_000 }); const auditEffort = has('--no-tiers') || (tiersSetting.stdout ?? '').trim() === 'off' ? 'host' : 'light'; const plan = planReview({ paths, deleted, stampOnly, root, scope, gateTagged, diffLines, numbers: scanNumbers(added), force, gated: list('--gated'), criticalFound: has('--critical-found'), adversarialRan: has('--adversarial-ran'), reviewedNumbers: flag('--reviewed-numbers'), history: { fullPasses: pass, last, fixedBlocking: has('--fixed-blocking'), fixedBelow: has('--fixed-below'), blockingOpen: has('--blocking-open'), touchedGate: has('--touched-gate') }, }); if (has('--json')) { process.stdout.write(JSON.stringify({ ...plan, diff_lines: diffLines, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly }) + '\n'); return EXIT.ok; } const out = [`REVIEW_CLASS: ${plan.class} (${plan.reasons.slice(0, 3).join('; ') || 'no signals'})`, reviewPlanLine(plan, pass + (plan.next.step === 'full' ? 1 : 0)), `NEXT: ${plan.next.step} (${plan.next.reason})`]; for (const d of plan.dispatches) out.push(`DISPATCH: ${d.role} ${d.name} effort=${d.effort}`); 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)`); process.stdout.write(out.join('\n') + '\n'); return EXIT.ok; } process.exit(main());