#!/usr/bin/env bun import * as fs from 'node:fs'; import * as path from 'node:path'; import { atomicWriteSync } from '../lib/fs-atomic.ts'; import { assessOfficeHoursReviews, loadOfficeHoursReviews, officeHoursDesignChanges, officeHoursMaxRounds, officeHoursSnapshotChanges, officeHoursVerdictReceipt, parseOfficeHoursVerdictReceipt, renderOfficeHoursReview, renderOfficeHoursReviewerPrompt, replaceOfficeHoursReviewBlock, verifyOfficeHoursCitations, verifyOfficeHoursVerdictReceipt } from '../lib/office-hours-review.ts'; const shellQuote = value => `'${value.replaceAll("'", `'\\''`)}'`; function main(args) { const command = args.shift(); if (!['check', 'prepare', 'seal', 'finalize'].includes(command)) throw new Error('usage: gstack-office-hours-review check --receipt | prepare --design --out-dir [round.json...] | seal --design --verdict [preceding round.json...] | finalize --design [--report ] [--unreviewed ] (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'] }[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}\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'); const rendered = renderOfficeHoursReview(rounds, options['--unreviewed'], maxRounds); // 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 })); } try { main(process.argv.slice(2)); } catch (error) { console.error(`gstack-office-hours-review: ${error instanceof Error ? error.message : String(error)}`); process.exitCode = 1; }