mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* wip(GSTA-131): review depth, fix-check transitions, dispatch counters, carry pregate, autoplan round policy Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * wip(GSTA-131): reviewers stay at full effort; audits light Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(ship): Step 9 dispatches what gstack-review-plan plans; fix-check after below-floor fixes; cap two full passes plus one fix-check Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: review-dispatch-compare has a free lane; review-depth fixtures registered free; resumed max_rounds case off the old default Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(GSTA-131): ship review pass-1 repairs: fix-check binds under its own skill, Step 11.5 accepts a fix-check or a planned adversarial skip, delta carry with a pregate, carry --pregate pinned to the checkout, quiet-after-cap revise regates, skill-dir markdown and rule docs classed code, gate signals from tests class up, plan fails closed on git errors, per-class compare ratios Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(GSTA-131): ship review pass-2 repairs: Step 11 follows the last full-pass plan, Important Rules restored, ship goldens regenerated, true per-class medians, review-policy transitions and once-per-invocation voices, docs/** assets stay docs-class, CLI and fix-check binding tests Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): GSTA-131 review depth, fix-check, delta /ship and autoplan rounds Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: ship-policy cap and counters, review-policy /review cycle cap, unattended passes (docs audit) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review): 400-word findings cap fits the /review size pins (five host pins lowered by 2); CHANGELOG max_rounds wording Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): gate integrity names the size-ledger entry and the lowered /review pins Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: the round-budget case asserts the default through ROUNDS output only (pregate literals heuristic) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: curate ship-receipt-carry-pregate out of the Windows lane like its --pregate siblings Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): end the Windows curation sentence instead of a mid-sentence colon Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): one decision each for the review-loop exits, the planned adversarial skip and Step 16's TODOS rerun (screen judge repair) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): fix-check repairs: pin the renamed Step 11 heading, keep the below-floor exit at the cap, name the plan run a repeat reuses, print the native skip on outside-only plans 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>
127 lines
9.8 KiB
TypeScript
Executable File
127 lines
9.8 KiB
TypeScript
Executable File
#!/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 <ref> [--pass <full passes run>] [--last none|full|fix-check] [--fixed-blocking] [--fixed-below]
|
|
* [--blocking-open] [--touched-gate] [--critical-found] [--adversarial-ran] [--gate-tagged <n>] [--gated a,b] [--force a,b|all]
|
|
* [--reviewed-numbers <fingerprint>] [--no-tiers] [--json]
|
|
* gstack-review-plan --fix-check-prompt --fix-list <file> --hunks <file> --tests <file> [--callers <file>]
|
|
*
|
|
* Reads the diff against the merge base with <ref> (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 <ref> the base branch (merge base with HEAD is the diff base); required
|
|
--pass <n> 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 <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)
|
|
--reviewed-numbers <fp> 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 <ref> 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<string, string[]> = {};
|
|
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<string, string> = {};
|
|
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());
|