fix(office-hours): only blocking findings send a design back for another review round

Review findings carry severity blocking|minor (verdict schema v2). PASS when
no blocking findings remain; minor findings are recorded in Reviewer
Concerns and the Spec Review table, never a reason for another round.
CONVERGENCE needs a persisting blocking prior. A persisting or unverified
blocking prior cannot be relabeled minor. The 3-round cap is unchanged.
This commit is contained in:
garrytan committed 2026-10-05 19:23:49 +00:00
1 parent 209a61dcd3
commit 69a71f5da2
6 files changed
+132 -37

No files matched your search

+35 -12
View File
@@ -5,9 +5,12 @@ import * as path from 'node:path';
export const OFFICE_HOURS_DIMENSIONS = ['completeness', 'consistency', 'clarity', 'scope', 'feasibility'] as const;
export type OfficeHoursDimension = typeof OFFICE_HOURS_DIMENSIONS[number];
export const OFFICE_HOURS_SEVERITIES = ['blocking', 'minor'] as const;
export type OfficeHoursSeverity = typeof OFFICE_HOURS_SEVERITIES[number];
export interface OfficeHoursFinding {
id: string;
dimension: OfficeHoursDimension;
severity: OfficeHoursSeverity;
problem: string;
remedy: string;
}
@@ -18,7 +21,7 @@ export interface OfficeHoursPriorStatus {
current_id: string | null;
}
export interface OfficeHoursReview {
version: 1;
version: 2;
round: number;
document: string;
quality_score: number;
@@ -32,6 +35,8 @@ export interface OfficeHoursReviewMetrics {
issues_found: number;
issues_fixed: number;
remaining: number;
remaining_blocking: number;
remaining_minor: number;
quality_score: number | null;
attempted_fix_rounds: number;
}
@@ -49,7 +54,7 @@ function nonempty(value: unknown, label: string): string {
export function validateOfficeHoursReview(value: unknown, previous?: OfficeHoursReview): OfficeHoursReview {
const review = object(value, ['version', 'round', 'document', 'quality_score', 'dimensions', 'findings', 'prior'], 'artifact');
if (review.version !== 1) fail('unsupported version');
if (review.version !== 2) fail('unsupported version; expected 2');
if (!Number.isInteger(review.round) || review.round < 1 || review.round > 3
|| review.round !== (previous?.round ?? 0) + 1) fail('rounds must be contiguous, starting at 1, with at most 3 rounds');
nonempty(review.document, 'document');
@@ -61,11 +66,12 @@ export function validateOfficeHoursReview(value: unknown, previous?: OfficeHours
if (!Array.isArray(review.findings) || !Array.isArray(review.prior)) fail('findings and prior must be arrays');
const ids = new Set<string>();
for (const raw of review.findings) {
const finding = object(raw, ['id', 'dimension', 'problem', 'remedy'], 'finding');
const finding = object(raw, ['id', 'dimension', 'severity', 'problem', 'remedy'], 'finding');
if (typeof finding.id !== 'string' || !new RegExp(`^R${review.round}-[1-9][0-9]*$`).test(finding.id)
|| ids.has(finding.id)) fail('finding ids must be unique R<round>-<positive integer> identifiers');
ids.add(finding.id);
if (!OFFICE_HOURS_DIMENSIONS.includes(finding.dimension)) fail(`invalid dimension for ${finding.id}`);
if (!OFFICE_HOURS_SEVERITIES.includes(finding.severity)) fail(`${finding.id} severity must be blocking or minor`);
nonempty(finding.problem, `${finding.id} problem`);
nonempty(finding.remedy, `${finding.id} remedy`);
}
@@ -74,6 +80,8 @@ export function validateOfficeHoursReview(value: unknown, previous?: OfficeHours
if (dimensions[dimension] !== expected) fail(`${dimension} must be ${expected} for its canonical findings`);
}
const previousIds = new Set(previous?.findings.map(finding => finding.id) ?? []);
const blocking = new Set([...(previous?.findings ?? []), ...review.findings as OfficeHoursFinding[]]
.filter(finding => finding.severity === 'blocking').map(finding => finding.id));
const covered = new Set<string>();
const currentLinks = new Set<string>();
for (const raw of review.prior) {
@@ -89,15 +97,19 @@ export function validateOfficeHoursReview(value: unknown, previous?: OfficeHours
} else {
if (currentLinks.has(status.current_id)) fail('distinct prior findings cannot merge into one current finding');
currentLinks.add(status.current_id);
if (blocking.has(status.id) && !blocking.has(status.current_id)) fail(`${status.status} blocking ${status.id} cannot be downgraded to minor`);
}
}
if (covered.size !== previousIds.size) fail('prior must cover each preceding finding exactly once');
return review as OfficeHoursReview;
}
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 {
if (review.findings.length === 0) return 'PASS';
if (review.prior.some(status => status.status === 'persisting')) return 'CONVERGENCE';
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';
}
function metricsFor(rounds: readonly OfficeHoursReview[]): OfficeHoursReviewMetrics {
@@ -107,6 +119,8 @@ function metricsFor(rounds: readonly OfficeHoursReview[]): OfficeHoursReviewMetr
issues_found: rounds.reduce((sum, review) => sum + review.findings.length, 0),
issues_fixed: rounds.reduce((sum, review) => sum + review.prior.filter(status => status.status === 'resolved').length, 0),
remaining: last?.findings.length ?? 0,
remaining_blocking: last ? blockingIn(last).length : 0,
remaining_minor: last ? last.findings.length - blockingIn(last).length : 0,
quality_score: last?.quality_score ?? null,
attempted_fix_rounds: Math.max(0, rounds.length - 1),
};
@@ -165,9 +179,9 @@ export function renderOfficeHoursReviewerPrompt({ document, verdictPath, previou
if (previous && stopFor(previous) !== 'CONTINUE') fail('cannot prepare another round after a terminal review');
const round = (previous?.round ?? 0) + 1;
const example = {
version: 1, round, document, quality_score: 7,
version: 2, round, document, quality_score: 7,
dimensions: { completeness: 'PASS', consistency: 'PASS', clarity: 'ISSUES', scope: 'PASS', feasibility: 'PASS' },
findings: [{ id: `R${round}-1`, dimension: 'clarity', problem: "The fallback's user-visible behavior is unspecified.",
findings: [{ id: `R${round}-1`, dimension: 'clarity', severity: 'blocking', problem: "The fallback's user-visible behavior is unspecified.",
remedy: 'Choose and document whether the fallback warns the user or is intentionally silent.' }],
prior: [],
};
@@ -182,11 +196,19 @@ Return only that printed \`OFFICE_HOURS_VERDICT round=${round} sha256=<hash> pat
The saved JSON is your sole findings inventory: include every unresolved problem and necessary remedy, including minor findings that a short conclusion might omit.
Use one finding per distinct obligation. An exact duplicate shares a finding; a shared component does not combine separate decisions, behavior, or effort.
## Severity
Give every finding a severity. Only blocking findings send the design back for another round; minor findings are recorded for the user and never require another round on their own.
- **blocking**: a contradiction; a safety or correctness risk; an unsupported claim the recommendation depends on; missing behavior the committed approach needs; or a persisting blocking prior obligation. Example: the design promises the roster is never stored, yet its sync step saves it nightly.
- **minor**: clarity, wording, or polish that does not change a decision or behavior. Example: the Recommended Approach repeats the problem statement's wording and could be shorter.
When unsure whether a gap changes a decision or behavior, it is blocking.
This is an /office-hours design and coaching document, produced before engineering planning. The startup-mode 'The Assignment' and both modes' 'What I noticed about how you think' sections are intentional: evaluate their evidence and usefulness; do not remove them merely because they are coaching content. Unknown customer facts may remain explicit Open Questions or assignments; do not invent answers.
Still flag unsupported claims, contradictions, safety/correctness risks, and missing behavior needed by the approach the document actually commits to. Labeling a contradiction or a required behavior an open question does not resolve it.
On re-review, classify EVERY preceding finding as resolved, persisting, or unverified. Cite the specific document decision/behavior proving the status or the missing evidence. Absence from the new findings list is not confirmation.
A new refinement of an accepted fix is new unless the same specific original obligation demonstrably remains unmet. For persisting/unverified issues, include that unmet obligation in the current findings and reference its current ID. Distinct prior obligations must retain distinct current findings.
Classify minor and blocking preceding findings alike. A persisting or unverified blocking finding stays blocking until resolved; never relabel it minor.
Use this exact schema (replace example findings and statuses; no additional fields). The round and document below are assigned values:
@@ -194,7 +216,7 @@ Use this exact schema (replace example findings and statuses; no additional fiel
${JSON.stringify(example, null, 2)}
\`\`\`
Finding IDs are R${round}-<number>; dimension names are the five lowercase keys above. Supply a quality score from 1 to 10. A dimension is ISSUES exactly when it has findings; otherwise PASS.
Finding IDs are R${round}-<number>; dimension names are the five lowercase keys above; severity is blocking or minor. Supply a quality score from 1 to 10. A dimension is ISSUES exactly when it has findings; otherwise PASS.
Round 1 has an empty prior array. In later rounds, replace the example's empty prior array with one status for EVERY finding in the complete preceding verdict below:
{"id":"<preceding finding ID>","status":"resolved","evidence":"Specific document decision proving resolution","current_id":null}
or {"id":"<preceding finding ID>","status":"persisting","evidence":"Same original obligation still unmet at this document passage","current_id":"R${round}-1"}.
@@ -223,7 +245,7 @@ const marker = (kind: 'concerns' | 'report', edge: 'start' | 'end') => `<!-- gst
const sectionName = (kind: 'concerns' | 'report') => kind === 'concerns' ? 'reviewer concerns' : 'spec review';
function renderFindings(findings: readonly OfficeHoursFinding[]): string {
if (!findings.length) return 'No unresolved findings.';
return findings.map(finding => `### ${finding.id} — ${finding.dimension}\n\n**Problem**\n\n${quote(finding.problem)}\n\n**Remedy**\n\n${quote(finding.remedy)}`).join('\n\n');
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): {
concerns: string; report: string; metrics: OfficeHoursReviewMetrics; stop: OfficeHoursReviewStop | 'UNREVIEWED';
@@ -239,12 +261,13 @@ export function renderOfficeHoursReview(values: readonly unknown[], unavailable?
const disposition = stop === 'UNREVIEWED' ? 'UNREVIEWED' : stop === 'PASS' ? 'COMPLETED' : 'CONCERNS_RECORDED';
const findings = renderFindings(rounds.at(-1)?.findings ?? []);
const status = `Disposition: ${disposition}\n\nStop: ${stop}`
+ (stop === 'PASS' && metrics.remaining_minor ? `\n\nNo blocking findings remain. The ${metrics.remaining_minor} open minor finding(s) below are recorded for the user, not fixed.` : '')
+ (unavailable === undefined ? '' : `\n\nUnavailable reason: ${JSON.stringify(unavailable)}\n\nFindings below are retained from the last completed review; the current document remains unreviewed.`);
const table = ['| Round | Findings | Prior findings confirmed resolved | Quality score |', '|---|---:|---:|---:|',
...rounds.map(review => `| ${review.round} | ${review.findings.length} | ${review.prior.filter(item => item.status === 'resolved').length} | ${review.quality_score}/10 |`)].join('\n');
const table = ['| Round | Blocking findings | Minor findings | Prior findings confirmed resolved | Quality score |', '|---|---:|---:|---:|---:|',
...rounds.map(review => `| ${review.round} | ${blockingIn(review).length} | ${review.findings.length - blockingIn(review).length} | ${review.prior.filter(item => item.status === 'resolved').length} | ${review.quality_score}/10 |`)].join('\n');
const totals = `Findings reported across rounds: ${metrics.issues_found} (sum of round inventories; recurrences count again).\n\n`
+ `Confirmed resolutions: ${metrics.issues_fixed} (sum of explicit later-reviewer resolved statuses).\n\n`
+ `Unresolved findings in the last completed inventory: ${metrics.remaining}.\n\n`
+ `Unresolved findings in the last completed inventory: ${metrics.remaining} (${metrics.remaining_blocking} blocking, ${metrics.remaining_minor} minor).\n\n`
+ `Completed fix-and-review transitions: ${metrics.attempted_fix_rounds} (rounds, not edits).`;
const persistence = rounds.at(-1)?.prior.filter(item => item.status !== 'resolved') ?? [];
const links = persistence.length ? '\n\n### Prior finding evidence\n\n' + persistence.map(item =>
+11 -7
View File
@@ -217,13 +217,17 @@ receipt and list every completed round in order:
A missing, malformed, or mismatched receipt fails the check: that attempt is a failed review.
Omit absent arguments rather than passing placeholders.
**Convergence guard and stopping rules:** Read its stop reason:
- PASS: no unresolved findings; proceed to Step 3.
- CONVERGENCE: the reviewer explicitly marked a prior obligation 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.
**Convergence guard and stopping rules:** Each finding is blocking or minor. Only
blocking findings require another round. Read its stop reason:
- PASS: no blocking findings remain. Any minor findings are recorded, not fixed,
and never justify another round; proceed to Step 3.
- CONVERGENCE: the reviewer explicitly marked a blocking prior obligation
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 the listed findings in the design, then return to Step 1 to prepare and dispatch the next review.
- 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.
On a stop, do not fix again or re-dispatch. Run the finalizer before approval:
@@ -265,7 +269,7 @@ mkdir -p "$GSTACK_STATE_ROOT/analytics"
echo '{"skill":"office-hours","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","iterations":ITERATIONS,"issues_found":FOUND,"issues_fixed":FIXED,"remaining":REMAINING,"quality_score":SCORE}' >> "$GSTACK_STATE_ROOT/analytics/spec-review.jsonl" 2>/dev/null || true
```
Use iterations, issues_found, issues_fixed, remaining, and quality_score from the
helper. FOUND counts finding observations across rounds; FIXED counts only
helper; its remaining_blocking and remaining_minor split the remaining count. FOUND counts finding observations across rounds; FIXED counts only
reviewer-confirmed resolutions. An unavailable score is null, never invented.
---
+11 -7
View File
@@ -75,13 +75,17 @@ receipt and list every completed round in order:
A missing, malformed, or mismatched receipt fails the check: that attempt is a failed review.
Omit absent arguments rather than passing placeholders.
**Convergence guard and stopping rules:** Read its stop reason:
- PASS: no unresolved findings; proceed to Step 3.
- CONVERGENCE: the reviewer explicitly marked a prior obligation 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.
**Convergence guard and stopping rules:** Each finding is blocking or minor. Only
blocking findings require another round. Read its stop reason:
- PASS: no blocking findings remain. Any minor findings are recorded, not fixed,
and never justify another round; proceed to Step 3.
- CONVERGENCE: the reviewer explicitly marked a blocking prior obligation
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 the listed findings in the design, then return to Step 1 to prepare and dispatch the next review.
- 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.
On a stop, do not fix again or re-dispatch. Run the finalizer before approval:
@@ -123,7 +127,7 @@ mkdir -p "$GSTACK_STATE_ROOT/analytics"
echo '{"skill":"office-hours","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","iterations":ITERATIONS,"issues_found":FOUND,"issues_fixed":FIXED,"remaining":REMAINING,"quality_score":SCORE}' >> "$GSTACK_STATE_ROOT/analytics/spec-review.jsonl" 2>/dev/null || true
\`\`\`
Use iterations, issues_found, issues_fixed, remaining, and quality_score from the
helper. FOUND counts finding observations across rounds; FIXED counts only
helper; its remaining_blocking and remaining_minor split the remaining count. FOUND counts finding observations across rounds; FIXED counts only
reviewer-confirmed resolutions. An unavailable score is null, never invented.`;
}
+1 -1
View File
@@ -1550,7 +1550,7 @@ describe('SPEC_REVIEW_LOOP resolver', () => {
expect(prompt).toMatch(/classify every preceding finding/i);
ordered(prompt, ['1. **Completeness**', '2. **Consistency**', '3. **Clarity**', '4. **Scope**', '5. **Feasibility**']);
expect(prompt).toMatch(/distinguished from committed behavior/i);
expect(prompt).toContain('"version": 1');
expect(prompt).toContain('"version": 2');
expect(prompt).toContain('"prior": []');
});
+23 -4
View File
@@ -708,9 +708,9 @@ describe('office-hours mechanical review evidence', () => {
] as const;
function structured() {
const round = (n: number): OfficeHoursReview => ({
version: 1, round: n, document: designPath, quality_score: 7,
version: 2, round: n, document: designPath, quality_score: 7,
dimensions: { completeness: 'ISSUES', consistency: 'ISSUES', clarity: 'ISSUES', scope: 'PASS', feasibility: 'PASS' },
findings: problems.map(([dimension, problem, remedy], i) => ({ id: `R${n}-${i + 1}`, dimension, problem, remedy })),
findings: problems.map(([dimension, problem, remedy], i) => ({ id: `R${n}-${i + 1}`, dimension, severity: 'blocking' as const, problem, remedy })),
prior: n === 1 ? [] : problems.map((_, i) => ({ id: `R1-${i + 1}`, status: 'persisting',
evidence: `The Recommended Approach still omits the original obligation: ${problems[i][1]}`, current_id: `R2-${i + 1}` })),
});
@@ -842,6 +842,25 @@ describe('office-hours mechanical review evidence', () => {
expect(() => validateOfficeHoursCompletion(evidence)).toThrow('next-skill recommendation');
});
test('a minor-only first round completes as PASS with every minor item recorded', () => {
const { evidence, artifacts, rounds } = structured();
const minor: OfficeHoursReview = { ...rounds[0], findings: rounds[0].findings.map(finding => ({ ...finding, severity: 'minor' as const })) };
const rendered = renderOfficeHoursReview([minor]);
expect(rendered.stop).toBe('PASS');
evidence.designContent = evidence.designContent!.replace(/<!-- gstack:office-hours:concerns:start -->[\s\S]*<!-- gstack:office-hours:concerns:end -->/, rendered.concerns);
evidence.output = evidence.output.replace(/<!-- gstack:office-hours:report:start -->[\s\S]*<!-- gstack:office-hours:report:end -->/, rendered.report);
const saved = [{ ...artifacts[0], content: JSON.stringify(minor) }];
evidence.toolCalls = evidence.toolCalls.slice(0, 4);
evidence.toolCalls[2].output = officeHoursVerdictReceipt(1, saved[0].path, saved[0].content);
evidence.toolCalls[3].input!.content = saved[0].content;
expect(() => validateOfficeHoursReviewArtifacts(evidence, saved)).not.toThrow();
expect(evidence.designContent).toContain('Disposition: COMPLETED');
for (const [, problem, remedy] of problems) {
expect(evidence.designContent).toContain(problem);
expect(evidence.designContent).toContain(remedy);
}
});
test('accepts a complete, reviewer-owned six-finding report without a judge', () => {
const { evidence, artifacts } = structured();
expect(() => validateOfficeHoursReviewArtifacts(evidence, artifacts)).not.toThrow();
@@ -930,8 +949,8 @@ Next: /plan-eng-review after observing the workflow. The user declined launching
test('rejects a five-finding count for the actual six', () => {
const { evidence, artifacts } = structured();
evidence.output = evidence.output.replace('Unresolved findings in the last completed inventory: 6.',
'Unresolved findings in the last completed inventory: 5.');
evidence.output = evidence.output.replace('Unresolved findings in the last completed inventory: 6 (6 blocking, 0 minor).',
'Unresolved findings in the last completed inventory: 5 (5 blocking, 0 minor).');
expect(() => validateOfficeHoursReviewArtifacts(evidence, artifacts)).toThrow('computed metrics');
});
+51 -6
View File
@@ -10,12 +10,12 @@ import {
import { expectMentions } from './helpers/prompt-structure';
const cli = path.resolve(import.meta.dir, '../bin/gstack-office-hours-review');
function review(round = 1, count = 1): OfficeHoursReview {
function review(round = 1, count = 1, severity: 'blocking' | 'minor' = 'blocking'): OfficeHoursReview {
return {
version: 1, round, document: '/tmp/design.md', quality_score: 7,
version: 2, round, document: '/tmp/design.md', quality_score: 7,
dimensions: { completeness: count ? 'ISSUES' : 'PASS', consistency: 'PASS', clarity: 'PASS', scope: 'PASS', feasibility: 'PASS' },
findings: Array.from({ length: count }, (_, index) => ({
id: `R${round}-${index + 1}`, dimension: 'completeness',
id: `R${round}-${index + 1}`, dimension: 'completeness', severity,
problem: `Problem ${index + 1} remains undefined.`, remedy: `Define behavior ${index + 1}.`,
})), prior: [],
};
@@ -41,7 +41,9 @@ describe('office-hours canonical review schema', () => {
});
test.each([
['version', (item: any) => { item.version = 2; }],
['version 1', (item: any) => { item.version = 1; }],
['missing severity', (item: any) => { delete item.findings[0].severity; }],
['unknown severity', (item: any) => { item.findings[0].severity = 'major'; }],
['unexpected fields', (item: any) => { item.summary = 'PASS'; }],
['missing dimension', (item: any) => { delete item.dimensions.scope; }],
['false dimension pass', (item: any) => { item.dimensions.completeness = 'PASS'; }],
@@ -74,6 +76,8 @@ describe('office-hours canonical review schema', () => {
['blank resolution evidence', (item: any) => { item.prior[1].evidence = ''; }],
['different document', (item: any) => { item.document = '/tmp/other.md'; }],
['merged unresolved prior obligations', (item: any) => { item.prior[1].status = 'unverified'; item.prior[1].current_id = 'R2-1'; }],
['persisting blocking finding downgraded to minor', (item: any) => { item.findings[0].severity = 'minor'; }],
['unverified blocking finding downgraded to minor', (item: any) => { item.prior[0].status = 'unverified'; item.findings[0].severity = 'minor'; }],
])('rejects %s', (_name, change) => {
const [first, second] = recurrence();
change(second);
@@ -114,14 +118,55 @@ describe('office-hours prepared reviewer input', () => {
});
});
describe('office-hours severity-based convergence', () => {
test('minor findings alone PASS and are recorded, never fixed by another round', () => {
const result = renderOfficeHoursReview([review(1, 2, 'minor')]);
expect(result.stop).toBe('PASS');
expect(result.metrics).toMatchObject({ iterations: 1, remaining: 2, remaining_blocking: 0, remaining_minor: 2 });
for (const output of [result.concerns, result.report]) {
expect(output).toContain('Disposition: COMPLETED');
expect(output).toContain('### R1-1 — completeness (minor)');
expect(output).toContain('Define behavior 2.');
expect(output).toContain('2 open minor finding(s) below are recorded for the user, not fixed');
}
expect(result.report).toContain('| 1 | 0 | 2 | 0 | 7/10 |');
expect(() => renderOfficeHoursReviewerPrompt({ document: '/tmp/design.md', verdictPath: '/tmp/round-2.json', previous: review(1, 1, 'minor') })).toThrow('terminal');
});
test('blocking findings CONTINUE until round 3 even beside minor ones', () => {
const first = review(1, 1);
first.findings.push({ ...first.findings[0], id: 'R1-2', severity: 'minor' });
expect(assessOfficeHoursReviews([first]).stop).toBe('CONTINUE');
expect(renderOfficeHoursReview([first], 'timeout').report).toContain('| 1 | 1 | 1 | 0 | 7/10 |');
});
test('a persisting blocking prior converges; a persisting minor prior does not', () => {
expect(assessOfficeHoursReviews(recurrence()).stop).toBe('CONVERGENCE');
const [first, second] = recurrence();
first.findings[0].severity = 'minor';
second.findings[0].severity = 'minor';
expect(assessOfficeHoursReviews([first, second]).stop).toBe('PASS');
second.findings[0].severity = 'blocking';
expect(assessOfficeHoursReviews([first, second]).stop).toBe('CONVERGENCE');
});
test('the reviewer prompt defines both severities with an example of each', () => {
const prompt = renderOfficeHoursReviewerPrompt({ document: '/tmp/design.md', verdictPath: '/tmp/round-1.json' });
expect(prompt).toContain('"severity": "blocking"');
expect(prompt).toMatch(/- \*\*blocking\*\*: a contradiction;[^\n]+Example:/);
expect(prompt).toMatch(/- \*\*minor\*\*: clarity, wording, or polish[^\n]+Example:/);
expect(prompt).toMatch(/never relabel it minor/);
});
});
describe('office-hours lifecycle and deterministic preservation', () => {
test('convergence precedes further editing even when another finding is new', () => {
const rounds = recurrence();
rounds[1].findings.push({ id: 'R2-2', dimension: 'clarity', problem: 'Encoding fallback has no user-visible policy.', remedy: 'Specify whether the latin-1 fallback emits a warning or is intentionally silent.' });
rounds[1].findings.push({ id: 'R2-2', dimension: 'clarity', severity: 'minor', problem: 'Encoding fallback has no user-visible policy.', remedy: 'Specify whether the latin-1 fallback emits a warning or is intentionally silent.' });
rounds[1].dimensions.clarity = 'ISSUES';
const result = renderOfficeHoursReview(rounds);
expect(result.stop).toBe('CONVERGENCE');
expect(result.metrics).toEqual({ iterations: 2, issues_found: 4, issues_fixed: 1, remaining: 2, quality_score: 7, attempted_fix_rounds: 1 });
expect(result.metrics).toEqual({ iterations: 2, issues_found: 4, issues_fixed: 1, remaining: 2, remaining_blocking: 1, remaining_minor: 1, quality_score: 7, attempted_fix_rounds: 1 });
for (const output of [result.concerns, result.report]) {
for (const finding of rounds[1].findings) {
expect(output).toContain(finding.problem);