diff --git a/bin/gstack-office-hours-review b/bin/gstack-office-hours-review index 1eed8c27f..4716e1d14 100755 --- a/bin/gstack-office-hours-review +++ b/bin/gstack-office-hours-review @@ -2,25 +2,26 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { atomicWriteSync } from '../lib/fs-atomic.ts'; -import { assessOfficeHoursReviews, loadOfficeHoursReviews, officeHoursDesignChanges, officeHoursSnapshotChanges, officeHoursVerdictReceipt, parseOfficeHoursVerdictReceipt, renderOfficeHoursReview, renderOfficeHoursReviewerPrompt, replaceOfficeHoursReviewBlock, verifyOfficeHoursCitations, verifyOfficeHoursVerdictReceipt } from '../lib/office-hours-review.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 ] '); + 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]; + 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 rounds = loadOfficeHoursReviews(files); + 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'); @@ -28,7 +29,7 @@ function main(args) { 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))); + console.log(JSON.stringify(assessOfficeHoursReviews(rounds, maxRounds))); return; } if (!options['--design']) throw new Error(`${command} requires --design`); @@ -43,7 +44,7 @@ function main(args) { 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]).rounds.at(-1); + 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)); @@ -61,13 +62,13 @@ function main(args) { const snapshotPath = path.join(outDir, `round-${round}.design.md`); const design = fs.readFileSync(designPath, 'utf8'); let changes; - if (round > 1 && assessOfficeHoursReviews(rounds).stop !== 'CONTINUE') throw new Error('cannot prepare another round after a terminal review'); + 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 }); + 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'); @@ -79,7 +80,7 @@ function main(args) { 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 --design', shellQuote(designPath), '--verdict', shellQuote(verdictPath), ...files.map(file => shellQuote(fs.realpathSync(file)))].join(' '); + 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; @@ -91,7 +92,7 @@ function main(args) { 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']); + 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'); diff --git a/lib/office-hours-review.ts b/lib/office-hours-review.ts index 5d75e783f..c9a7e3f41 100644 --- a/lib/office-hours-review.ts +++ b/lib/office-hours-review.ts @@ -114,11 +114,17 @@ export function validateOfficeHoursReview(value: unknown, previous?: OfficeHours const blockingIn = (review: OfficeHoursReview) => review.findings.filter(finding => finding.severity === 'blocking'); /** Minor findings are recorded, never a reason for another round. */ -function stopFor(review: OfficeHoursReview): OfficeHoursReviewStop { +/** Callers may lower the 3-round cap; reaching it stops at MAX_ITERATIONS. */ +export function officeHoursMaxRounds(value: unknown = 3): number { + const rounds = typeof value === 'string' && /^[0-9]+$/.test(value) ? Number(value) : value; + if (!Number.isInteger(rounds) || (rounds as number) < 1 || (rounds as number) > 3) fail('max rounds must be 1, 2, or 3'); + return rounds as number; +} +function stopFor(review: OfficeHoursReview, maxRounds = 3): OfficeHoursReviewStop { const blocking = new Set(blockingIn(review).map(finding => finding.id)); if (blocking.size === 0) return 'PASS'; if (review.prior.some(status => status.status === 'persisting' && blocking.has(status.current_id!))) return 'CONVERGENCE'; - return review.round === 3 ? 'MAX_ITERATIONS' : 'CONTINUE'; + return review.round >= maxRounds ? 'MAX_ITERATIONS' : 'CONTINUE'; } function metricsFor(rounds: readonly OfficeHoursReview[]): OfficeHoursReviewMetrics { const last = rounds.at(-1); @@ -133,17 +139,18 @@ function metricsFor(rounds: readonly OfficeHoursReview[]): OfficeHoursReviewMetr attempted_fix_rounds: Math.max(0, rounds.length - 1), }; } -export function assessOfficeHoursReviews(values: readonly unknown[]): { +export function assessOfficeHoursReviews(values: readonly unknown[], maxRounds = 3): { rounds: OfficeHoursReview[]; stop: OfficeHoursReviewStop; metrics: OfficeHoursReviewMetrics; } { - if (!Array.isArray(values) || values.length === 0 || values.length > 3) fail('supply 1 to 3 review rounds'); + officeHoursMaxRounds(maxRounds); + if (!Array.isArray(values) || values.length === 0 || values.length > maxRounds) fail(`supply 1 to ${maxRounds} review rounds`); const rounds: OfficeHoursReview[] = []; for (const value of values) { const previous = rounds.at(-1); - if (previous && stopFor(previous) !== 'CONTINUE') fail('another round follows a terminal review outcome'); + if (previous && stopFor(previous, maxRounds) !== 'CONTINUE') fail('another round follows a terminal review outcome'); rounds.push(validateOfficeHoursReview(value, previous)); } - return { rounds, stop: stopFor(rounds.at(-1)!), metrics: metricsFor(rounds) }; + return { rounds, stop: stopFor(rounds.at(-1)!, maxRounds), metrics: metricsFor(rounds) }; } const flat = (text: string) => text.replace(/\s+/g, ' ').trim(); /** The exact line diff between two reviewed design versions, with 2 lines of context per hunk. */ @@ -180,12 +187,12 @@ export function officeHoursSnapshotChanges(verdictPath: string, round: number): }; return officeHoursDesignChanges(snapshot(round - 1), snapshot(round)); } -export function loadOfficeHoursReviews(paths: readonly string[]): OfficeHoursReview[] { +export function loadOfficeHoursReviews(paths: readonly string[], maxRounds = 3): OfficeHoursReview[] { const values = paths.map(file => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (cause) { throw new Error(`Office-hours review: cannot read artifact ${file}: ${cause instanceof Error ? cause.message : String(cause)}`, { cause }); } }); - const rounds = values.length ? assessOfficeHoursReviews(values).rounds : []; + const rounds = values.length ? assessOfficeHoursReviews(values, maxRounds).rounds : []; for (const review of rounds.slice(1)) verifyOfficeHoursCitations(review, officeHoursSnapshotChanges(paths[review.round - 1], review.round).changed); return rounds; } @@ -212,8 +219,8 @@ export function verifyOfficeHoursVerdictReceipt(response: string, { round, verdi } /** The caller validates the complete history before supplying its last verdict. */ -export function renderOfficeHoursReviewerPrompt({ document, verdictPath, previous, changes }: { - document: string; verdictPath: string; previous?: OfficeHoursReview; changes?: string; +export function renderOfficeHoursReviewerPrompt({ document, verdictPath, previous, changes, maxRounds = 3 }: { + document: string; verdictPath: string; previous?: OfficeHoursReview; changes?: string; maxRounds?: number; }): string { for (const [label, value] of [['document', document], ['verdictPath', verdictPath]]) { nonempty(value, label); @@ -221,7 +228,7 @@ export function renderOfficeHoursReviewerPrompt({ document, verdictPath, previou if (/[\r\n]/.test(value)) fail(`${label} must fit on one line`); } if (previous && path.resolve(previous.document) !== path.resolve(document)) fail('review prompt targets a different document'); - if (previous && stopFor(previous) !== 'CONTINUE') fail('cannot prepare another round after a terminal review'); + if (previous && stopFor(previous, officeHoursMaxRounds(maxRounds)) !== 'CONTINUE') fail('cannot prepare another round after a terminal review'); if ((previous === undefined) !== (changes === undefined)) fail('later rounds, and only later rounds, need the captured design changes'); const round = (previous?.round ?? 0) + 1; const fence = '`'.repeat(Math.max(3, ...[...(changes ?? '').matchAll(/`+/g)].map(run => run[0].length + 1))); @@ -310,11 +317,11 @@ function renderFindings(findings: readonly OfficeHoursFinding[]): string { if (!findings.length) return 'No unresolved findings.'; return findings.map(finding => `### ${finding.id} — ${finding.dimension} (${finding.severity})\n\n**Problem**\n\n${quote(finding.problem)}\n\n**Remedy**\n\n${quote(finding.remedy)}`).join('\n\n'); } -export function renderOfficeHoursReview(values: readonly unknown[], unavailable?: string): { +export function renderOfficeHoursReview(values: readonly unknown[], unavailable?: string, maxRounds = 3): { concerns: string; report: string; metrics: OfficeHoursReviewMetrics; stop: OfficeHoursReviewStop | 'UNREVIEWED'; } { if (unavailable !== undefined) nonempty(unavailable, 'unavailable reason'); - const assessment = values.length ? assessOfficeHoursReviews(values) : null; + const assessment = values.length ? assessOfficeHoursReviews(values, maxRounds) : null; if (!assessment && unavailable === undefined) fail('no review ran; supply an explicit unavailable reason'); if (unavailable !== undefined && assessment && assessment.stop !== 'CONTINUE') fail('an unavailable attempt cannot follow a terminal review'); if (unavailable === undefined && assessment?.stop === 'CONTINUE') fail('review is not terminal; fix and re-review before finalizing'); diff --git a/office-hours/sections/design-and-handoff.md b/office-hours/sections/design-and-handoff.md index 47085713d..516097fe1 100644 --- a/office-hours/sections/design-and-handoff.md +++ b/office-hours/sections/design-and-handoff.md @@ -189,7 +189,9 @@ all preceding valid round files in order (omit them for round 1): ~/.claude/skills/gstack/bin/gstack-office-hours-review prepare --design "" --out-dir "" "" "" ``` -Omit absent arguments rather than passing placeholders. The helper chooses the next +Omit absent arguments rather than passing placeholders. Users get 3 rounds; only +when a caller sets a lower review round limit, add `--max-rounds ` with that +same value to every prepare, check, and finalize command. The helper chooses the next round and writes `round-N.prompt.md`. It includes the full finding schema, all five review dimensions (Completeness, Consistency, Clarity, Scope, Feasibility), the office-hours coaching contract, and the COMPLETE preceding JSON verdict. It also @@ -229,11 +231,12 @@ blocking findings require another round. Read its stop reason: persisting with a concrete prior/current finding pair and document evidence. Stop even if new findings appear. Shared topic labels or new refinements alone are insufficient. -- MAX_ITERATIONS: round 3 completed; stop. -- CONTINUE: fix every blocking finding in the design (you may batch minor fixes - with them), then return to Step 1 to prepare and dispatch the next review. Its - reviewer checks every prior finding and raises new blocking findings only for - problems your changes introduced or exposed. +- MAX_ITERATIONS: round 3 completed (or the caller's lower round limit); stop. +- CONTINUE: fix only the blocking findings in the design, then return to Step 1 + to prepare and dispatch the next review. Do not edit for minor findings + mid-loop: they stay recorded for the user, and new text only gives the next + diff more to review. Its reviewer checks every prior finding and raises new + blocking findings only for problems your changes introduced or exposed. On a stop, do not fix again or re-dispatch. Run the finalizer before approval: diff --git a/scripts/resolvers/spec-review.ts b/scripts/resolvers/spec-review.ts index 6a65260c2..ccd36ad58 100644 --- a/scripts/resolvers/spec-review.ts +++ b/scripts/resolvers/spec-review.ts @@ -47,7 +47,9 @@ all preceding valid round files in order (omit them for round 1): ~/.claude/skills/gstack/bin/gstack-office-hours-review prepare --design "" --out-dir "" "" "" \`\`\` -Omit absent arguments rather than passing placeholders. The helper chooses the next +Omit absent arguments rather than passing placeholders. Users get 3 rounds; only +when a caller sets a lower review round limit, add \`--max-rounds \` with that +same value to every prepare, check, and finalize command. The helper chooses the next round and writes \`round-N.prompt.md\`. It includes the full finding schema, all five review dimensions (Completeness, Consistency, Clarity, Scope, Feasibility), the office-hours coaching contract, and the COMPLETE preceding JSON verdict. It also @@ -87,11 +89,12 @@ blocking findings require another round. Read its stop reason: persisting with a concrete prior/current finding pair and document evidence. Stop even if new findings appear. Shared topic labels or new refinements alone are insufficient. -- MAX_ITERATIONS: round 3 completed; stop. -- CONTINUE: fix every blocking finding in the design (you may batch minor fixes - with them), then return to Step 1 to prepare and dispatch the next review. Its - reviewer checks every prior finding and raises new blocking findings only for - problems your changes introduced or exposed. +- MAX_ITERATIONS: round 3 completed (or the caller's lower round limit); stop. +- CONTINUE: fix only the blocking findings in the design, then return to Step 1 + to prepare and dispatch the next review. Do not edit for minor findings + mid-loop: they stay recorded for the user, and new text only gives the next + diff more to review. Its reviewer checks every prior finding and raises new + blocking findings only for problems your changes introduced or exposed. On a stop, do not fix again or re-dispatch. Run the finalizer before approval: diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index a15444a16..bceb515dd 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -1559,6 +1559,7 @@ describe('SPEC_REVIEW_LOOP resolver', () => { const step2 = output.slice(output.indexOf('**Step 2:'), output.indexOf('**Step 3:')); expectMentions(step2, [['before','dispatching','findings']], 'step2'); ordered(step2, ['gstack-office-hours-review check --receipt ""', 'mismatched receipt fails the check', '- PASS:', '- CONVERGENCE:', '- MAX_ITERATIONS: round 3', '- CONTINUE:', 'gstack-office-hours-review finalize --design']); + expectMentions(step2, [['fix only the blocking findings'], ['do not edit for minor findings']], 'step2'); expectMentions(step2, [['do not','re-dispatch','again']], 'step2'); expectMentions(step2, [['do not','generated','section']], 'step2'); expect(step2.slice(step2.indexOf('finalize --design'))).toMatch(/existing user approval/i); diff --git a/test/helpers/office-hours-completion.ts b/test/helpers/office-hours-completion.ts index fbe5a6089..fa78c03e6 100644 --- a/test/helpers/office-hours-completion.ts +++ b/test/helpers/office-hours-completion.ts @@ -221,6 +221,7 @@ export function validateOfficeHoursReviewArtifacts( evidence: OfficeHoursCompletionEvidence, artifacts: OfficeHoursFile[], snapshots: readonly OfficeHoursFile[] = [], + maxRounds = 3, ): OfficeHoursReviewEvidence | null { const fail = (message: string): never => { throw new Error(`Office-hours review artifacts: ${message}`); }; const reviewEvidence = validateOfficeHoursCompletion(evidence); @@ -234,7 +235,7 @@ export function validateOfficeHoursReviewArtifacts( const attempts = evidence.toolCalls.slice(firstWrite + 1).filter(call => ['Agent', 'Task'].includes(call.tool) && /\breview\b/i.test(String(call.input?.prompt ?? '')) && [designPath, relativeDesign].some(p => normalize(call.input?.prompt).includes(p))); - if (!attempts.length || attempts.length > 3) fail('expected one to three reviewer attempts'); + if (!attempts.length || attempts.length > maxRounds) fail(`expected one to ${maxRounds} reviewer attempts`); const rounds: OfficeHoursReview[] = []; const verdicts: string[] = []; let failedAttempt = false; @@ -297,7 +298,7 @@ export function validateOfficeHoursReviewArtifacts( fail('valid saved verdict has no matching reviewer receipt'); } } - const expected = renderOfficeHoursReview(rounds, unavailable); + const expected = renderOfficeHoursReview(rounds, unavailable, maxRounds); const expectedReport = sectionBody(expected.report, ['spec review'], true); const expectedConcerns = sectionBody(expected.concerns, ['reviewer concerns'], true); if (extractOfficeHoursReviewBlock(evidence.output, 'report') !== expected.report @@ -317,6 +318,7 @@ export function validateOfficeHoursReviewerHandoffs( evidence: OfficeHoursCompletionEvidence, artifacts: OfficeHoursFile[], snapshots: readonly OfficeHoursFile[] = [], + maxRounds = 3, ): void { const fail = (message: string): never => { throw new Error(`Office-hours reviewer handoff: ${message}`); }; const calls = evidence.toolCalls; @@ -353,7 +355,7 @@ export function validateOfficeHoursReviewerHandoffs( || path.basename(verdicts[0]) !== `round-${index + 1}.json`) fail(`round ${index + 1} lacks its assigned verdict path`); const verdictPath = verdicts[0]; const promptPath = verdictPath.replace(/\.json$/, '.prompt.md'); - const expected = renderOfficeHoursReviewerPrompt({ document: evidence.designPath, verdictPath, previous, + const expected = renderOfficeHoursReviewerPrompt({ document: evidence.designPath, verdictPath, previous, maxRounds, changes: index > 0 ? snapshotChanges(snapshots, verdictPath, index + 1).diff : undefined }); let delivered = prompt.includes(expected.trimEnd()); let unavailableBeforeRead = false; diff --git a/test/office-hours-review.test.ts b/test/office-hours-review.test.ts index bbd9e8112..b70bc3bcd 100644 --- a/test/office-hours-review.test.ts +++ b/test/office-hours-review.test.ts @@ -228,6 +228,52 @@ describe('office-hours delta re-review', () => { }); }); +describe('office-hours caller round limit', () => { + test('--max-rounds lowers the cap; reaching it stops at MAX_ITERATIONS with the usual dispositions', () => { + const [first] = recurrence(); + const second = review(2, 1); + second.prior = first.findings.map(finding => ({ id: finding.id, status: 'resolved' as const, evidence: 'The design now defines it.', current_id: null })); + second.findings[0].changed_text = 'Rosters are now saved nightly to disk.'; + expect(assessOfficeHoursReviews([first, second]).stop).toBe('CONTINUE'); + expect(assessOfficeHoursReviews([first, second], 2).stop).toBe('MAX_ITERATIONS'); + expect(renderOfficeHoursReview([first, second], undefined, 2).report).toContain('Disposition: CONCERNS_RECORDED\n\nStop: MAX_ITERATIONS'); + expect(() => renderOfficeHoursReview([first, second])).toThrow('not terminal'); + expect(assessOfficeHoursReviews([review(1, 1)], 1).stop).toBe('MAX_ITERATIONS'); + expect(() => assessOfficeHoursReviews([first, second, review(3)], 2)).toThrow('supply 1 to 2 review rounds'); + expect(() => renderOfficeHoursReviewerPrompt({ document: '/tmp/design.md', verdictPath: '/tmp/round-3.json', previous: second, changes: '(no changes)', maxRounds: 2 })).toThrow('terminal'); + for (const invalid of [0, 4, 2.5, '2x']) expect(() => assessOfficeHoursReviews([first], invalid as number)).toThrow('max rounds must be 1, 2, or 3'); + }); + + test('the helper applies the same limit to prepare, seal, check and finalize', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'office-rounds-')); + try { + const run = (...args: string[]) => Bun.spawnSync([process.execPath, cli, ...args], { cwd: dir, stdout: 'pipe', stderr: 'pipe', timeout: 5000 }); + const design = path.join(dir, 'design.md'), outDir = path.join(dir, 'reviews'); + fs.writeFileSync(design, '# Design\n\n## Approach\nRosters stay in memory.\n'); + fs.mkdirSync(outDir); + const first = JSON.parse(String(run('prepare', '--max-rounds', '2', '--design', design, '--out-dir', outDir).stdout)); + expect(first.dispatch).toContain("seal --max-rounds 2 --design '"); + fs.writeFileSync(first.verdictPath, JSON.stringify({ ...review(1, 1), document: fs.realpathSync(design) })); + fs.appendFileSync(design, 'Rosters are now saved nightly to disk.\n'); + const second = JSON.parse(String(run('prepare', '--max-rounds', '2', '--design', design, '--out-dir', outDir, first.verdictPath).stdout)); + const round2 = { ...review(2, 1), document: fs.realpathSync(design), prior: [{ id: 'R1-1', status: 'resolved', evidence: 'The Approach now defines it.', current_id: null }] }; + round2.findings[0].changed_text = 'saved nightly to disk'; + fs.writeFileSync(second.verdictPath, JSON.stringify(round2)); + expect(run('seal', '--max-rounds', '2', '--design', design, '--verdict', second.verdictPath, first.verdictPath).exitCode).toBe(0); + const receipt = officeHoursVerdictReceipt(2, second.verdictPath, fs.readFileSync(second.verdictPath)); + const files = [first.verdictPath, second.verdictPath]; + expect(JSON.parse(String(run('check', '--max-rounds', '2', '--receipt', receipt, ...files).stdout)).stop).toBe('MAX_ITERATIONS'); + expect(JSON.parse(String(run('check', '--receipt', receipt, ...files).stdout)).stop).toBe('CONTINUE'); + expect(String(run('prepare', '--max-rounds', '2', '--design', design, '--out-dir', outDir, ...files).stderr)).toContain('terminal'); + expect(String(run('finalize', '--design', design, ...files).stderr)).toContain('not terminal'); + const finalized = run('finalize', '--max-rounds', '2', '--design', design, ...files); + expect(finalized.exitCode, String(finalized.stderr)).toBe(0); + expect(JSON.parse(String(finalized.stdout)).stop).toBe('MAX_ITERATIONS'); + expect(run('check', '--max-rounds', '4', '--receipt', receipt, ...files).exitCode).toBe(1); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); +}); + describe('office-hours lifecycle and deterministic preservation', () => { test('convergence precedes further editing even when another finding is new', () => { const rounds = recurrence(); diff --git a/test/skill-e2e-office-hours-section-loading.test.ts b/test/skill-e2e-office-hours-section-loading.test.ts index d392587db..dcdfc1aec 100644 --- a/test/skill-e2e-office-hours-section-loading.test.ts +++ b/test/skill-e2e-office-hours-section-loading.test.ts @@ -51,10 +51,12 @@ describeMarathon('/office-hours full section-loading workflow (marathon)', () => } const formatter = path.join(planDir, 'bin/gstack-office-hours-review'); fs.chmodSync(formatter, 0o755); + // Two rounds keep the live run inside its budget: round 1 (full) and round 2 (delta) run here; round 3 and CONVERGENCE are covered by free validator tests. + const reviewRounds = 2; const capture = await captureSectionReads({ planDir, skillName: guard.skill, scenario: guard.scenario, artifactCommands: `${PILOT_SCOPE} -Use ${formatter} for prepare/check/finalize; Bash is only for those commands and creating the local review directory. Use Read for skills, sections, reviewer prompts and designs, never Bash. Reviewers must save verdicts with Write as the prepared contract requires. Use targeted Edit for local design revisions, preserving every finding and remedy. Do not inspect formatter source unless its command fails. Keep all artifacts inside this fixture. +Use ${formatter} for prepare/check/finalize. As the caller, I set a ${reviewRounds}-round spec-review limit for this run: pass --max-rounds ${reviewRounds} to every formatter prepare, check and finalize command. Bash is only for those commands and creating the local review directory. Use Read for skills, sections, reviewer prompts and designs, never Bash. Reviewers must save verdicts with Write as the prepared contract requires. Use targeted Edit for local design revisions, preserving every finding and remedy. Do not inspect formatter source unless its command fails. Keep all artifacts inside this fixture. Delivery throughout this non-interactive run: keep chat to brief progress and actual decision acknowledgements. Write the complete diagnostic, premise challenge, alternatives, independent opinion and rationale into the design instead of first publishing a separate walkthrough in chat. After design approval, write the full relationship closing and handoff directly into REPORT.md. These file writes deliver the required content; do not narrate it in full and then transcribe it again. Completion delivery, after the full workflow and design approval: 1. Compose REPORT.md as a completion record: summarize each phase's outcome and actual decisions with their rationale, and link the approved design and saved review evidence. The design retains the detailed diagnostic, alternatives, and independent opinion; do not replay those as a second transcript. Include the full actual Assignment, coaching/relationship closing, approval outcome, and Handoff, including the user's declined downstream launch. This changes delivery only; complete every required phase and preserve all findings. @@ -81,11 +83,11 @@ Completion delivery, after the full workflow and design approval: const reviewEvidence = validateOfficeHoursReviewArtifacts({ ...capture, designPath, designContent: fs.existsSync(designPath) ? fs.readFileSync(designPath, 'utf-8') : null, - }, artifacts, snapshots); + }, artifacts, snapshots, reviewRounds); validateOfficeHoursReviewerHandoffs({ ...capture, designPath, designContent: fs.existsSync(designPath) ? fs.readFileSync(designPath, 'utf-8') : null, - }, artifacts, snapshots); + }, artifacts, snapshots, reviewRounds); const missing = guard.requiredReads.filter(section => !capture.readSections.has(section)); expect({ reportProduced: capture.reportProduced, read: [...capture.readSections], missing }).toEqual({ reportProduced: true, read: expect.any(Array), missing: [],