mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 15:04:37 +01:00
* G1: gstack-plan-reality check opens every file:line receipt (#973) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * G1: office-hours spec reviewer checks paths and flags through a bin-enforced probe (#973) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * G2: plan-review reruns reconcile the last task list (gstack-tasks prior/supersede) (#1791) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * G2: plan reviews write task lists through gstack-tasks start/add/close The emit block shrinks to three calls: prior (now defaulting --dir and --branch, and printing how to reconcile), start (creates the empty branch-named list) and add (one validated row per task). close records a done or dropped prior task. The plan-review result codes move to lib/result-codes-plan.ts so lib/result-codes.ts stays under the module-size ratchet; generated plan-review sections shrink, so their size pins go down. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * F2: office-hours saves the sketch beside the design doc; /plan-design-review reads it Step 5 copies sketch.png/html to <PROJECT_DIR>/{user}-{branch}-sketch-{datetime}.* and writes a Wireframe: line (state-root relative) or Wireframe: none (<reason>). The design review section finds the design doc before Pass 1 and opens the wireframe, text-only when the file is missing. G4's signals line rides along in the same generated office-hours files; tests in planning-wave-prose. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * G3: plan reviews tie storage to queries, not file count (#1048) One bullet in each of plan-eng-review and plan-ceo-review: fields queried, constrained or updated on their own get explicit columns or tables; JSON suits a document read and written whole. Written fresh; the reporter's patch is not ported. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * evals: GSTA-228 planning-wave cases with branch, main and no-gstack arms planted facts (G1, behavior), data model (G3, behavior), task-list rerun (G2, rule) and closing signals (G4, behavior). GSTACK_WAVE_ARM picks the arm; only the branch arm asserts the preregistered bar, every arm writes a score row. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: GSTA-228 planning wave fragment Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * G1: the probe takes subcommand words for fixed-subcommand CLIs The planted-facts eval's one branch miss: the reviewer asked to probe "git log", the probe refused it, and git's top-level help cannot show a log flag, so the fabricated --since-commit stayed "unverified". probe --help <command> [<subcommand> [<subcommand>]] now runs <cli> <sub> --help for git, gh, docker, cargo, go, npm and uv (never node, bun, python3, make, yarn, pnpm or a gstack bin, where the word could name a script), in an empty temporary directory with pagers set to cat, falling back to -h when --help has no usage line (a minimized system's missing man page). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * G2: task text goes through a rows file, never a command line The free suite's generated-bash lint (INV-3) refused the add command's <title>/<finding> placeholders: free text reaches a command only as a file the agent writes with its file-write tool. start now also creates an empty rows file in the repository's git-excluded .gstack/tmp and records the run; the agent writes task and disposition rows there; add validates every row, stamps phase, run, branch and commit, appends tasks to the list and dispositions to task-dispositions-<phase>-<branch>.jsonl, then supersedes the branch's earlier lists. close is gone (dispositions are rows). The new planning-wave cases get measured periodic walls in scripts/paid-test-durations.json so the periodic census still packs into 40 slices, the schema case declares the preamble sources its eng-template owners need, and the paid file count is 114. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * G2: fold start into add so each plan review emits one bash fence The rows file lives at .gstack/tmp/tasks-<phase>-rows.jsonl; add writes this run's list (empty when there are no rows), appends dispositions, deletes the rows file and supersedes the branch's earlier lists. Lowers 27 size pins. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * G2 tests: give gstack-tasks a private HOME and state root CI shard 13 home containment caught gstack-slug writing ~/.gstack/slug-cache entries from the tasks-prior fixtures. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: gstack-tasks has no start command Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * G1/G2: tighten the probe and the rows file after ship review The probe now accepts only each CLI's listed built-in subcommands (no uv run, docker run, go test, npm exec or gh extensions), refuses gstack bins that do not handle --help and never retries them with -h, and runs with a throwaway HOME and an environment without keys. gstack-tasks resolves the slug through runScriptSync (Windows) and prior deletes a rows file an unfinished run left. Adds tests for the dropped disposition and the stale rows file. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * G2: add refuses while an earlier task is neither carried nor closed Ship review (state matrix): a rerun that skipped RECONCILE silently retired open tasks. add now names each undecided <run_id>/<id> and writes nothing. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * G1/G2: fixes from the ship fix-check and Step 11 voices Probe: find the repository root without running git, drop relative and in-repository PATH entries for lookup and for the child, keep HOME and toolchain locations (rustup, mise, asdf) but no keys, drop cargo fmt/clippy (external cargo-* programs), restore planted env in the test. gstack-tasks: resolve the exclude path against the cwd (subdirectories), resolve --dir, stamp an empty branch on a detached HEAD like the aggregator, name the file from --branch, and refuse a same-second second list instead of appending. Receipts: line 0, reversed ranges, dangling links and paths through files are unresolved instead of crashing. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: probe limits and the reconcile refusal Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: probe subcommand counts per CLI (ship docs audit) 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>
145 lines
11 KiB
TypeScript
Executable File
145 lines
11 KiB
TypeScript
Executable File
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { atomicWriteSync } from '../lib/fs-atomic.ts';
|
|
import { assessOfficeHoursReviews, extractOfficeHoursReviewBlock, loadOfficeHoursReviews, officeHoursDesignChanges, officeHoursMaxRounds, officeHoursSnapshotChanges, officeHoursVerdictReceipt, parseOfficeHoursVerdictReceipt, renderOfficeHoursReview, renderOfficeHoursReviewerPrompt, replaceOfficeHoursReviewBlock, verifyOfficeHoursCitations, verifyOfficeHoursVerdictReceipt } from '../lib/office-hours-review.ts';
|
|
import { parseSpecFixes, verifySpecFixesAtCap } from '../lib/spec-review-cap.ts';
|
|
import { repoRootOf, resolveProbe, runProbe } from '../lib/office-hours-probe.ts';
|
|
import { resultLine } from '../lib/result-codes.ts';
|
|
|
|
const shellQuote = value => `'${value.replaceAll("'", `'\\''`)}'`;
|
|
|
|
// The reviewer's one execution right: --help for a gstack bin or a listed read-only CLI (lib/office-hours-probe.ts).
|
|
function probe(args) {
|
|
if (args.length < 2 || args.length > 4 || args[0] !== '--help') throw new Error('usage: gstack-office-hours-review probe --help <command> [<subcommand> [<subcommand>]]');
|
|
const named = args.slice(1).join(' ');
|
|
const where = { pathEnv: process.env.PATH ?? '', repoRoot: repoRootOf(process.cwd()) };
|
|
const target = resolveProbe(args[1], { ...where, gstackBin: path.dirname(fs.realpathSync(process.argv[1])) }, args.slice(2));
|
|
if ('refused' in target) {
|
|
console.error(resultLine('gstack-office-hours-review probe', 'OFFICE_HOURS_PROBE_REFUSED', `${named}: ${target.refused}`));
|
|
process.exitCode = 3;
|
|
return;
|
|
}
|
|
const r = runProbe(target.exe, args.slice(2), where);
|
|
console.log(`PROBE: ${named} ${r.timedOut ? 'timeout' : `exit=${r.status}`}`);
|
|
if (r.output) console.log(r.output);
|
|
if (r.timedOut) process.exitCode = 1;
|
|
}
|
|
|
|
function main(args) {
|
|
const command = args.shift();
|
|
if (command === 'probe') return probe(args);
|
|
if (!['check', 'prepare', 'seal', 'finalize'].includes(command)) throw new Error('usage: gstack-office-hours-review probe --help <command> [<subcommand>] | check --receipt <reviewer response> <round.json...> | prepare --design <doc> --out-dir <reviewdir> [round.json...] | seal --design <doc> --verdict <round-N.json> [preceding round.json...] | finalize --design <doc> [--report <report>] [--unreviewed <reason>] [--fixes <fixes.json>] <round.json...> (each also takes --max-rounds 1|2|3, default 3)');
|
|
const options = {};
|
|
const files = [];
|
|
while (args.length) {
|
|
const value = args.shift();
|
|
if (value.startsWith('--')) {
|
|
const allowed = [...{ check: ['--receipt'], prepare: ['--design', '--out-dir'], seal: ['--design', '--verdict'], finalize: ['--design', '--report', '--unreviewed', '--fixes'] }[command], '--max-rounds'];
|
|
if (!allowed.includes(value)
|
|
|| options[value] !== undefined || !args.length) throw new Error(`invalid option: ${value}`);
|
|
options[value] = args.shift();
|
|
} else files.push(value);
|
|
}
|
|
const maxRounds = officeHoursMaxRounds(options['--max-rounds']);
|
|
const rounds = loadOfficeHoursReviews(files, maxRounds);
|
|
if (command === 'check') {
|
|
// The receipt binds the reviewer's response to the last round's exact bytes.
|
|
if (!options['--receipt'] || !files.length) throw new Error('check requires --receipt with the reviewer response and every completed round');
|
|
const last = files.at(-1);
|
|
const named = parseOfficeHoursVerdictReceipt(options['--receipt']).path;
|
|
const same = fs.existsSync(named) && fs.realpathSync(named) === fs.realpathSync(last);
|
|
verifyOfficeHoursVerdictReceipt(options['--receipt'], { round: files.length, verdictPath: same ? named : last, bytes: fs.readFileSync(last) });
|
|
console.log(JSON.stringify(assessOfficeHoursReviews(rounds, maxRounds)));
|
|
return;
|
|
}
|
|
if (!options['--design']) throw new Error(`${command} requires --design`);
|
|
const designPath = path.resolve(options['--design']);
|
|
if (rounds.some(review => path.resolve(review.document) !== designPath)) throw new Error('artifact document does not match --design');
|
|
if (command === 'seal') {
|
|
if (!options['--verdict']) throw new Error('seal requires --verdict');
|
|
const verdictPath = path.resolve(options['--verdict']);
|
|
const round = rounds.length + 1;
|
|
if (path.basename(verdictPath) !== `round-${round}.json`) throw new Error(`verdict must be round-${round}.json after the supplied history`);
|
|
const bytes = fs.readFileSync(verdictPath);
|
|
let value;
|
|
try { value = JSON.parse(bytes.toString('utf8')); }
|
|
catch (error) { throw new Error(`saved verdict is invalid JSON: ${error instanceof Error ? error.message : String(error)}`); }
|
|
const review = assessOfficeHoursReviews([...rounds, value], maxRounds).rounds.at(-1);
|
|
if (path.resolve(review.document) !== designPath) throw new Error('verdict document does not match --design');
|
|
if (round > 1) verifyOfficeHoursCitations(review, officeHoursSnapshotChanges(verdictPath, round).changed);
|
|
console.log(officeHoursVerdictReceipt(round, verdictPath, bytes));
|
|
return;
|
|
}
|
|
if (command === 'prepare') {
|
|
if (!options['--out-dir']) throw new Error('prepare requires --out-dir');
|
|
if (!fs.statSync(designPath).isFile()) throw new Error('design must be an existing file');
|
|
const outDir = fs.realpathSync(options['--out-dir']);
|
|
if (!fs.statSync(outDir).isDirectory()) throw new Error('out-dir must be an existing directory');
|
|
const round = rounds.length + 1;
|
|
const promptPath = path.join(outDir, `round-${round}.prompt.md`);
|
|
const verdictPath = path.join(outDir, `round-${round}.json`);
|
|
// Each round records the exact design it reviewed; later rounds see the diff since then.
|
|
const snapshotPath = path.join(outDir, `round-${round}.design.md`);
|
|
const design = fs.readFileSync(designPath, 'utf8');
|
|
let changes;
|
|
if (round > 1 && assessOfficeHoursReviews(rounds, maxRounds).stop !== 'CONTINUE') throw new Error('cannot prepare another round after a terminal review');
|
|
if (round > 1) {
|
|
const before = path.join(outDir, `round-${round - 1}.design.md`);
|
|
if (!fs.existsSync(before)) throw new Error(`out-dir lacks round-${round - 1}.design.md; prepare every round in the same review directory`);
|
|
changes = officeHoursDesignChanges(fs.readFileSync(before, 'utf8'), design).diff;
|
|
}
|
|
const prompt = renderOfficeHoursReviewerPrompt({ document: designPath, verdictPath, previous: rounds.at(-1), changes, maxRounds });
|
|
const inputs = new Set([fs.realpathSync(designPath), ...files.map(file => fs.realpathSync(file))]);
|
|
for (const destination of [promptPath, verdictPath, snapshotPath]) {
|
|
if (fs.existsSync(destination) && inputs.has(fs.realpathSync(destination))) throw new Error('output destination overlaps input evidence');
|
|
}
|
|
if (fs.lstatSync(verdictPath, { throwIfNoEntry: false })) throw new Error('next verdict already exists; check it before preparing another attempt');
|
|
const captured = fs.lstatSync(snapshotPath, { throwIfNoEntry: false });
|
|
if (captured && (!captured.isFile() || fs.readFileSync(snapshotPath, 'utf8') !== design)) throw new Error(`the design changed after round ${round} was prepared`);
|
|
const existing = fs.lstatSync(promptPath, { throwIfNoEntry: false });
|
|
if (existing && (!existing.isFile() || fs.readFileSync(promptPath, 'utf8') !== prompt)) throw new Error('existing review prompt differs from the complete saved evidence');
|
|
if (!captured) atomicWriteSync(snapshotPath, design, { mode: 0o600 });
|
|
if (!existing) atomicWriteSync(promptPath, prompt, { mode: 0o600 });
|
|
const seal = [shellQuote(fs.realpathSync(process.argv[1])), 'seal', ...(maxRounds === 3 ? [] : [`--max-rounds ${maxRounds}`]), '--design', shellQuote(designPath), '--verdict', shellQuote(verdictPath), ...files.map(file => shellQuote(fs.realpathSync(file)))].join(' ');
|
|
const dispatch = `Read ${JSON.stringify(promptPath)} and execute the independent spec review as instructed.\nDocument: ${designPath}\nPrompt: ${promptPath}\nVerdict: ${verdictPath}\nProbe: ${shellQuote(fs.realpathSync(process.argv[1]))} probe --help <command> [<subcommand>]\nSeal: ${seal}`;
|
|
console.log(JSON.stringify({ round, promptPath, verdictPath, dispatch }));
|
|
return;
|
|
}
|
|
const reportPath = options['--report'] ? path.resolve(options['--report']) : null;
|
|
if (reportPath === designPath) throw new Error('design and report must be different files');
|
|
const inputs = new Set(files.map(file => fs.realpathSync(file)));
|
|
for (const destination of [designPath, reportPath].filter(Boolean)) {
|
|
if (inputs.has(fs.realpathSync(destination))) throw new Error('output destination overlaps a review artifact');
|
|
}
|
|
if (reportPath && fs.realpathSync(reportPath) === fs.realpathSync(designPath)) throw new Error('design and report must be different files');
|
|
// At the cap (blocking findings remain) the finalizer verifies listed fixes deterministically; it never launches a reviewer.
|
|
const stop = rounds.length && options['--unreviewed'] === undefined ? assessOfficeHoursReviews(rounds, maxRounds).stop : null;
|
|
let cap = null;
|
|
if (stop === 'MAX_ITERATIONS' || stop === 'CONVERGENCE') {
|
|
const last = rounds.at(-1)!;
|
|
const snapshotPath = path.join(path.dirname(files.at(-1)!), `round-${last.round}.design.md`);
|
|
if (!fs.existsSync(snapshotPath)) throw new Error(`round ${last.round} lacks its captured design snapshot ${snapshotPath}`);
|
|
const fixes = options['--fixes'] ? parseSpecFixes(JSON.parse(fs.readFileSync(options['--fixes'], 'utf8'))) : null;
|
|
// The finalizer's own owned block is not a design change: compare the documents without it.
|
|
const withoutConcerns = (text) => { const block = extractOfficeHoursReviewBlock(text, 'concerns'); return block ? text.replace(block, '') : text; };
|
|
cap = verifySpecFixesAtCap(last, withoutConcerns(fs.readFileSync(snapshotPath, 'utf8')), withoutConcerns(fs.readFileSync(designPath, 'utf8')), fixes);
|
|
} else if (options['--fixes']) throw new Error('--fixes applies only when the review stopped at the cap with blocking findings');
|
|
const rendered = renderOfficeHoursReview(rounds, options['--unreviewed'], maxRounds, cap);
|
|
// Validate/read/render both destinations before publishing either. Each rename
|
|
// is atomic; any publication error fails the command and final verification.
|
|
const design = fs.readFileSync(designPath, 'utf8');
|
|
const report = reportPath ? fs.readFileSync(reportPath, 'utf8') : '';
|
|
const nextDesign = replaceOfficeHoursReviewBlock(design, 'concerns', rendered.concerns);
|
|
const nextReport = reportPath ? replaceOfficeHoursReviewBlock(report, 'report', rendered.report) : null;
|
|
if (nextDesign !== design) atomicWriteSync(designPath, nextDesign, { mode: fs.statSync(designPath).mode & 0o777 });
|
|
if (reportPath && nextReport !== null && nextReport !== report) atomicWriteSync(reportPath, nextReport, { mode: fs.statSync(reportPath).mode & 0o777 });
|
|
console.log(JSON.stringify({ stop: rendered.stop, metrics: rendered.metrics, report: rendered.report, cap: cap && { line: cap.line, decision: cap.decision, verified: cap.verified, unconfirmed: cap.unconfirmed } }));
|
|
}
|
|
|
|
try { main(process.argv.slice(2)); }
|
|
catch (error) {
|
|
console.error(`gstack-office-hours-review: ${error instanceof Error ? error.message : String(error)}`);
|
|
process.exitCode = 1;
|
|
}
|