iterate-plugin 2.7.3 → 2.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,187 @@
1
+ /**
2
+ * File-inventory collection, chunking, and coverage scoring for review scope.
3
+ *
4
+ * Mirrors `harness/iterate-harness/.../review_scope.py`. The iterate review
5
+ * loop must force each reviewer subagent to actually open EVERY file in the
6
+ * scope it is responsible for (not silently skip or assume files). This
7
+ * module supplies the deterministic building blocks:
8
+ *
9
+ * - `collectScopeFiles`: produce the sorted relative-path inventory for a
10
+ * review scope (changed-only delta, or a full walk filtered to source files
11
+ * and stripped of dependency/build/vendor dirs).
12
+ * - `chunkFiles`: split a large inventory into stable batches so `full`
13
+ * reviews stay bounded; consecutive files from the same directory are kept
14
+ * together to avoid splitting a module's review across two reviewers.
15
+ * - `computeCoverage`: compare a reviewer's self-reported `readFiles` against
16
+ * the inventory it was assigned, returning a coverage ratio plus the list of
17
+ * files that were not opened. Consumed by meta-review as a
18
+ * *prompt-informative* metric (never a hard gate).
19
+ *
20
+ * Pure math (chunkFiles / computeCoverage) has no I/O so it unit-tests
21
+ * cleanly; collectScopeFiles walks the filesystem.
22
+ */
23
+ import { readdirSync } from 'node:fs';
24
+ import { join } from 'node:path';
25
+ /** Relative-scope sentinel for whole-module findings. */
26
+ export const WHOLE_FILE_LINE = 0;
27
+ /** Source extensions a full-scope walk includes. */
28
+ const SOURCE_EXTENSIONS = new Set([
29
+ '.py', '.pyi', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
30
+ '.go', '.java', '.rs', '.c', '.h', '.cc', '.cpp', '.cs',
31
+ '.swift', '.kt', '.scala', '.rb', '.php', '.sh', '.bash', '.zsh',
32
+ '.sql', '.html', '.htm', '.css', '.scss', '.vue', '.svelte',
33
+ ]);
34
+ /** Directory names always excluded from a full-scope walk. */
35
+ const IGNORED_DIRS = new Set([
36
+ '.git', '.hg', '.svn', 'node_modules', '.venv', 'venv', 'env',
37
+ '__pycache__', '.cache', '.pytest_cache', '.mypy_cache', 'dist',
38
+ 'build', 'out', '.next', '.nuxt', 'coverage', '.tox', '.idea',
39
+ '.vscode', 'target', '.release', '.dist_tmp',
40
+ ]);
41
+ /** Default chunk size for a `full` scope review (files per batch). */
42
+ export const DEFAULT_SCOPE_CHUNK_SIZE = 25;
43
+ /** Coverage ratio at/above which a scope is fully covered. */
44
+ export const COVERAGE_TARGET = 0.95;
45
+ const SEP = '/';
46
+ /**
47
+ * Canonicalize separators + dot-segments. Leading `..` PATH segments are
48
+ * PRESERVED (mirrors Python `os.path.normpath`, which never resolves beyond
49
+ * the root), so callers can still detect path-escaping (`..`) after
50
+ * normalization — a full `..`-driven traversal must not be silently folded
51
+ * into a bare filename.
52
+ */
53
+ function normalizePath(path) {
54
+ const cleaned = path.replace(/\\/g, SEP);
55
+ const parts = [];
56
+ for (const part of cleaned.split(SEP)) {
57
+ if (part === '' || part === '.')
58
+ continue;
59
+ if (part === '..') {
60
+ if (parts.length > 0)
61
+ parts.pop();
62
+ else
63
+ parts.push(part); // no root segment to pop — keep the leading '..'
64
+ continue;
65
+ }
66
+ parts.push(part);
67
+ }
68
+ return parts.join(SEP);
69
+ }
70
+ function sourceExt(path) {
71
+ const dot = path.lastIndexOf('.');
72
+ if (dot < 0)
73
+ return false;
74
+ return SOURCE_EXTENSIONS.has(path.slice(dot).toLowerCase());
75
+ }
76
+ function isIgnoredDir(name) {
77
+ return IGNORED_DIRS.has(name);
78
+ }
79
+ /** Collect the sorted relative-path inventory for a review scope. */
80
+ export function collectScopeFiles(root, opts) {
81
+ if (opts.scope === 'changed-only')
82
+ return collectChanged(opts.changedFiles ?? []);
83
+ return collectFull(root);
84
+ }
85
+ function collectChanged(changedFiles) {
86
+ const out = new Set();
87
+ for (const rel of changedFiles) {
88
+ if (typeof rel !== 'string' || !rel.trim())
89
+ continue;
90
+ if (rel === String(WHOLE_FILE_LINE))
91
+ continue;
92
+ const cleaned = normalizePath(rel);
93
+ if (cleaned.startsWith('..'))
94
+ continue;
95
+ if (!sourceExt(cleaned))
96
+ continue;
97
+ out.add(cleaned);
98
+ }
99
+ return [...out].sort();
100
+ }
101
+ function collectFull(root) {
102
+ // Deterministic recursive walk built on Node's fs; a code reviewer never
103
+ // anchors findings to lock files, images, or vendored builds.
104
+ const out = [];
105
+ const walk = (dir) => {
106
+ let entries;
107
+ try {
108
+ entries = readdirSync(dir, { withFileTypes: true });
109
+ }
110
+ catch {
111
+ return;
112
+ }
113
+ for (const entry of entries) {
114
+ const abs = join(dir, entry.name);
115
+ if (entry.isDirectory()) {
116
+ if (!isIgnoredDir(entry.name))
117
+ walk(abs);
118
+ continue;
119
+ }
120
+ if (!entry.isFile())
121
+ continue;
122
+ if (!sourceExt(entry.name))
123
+ continue;
124
+ const rel = abs.startsWith(root + SEP) ? abs.slice(root.length + 1) : abs;
125
+ out.push(rel.split(SEP).join(SEP));
126
+ }
127
+ };
128
+ walk(root);
129
+ return out.sort();
130
+ }
131
+ /** Split `files` into stable batches, keeping directory runs together. */
132
+ export function chunkFiles(files, perChunk) {
133
+ const size = perChunk === undefined || perChunk < 1 ? DEFAULT_SCOPE_CHUNK_SIZE : perChunk;
134
+ const ordered = [...files].sort();
135
+ const chunks = [];
136
+ let current = [];
137
+ let lastDir;
138
+ for (const rel of ordered) {
139
+ const parent = rel.includes(SEP) ? rel.slice(0, rel.lastIndexOf(SEP)) : '.';
140
+ if (current.length > 0 && lastDir !== undefined && parent !== lastDir) {
141
+ chunks.push(current);
142
+ current = [];
143
+ lastDir = undefined;
144
+ }
145
+ current.push(rel);
146
+ lastDir = parent;
147
+ if (current.length >= size) {
148
+ chunks.push(current);
149
+ current = [];
150
+ lastDir = undefined;
151
+ }
152
+ }
153
+ if (current.length > 0)
154
+ chunks.push(current);
155
+ return chunks;
156
+ }
157
+ /** Score self-reported reads against the assigned inventory. */
158
+ export function computeCoverage(assigned, readFiles) {
159
+ const readNorm = new Set();
160
+ for (const p of readFiles ?? []) {
161
+ if (typeof p === 'string' && p)
162
+ readNorm.add(normalizePath(p));
163
+ }
164
+ const assignedSorted = [...assigned].sort();
165
+ const covered = assignedSorted.filter((rel) => readNorm.has(normalizePath(rel)));
166
+ const uncovered = assignedSorted.filter((rel) => !readNorm.has(normalizePath(rel)));
167
+ const rawRatio = assignedSorted.length === 0 ? 1 : covered.length / assignedSorted.length;
168
+ const ratio = Math.round(rawRatio * 1000) / 1000;
169
+ return {
170
+ assigned: assignedSorted,
171
+ read: [...new Set((readFiles ?? []).filter((p) => typeof p === 'string'))].sort(),
172
+ covered,
173
+ uncovered,
174
+ ratio,
175
+ };
176
+ }
177
+ /** Serialize a coverage result for the tool-layer JSON wire shape. */
178
+ export function coverageToDict(c) {
179
+ return {
180
+ assigned: c.assigned,
181
+ read: c.read,
182
+ covered: c.covered,
183
+ uncovered: c.uncovered,
184
+ ratio: c.ratio,
185
+ met: c.ratio >= COVERAGE_TARGET,
186
+ };
187
+ }
package/dist/review.js CHANGED
@@ -16,6 +16,7 @@
16
16
  * and stop when a round yields 0 new findings or the round cap is reached.
17
17
  * All deterministic math lives here so it can be unit-tested.
18
18
  */
19
+ import { DEFAULT_SCOPE_CHUNK_SIZE, chunkFiles } from "./review-scope.js";
19
20
  /** Severity ordering: lower rank = more severe. */
20
21
  export const SEVERITY_RANK = {
21
22
  critical: 0,
@@ -269,10 +270,163 @@ export function findingsSchema() {
269
270
  ],
270
271
  },
271
272
  },
273
+ readFiles: {
274
+ type: 'array',
275
+ items: { type: 'string' },
276
+ description: 'Every file you actually opened with read_file while reviewing your assigned scope. Used to audit coverage; files you never opened count as un-reviewed.',
277
+ },
272
278
  },
273
- required: ['findings'],
279
+ required: ['findings', 'readFiles'],
274
280
  };
275
281
  }
282
+ // ─── Output schema validation ──────────────────────────────────────────────
283
+ //
284
+ // `config.reviewer.output_schema_validation` (default true) turns on a
285
+ // deterministic schema gate at the `aggregate` boundary: reviewer subagent
286
+ // outputs are parsed as JSON by the orchestrator, but models sometimes return
287
+ // malformed findings (missing fields, wrong types, out-of-range severity).
288
+ // Before any finding reaches the deterministic core (dedupe/sort/report) —
289
+ // which would crash on e.g. a missing `summary` — we validate every entry
290
+ // against the same shape `findingsSchema()` describes and surface the issues
291
+ // so the workflow can retry that round (≤2 times) with a strict-JSON nudge.
292
+ // Schema-invalid findings are dropped from the report; the workflow never
293
+ // forwards them into fixes.
294
+ /** Fields every finding object MUST carry (mirrors findingsSchema().required). */
295
+ export const REQUIRED_FINDING_FIELDS = [
296
+ 'dimension',
297
+ 'file',
298
+ 'severity',
299
+ 'summary',
300
+ 'failure_scenario',
301
+ 'suggested_fix',
302
+ 'is_atomic',
303
+ ];
304
+ /** Allowed severity values (mirrors the schema enum). */
305
+ export const SEVERITY_VALUES = ['critical', 'high', 'medium', 'low'];
306
+ /** String-typed finding fields (type check only, presence handled by REQUIRED). */
307
+ const STRING_FINDING_FIELDS = [
308
+ 'dimension',
309
+ 'file',
310
+ 'summary',
311
+ 'failure_scenario',
312
+ 'suggested_fix',
313
+ ];
314
+ /**
315
+ * Validate an arbitrary parsed value against the findings schema shape.
316
+ * Accepts the `{findings: [...]}` wrapper OR a bare findings array, so callers
317
+ * can validate either the raw reviewer output object or a round's findings.
318
+ * Pure and deterministic — never touches the filesystem.
319
+ */
320
+ export function validateFindingsSchema(input) {
321
+ const raw = input?.findings ?? input;
322
+ if (!Array.isArray(raw)) {
323
+ return [
324
+ {
325
+ index: -1,
326
+ field: 'findings',
327
+ message: 'expected a JSON array of finding objects',
328
+ },
329
+ ];
330
+ }
331
+ const issues = [];
332
+ for (let i = 0; i < raw.length; i++) {
333
+ const item = raw[i];
334
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
335
+ issues.push({
336
+ index: i,
337
+ field: `findings[${i}]`,
338
+ message: 'expected a finding object',
339
+ });
340
+ continue;
341
+ }
342
+ const f = item;
343
+ for (const key of REQUIRED_FINDING_FIELDS) {
344
+ if (f[key] === undefined || f[key] === null) {
345
+ issues.push({
346
+ index: i,
347
+ field: `findings[${i}].${key}`,
348
+ message: `required field "${key}" is missing`,
349
+ });
350
+ }
351
+ }
352
+ for (const key of STRING_FINDING_FIELDS) {
353
+ if (f[key] !== undefined && f[key] !== null && typeof f[key] !== 'string') {
354
+ issues.push({
355
+ index: i,
356
+ field: `findings[${i}].${key}`,
357
+ message: `"${key}" must be a string`,
358
+ });
359
+ }
360
+ }
361
+ if (f.severity !== undefined &&
362
+ f.severity !== null &&
363
+ !SEVERITY_VALUES.includes(f.severity)) {
364
+ issues.push({
365
+ index: i,
366
+ field: `findings[${i}].severity`,
367
+ message: `severity must be one of: ${SEVERITY_VALUES.join(', ')}`,
368
+ });
369
+ }
370
+ if (f.is_atomic !== undefined &&
371
+ f.is_atomic !== null &&
372
+ typeof f.is_atomic !== 'boolean') {
373
+ issues.push({
374
+ index: i,
375
+ field: `findings[${i}].is_atomic`,
376
+ message: 'is_atomic must be a boolean',
377
+ });
378
+ }
379
+ if (f.line !== undefined &&
380
+ f.line !== null &&
381
+ (typeof f.line !== 'number' || !Number.isInteger(f.line) || f.line < 0)) {
382
+ issues.push({
383
+ index: i,
384
+ field: `findings[${i}].line`,
385
+ message: 'line must be a non-negative integer (0 = whole-file)',
386
+ });
387
+ }
388
+ }
389
+ return issues;
390
+ }
391
+ /**
392
+ * Validate every round's findings against the findings schema.
393
+ * Round order matches the input `rounds` array.
394
+ */
395
+ export function validateRoundsSchema(rounds) {
396
+ return rounds.map((r) => {
397
+ const issues = validateFindingsSchema(r.findings);
398
+ return { round: r.round, valid: issues.length === 0, issues };
399
+ });
400
+ }
401
+ /**
402
+ * Drop schema-invalid findings before they reach the deterministic core.
403
+ *
404
+ * - With a non-null `schemaValidation` (validation enabled): drop every finding
405
+ * flagged by a schema issue; a round-level issue (index -1) empties the round.
406
+ * - With `schemaValidation === null` (validation disabled): still drop entries
407
+ * that are not plain objects, which would crash `findingKey`/dedupe.
408
+ *
409
+ * Round order and round numbers are preserved so downstream convergence math
410
+ * keeps working on the sanitized stream.
411
+ */
412
+ export function sanitizeRounds(rounds, schemaValidation) {
413
+ return rounds.map((r, i) => {
414
+ if (schemaValidation) {
415
+ const issues = schemaValidation[i]?.issues ?? [];
416
+ if (issues.some((iss) => iss.index === -1))
417
+ return { round: r.round, findings: [] };
418
+ const bad = new Set(issues.map((iss) => iss.index));
419
+ return {
420
+ round: r.round,
421
+ findings: r.findings.filter((_, fi) => !bad.has(fi)),
422
+ };
423
+ }
424
+ return {
425
+ round: r.round,
426
+ findings: r.findings.filter((f) => Boolean(f) && typeof f === 'object' && !Array.isArray(f)),
427
+ };
428
+ });
429
+ }
276
430
  /**
277
431
  * Build the task prompt for one dimension's reviewer subagent.
278
432
  * In dry-run mode, pass `alreadyKnown` (the findings from earlier rounds) so the
@@ -281,6 +435,21 @@ export function findingsSchema() {
281
435
  export function reviewerTaskPrompt(input) {
282
436
  const parts = [];
283
437
  parts.push(`You are the "${input.dimension}" reviewer for the iterate review.`, `Goal: ${input.goal}`, `Scope: ${input.scope === 'full' ? 'entire codebase' : 'changed files only'}.`);
438
+ if (input.scopeFiles && input.scopeFiles.length > 0) {
439
+ parts.push('COVERAGE RULE (mandatory): below is the exact file inventory you are ' +
440
+ 'assigned to review. You MUST open EVERY file in this inventory with ' +
441
+ 'the read_file tool before judging it — do not skip, skim-declare, or ' +
442
+ 'assume any file without reading it. Files you did not actually open ' +
443
+ 'are considered un-reviewed and will lower your coverage score. ' +
444
+ 'Return a `readFiles` array listing every file you actually opened.', 'Assigned file inventory:', input.scopeFiles.map((p) => `- ${p}`).join('\n'));
445
+ }
446
+ else if (input.scope === 'changed-only' &&
447
+ input.changedFiles &&
448
+ input.changedFiles.length > 0) {
449
+ parts.push('Changed files to review (review ONLY these files; they are the diff against ' +
450
+ 'the target branch). You MUST open EVERY listed file with read_file ' +
451
+ 'before judging it — never skip or assume a file:', input.changedFiles.map((p) => `- ${p}`).join('\n'));
452
+ }
284
453
  if (input.mode === 'dry-run') {
285
454
  parts.push('MODE: dry-run / pure review. You MUST NOT modify, create, or delete ANY file. Read-only analysis only.');
286
455
  }
@@ -290,9 +459,17 @@ export function reviewerTaskPrompt(input) {
290
459
  else {
291
460
  parts.push('This is round 1 — report every issue you find in this dimension.');
292
461
  }
293
- parts.push(`Return a JSON object: {"findings": [...]}.`, `Each finding: dimension (must be "${input.dimension}"), file (relative path), ` +
294
- 'line (optional integer), severity (critical/high/medium/low), summary (one line), ' +
295
- 'failure_scenario (how/when it fails, specific evidence), suggested_fix (the concrete fix), ' +
462
+ parts.push('EVIDENCE RULE (mandatory): read every file you report on with the ' +
463
+ 'read_file tool BEFORE judging it. NEVER report a location you did not ' +
464
+ 'actually read speculation about code you never inspected is a ' +
465
+ 'disqualifying failure, and fabricated line numbers are treated as ' +
466
+ 'poisoned evidence. Anchor every finding to real code.');
467
+ parts.push(`Return a JSON object: {"findings": [...], "readFiles": [...]}.`, `Each finding: dimension (must be "${input.dimension}"), file (relative path), ` +
468
+ 'line (REQUIRED positive integer — the exact line you READ for an ' +
469
+ 'anchored, line-targeted issue; use 0 for whole-file/module-level ' +
470
+ 'issues), severity (critical/high/medium/low), summary (one line), ' +
471
+ 'failure_scenario (how/when it fails, backed by the code you actually ' +
472
+ 'read), suggested_fix (the concrete fix), ' +
296
473
  `is_atomic (true if the fix is <= ${input.maxLines} lines within a SINGLE file/function, else false).`, `Write summaries and details in ${input.outputLanguage}.`);
297
474
  return parts.join('\n');
298
475
  }
@@ -307,27 +484,58 @@ export function buildReviewPlan(input) {
307
484
  // throwing an uncaught TypeError inside the tool's `execute`.
308
485
  const language = input.config.language === 'zh' ? 'Chinese (中文)' : 'English';
309
486
  const goal = input.config.goal ?? '';
310
- const scope = input.config.review?.scope ?? 'full';
487
+ const configuredScope = input.config.review?.scope ?? 'full';
311
488
  const dimensions = Array.isArray(input.config.dimensions) ? input.config.dimensions : [];
312
489
  const maxLines = input.config.atomic?.max_lines ?? 20;
490
+ const changedFiles = Array.isArray(input.changedFiles) ? input.changedFiles : [];
491
+ // changed-only with zero detected changes → auto-fallback to full scope.
492
+ const effectiveChangedOnly = configuredScope === 'changed-only' && changedFiles.length > 0;
493
+ const scope = effectiveChangedOnly ? 'changed-only' : 'full';
494
+ const fallbackToFull = configuredScope === 'changed-only' && changedFiles.length === 0;
495
+ // Scope batching (coverage enforcement): changed-only is a single batch
496
+ // owning the full delta; full scope splits the collected inventory into
497
+ // per-chunk reviewer tasks when scopeFiles is supplied.
498
+ const chunkSize = Number(input.config.reviewer?.scope_chunk_size);
499
+ const perChunk = Number.isFinite(chunkSize) && chunkSize > 0 ? chunkSize : DEFAULT_SCOPE_CHUNK_SIZE;
500
+ let batches;
501
+ if (effectiveChangedOnly) {
502
+ batches = [undefined];
503
+ }
504
+ else if (input.scopeFiles && input.scopeFiles.length > 0) {
505
+ batches = chunkFiles(input.scopeFiles, perChunk).filter((b) => b.length > 0);
506
+ }
507
+ else {
508
+ batches = [undefined];
509
+ }
510
+ const dimensionTasks = [];
511
+ for (const d of dimensions) {
512
+ batches.forEach((batch, index) => {
513
+ const dimensionId = batches.length === 1 ? d : `${d}#${index + 1}`;
514
+ dimensionTasks.push({
515
+ id: dimensionId,
516
+ reviewerPrompt: reviewerTaskPrompt({
517
+ dimension: d,
518
+ goal,
519
+ scope,
520
+ mode: input.mode,
521
+ alreadyKnown: [],
522
+ outputLanguage: language,
523
+ maxLines,
524
+ changedFiles: effectiveChangedOnly ? changedFiles : undefined,
525
+ scopeFiles: batch,
526
+ }),
527
+ findingsSchema: findingsSchema(),
528
+ });
529
+ });
530
+ }
313
531
  return {
314
532
  mode: input.mode,
315
533
  goal,
316
534
  scope,
317
- dimensions: dimensions.map((d) => ({
318
- id: d,
319
- reviewerPrompt: reviewerTaskPrompt({
320
- dimension: d,
321
- goal,
322
- scope,
323
- mode: input.mode,
324
- alreadyKnown: [],
325
- outputLanguage: language,
326
- maxLines,
327
- }),
328
- findingsSchema: findingsSchema(),
329
- })),
535
+ dimensions: dimensionTasks,
330
536
  maxReviewRounds: input.maxReviewRounds,
331
537
  knownIntentional: input.knownIntentional ?? [],
538
+ changedFiles: effectiveChangedOnly ? changedFiles : [],
539
+ fallbackToFull,
332
540
  };
333
541
  }
@@ -13,7 +13,7 @@ You have the iterate plugin installed, which registers these tools:
13
13
  - \`iterate_validate\` — run a whitelisted validation command
14
14
  - \`iterate_decision_log\` — append to the decision log, or read entries back for review
15
15
  - \`iterate_context\` — read SKILL.md / ITERATE.md project context
16
- - \`iterate_review\` — deterministic review engine: \`plan\` builds the review plan; \`aggregate\` dedupes/merges findings and computes convergence; \`meta-review\` audits a built report for internal consistency (counts, buckets, sorting, convergence math) and returns a final report with an \`approved\` / \`needs_revision\` verdict. Purely computational.
16
+ - \`iterate_review\` — deterministic review engine: \`plan\` builds the review plan (for \`review.scope: changed-only\`, it resolves the git-diff file set against \`git.target_branch\` and auto-falls back to \`full\` when nothing changed); \`aggregate\` dedupes/merges findings, validates every finding against the findings schema when \`reviewer.output_schema_validation\` is on (dropping invalid entries and reporting them via \`schemaValidation\`), and computes convergence; \`meta-review\` audits a built report for internal consistency (counts, buckets, sorting, convergence math) and returns a final report with an \`approved\` / \`needs_revision\` verdict. Purely computational.
17
17
  - \`iterate_triage\` — manage "known_intentional" entries in the config (list / apply, with dedupe + backup + rollback)
18
18
  - \`iterate_fix\` — apply ONE atomic fix: backs up the file, enforces the atomic max_lines threshold, writes the new content, and records the fix (id + diff summary) in \`.iterate/fixes/registry.json\`
19
19
  - \`iterate_diff\` — show the accumulated diff for a fixed file (vs its original backup) or a per-file summary of all fixes
@@ -61,18 +61,36 @@ const rounds = [] // raw per-round findings
61
61
  phase('review')
62
62
  for (let r = 1; r <= maxRounds; r++) {
63
63
  log('round ' + r + ' of ' + maxRounds + ' — finding NEW issues only')
64
- const raw = await parallel(dims.map(dim => () => agent(
65
- 'Review dimension "' + dim + '". Already-known findings (do NOT re-report): ' +
66
- JSON.stringify(known) + '\\nReturn the findings JSON object.',
67
- { label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }
68
- )))
69
- const thisRound = { round: r, findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])) }
70
- rounds.push(thisRound)
71
- // Deterministic aggregate: cross-round dedupe + known_intentional filter + severity sort.
72
- const agg = await agent(
73
- 'Call iterate_review({operation:"aggregate", mode:"dry-run", rounds:' + JSON.stringify(rounds) + ', maxReviewRounds:' + maxRounds + ', knownIntentional:' + JSON.stringify(knownIntentional) + '}) and return the report JSON.',
74
- { label: 'review:aggregate:r' + r }
75
- )
64
+ let agg = null
65
+ let schemaInvalid = false
66
+ let retries = 0
67
+ do {
68
+ // Schema validation retry: on the 2nd+ pass, nudge reviewers toward strict JSON.
69
+ const nudge = retries > 0
70
+ ? '\\nSTRICT JSON REQUIRED: your previous output failed schema validation. Return ONLY a JSON object {"findings":[...]} where EVERY finding has dimension, file, line (non-negative integer; 0 = whole-file), severity (critical|high|medium|low), summary, failure_scenario, suggested_fix, is_atomic (boolean).'
71
+ : ''
72
+ const raw = await parallel(dims.map(dim => () => agent(
73
+ 'Review dimension "' + dim + '". Already-known findings (do NOT re-report): ' +
74
+ JSON.stringify(known) + nudge + '\\nReturn the findings JSON object.',
75
+ { label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }
76
+ )))
77
+ const thisRound = { round: r, findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])) }
78
+ if (rounds.length >= r) rounds[r - 1] = thisRound; else rounds.push(thisRound)
79
+ // Deterministic aggregate: cross-round dedupe + known_intentional filter + severity sort.
80
+ agg = await agent(
81
+ 'Call iterate_review({operation:"aggregate", mode:"dry-run", rounds:' + JSON.stringify(rounds) + ', maxReviewRounds:' + maxRounds + ', knownIntentional:' + JSON.stringify(knownIntentional) + '}) and return the report JSON.',
82
+ { label: 'review:aggregate:r' + r }
83
+ )
84
+ // reviewer.output_schema_validation (default on): aggregate returns per-round
85
+ // schemaValidation; retry the just-finished round (≤2 times) when invalid.
86
+ schemaInvalid = agg && agg.schemaValidation && agg.schemaValidation.length > 0
87
+ ? agg.schemaValidation[agg.schemaValidation.length - 1].valid === false
88
+ : false
89
+ if (schemaInvalid && retries < 2) {
90
+ retries += 1
91
+ log('retry ' + retries + ': round ' + r + ' output failed schema validation — re-running reviewers with strict-JSON emphasis')
92
+ }
93
+ } while (schemaInvalid && retries <= 2)
76
94
  // Feed the DEDUPED + already-filtered set back (not raw findings) so the known
77
95
  // list stays bounded and reviewers never see the same issue twice.
78
96
  if (agg && agg.report && Array.isArray(agg.report.findings)) known = agg.report.findings
@@ -121,10 +139,12 @@ return {
121
139
 
122
140
  Key rules for dry-run:
123
141
  - **NEVER call a fixer / never edit files / never create branches or worktree.** Reviewers read only.
142
+ - **Every reviewer MUST actually read each file it reports on (read_file) BEFORE judging it, and anchor every finding to a real location. Fabricated file paths or invented line numbers are poisoned evidence and fail the run.** Subagents never report on code they didn't inspect.
124
143
  - Each round feeds the already-known findings to reviewers so they hunt NEW issues only → that is what drives convergence.
144
+ - **Schema validation & retry**: when \`reviewer.output_schema_validation\` is on (default), \`aggregate\` validates every finding against the findings schema and returns \`schemaValidation\` (per-round {round, valid, issues}). If the just-finished round is invalid, retry its reviewers up to 2 times with the strict-JSON nudge (see the loop above), then re-aggregate. Schema-invalid findings are dropped by \`aggregate\` and must NEVER be fed back as known findings or reported as converged.
125
145
  - Stop when a round reports 0 new findings (converged) or maxReviewRounds is reached.
126
146
  - The report (with per-round convergence stats + suggested fix priorities) is the deliverable.
127
- - **Meta-review**: after building the report, audit it with \`iterate_review({operation:"meta-review"})\` for internal consistency (counts, severity buckets, dimension sums, sort order, convergence math). The \`finalReport.verdict\` is \`approved\` only when the report passes every check; otherwise \`needs_revision\`. Surface the final report and its verdict as the closing deliverable.
147
+ - **Meta-review**: after building the report, audit it with \`iterate_review({operation:"meta-review"})\` for internal consistency (counts, severity buckets, dimension sums, sort order, convergence math). The meta-review ALSO runs the hard code-evidence gate (default on): every finding's file/line is validated against real files on disk, so any fabricated location surfaces as a critical \`EVIDENCE_VIOLATION\` and flips the verdict to \`needs_revision\`. The \`finalReport.verdict\` is \`approved\` only when the report passes every check AND every finding anchors to real, read code; otherwise \`needs_revision\`. Surface the final report and its verdict as the closing deliverable.
128
148
  - Only a single \`report\` entry may be appended to the decision log; nothing else is written.
129
149
 
130
150
  ### Normal-mode workflow (autonomous closed loop)
@@ -168,21 +188,39 @@ let failedCommands = []
168
188
  phase('loop')
169
189
  for (let r = startRound; r <= maxRounds; r++) {
170
190
  log('round ' + r + ' of ' + maxRounds + ' — review current state, fix atomics via iterate_fix, validate')
171
- const raw = await parallel(dims.map(dim => () => agent(
172
- 'Review dimension "' + dim + '" on the CURRENT code state (previous atomic findings are fixed). ' +
173
- 'Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + '\\nReturn the findings JSON object.',
174
- { label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }
175
- )))
176
- const thisRound = { round: r, findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])) }
177
- rounds.push(thisRound)
191
+ let agg = null
192
+ let schemaInvalid = false
193
+ let retries = 0
194
+ do {
195
+ // Schema validation retry: on the 2nd+ pass, nudge reviewers toward strict JSON.
196
+ const nudge = retries > 0
197
+ ? '\\nSTRICT JSON REQUIRED: your previous output failed schema validation. Return ONLY a JSON object {"findings":[...]} where EVERY finding has dimension, file, line (non-negative integer; 0 = whole-file), severity (critical|high|medium|low), summary, failure_scenario, suggested_fix, is_atomic (boolean).'
198
+ : ''
199
+ const raw = await parallel(dims.map(dim => () => agent(
200
+ 'Review dimension "' + dim + '" on the CURRENT code state (previous atomic findings are fixed). ' +
201
+ 'Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + nudge + '\\nReturn the findings JSON object.',
202
+ { label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }
203
+ )))
204
+ const thisRound = { round: r, findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])) }
205
+ if (rounds.length >= r) rounds[r - 1] = thisRound; else rounds.push(thisRound)
178
206
 
179
- // Deterministic dedupe / known_intentional filter / severity sort for this round.
180
- // \`fixedCount\` is threaded into the report summary so the client dashboard can
181
- // show a running "fixes applied" metric for normal mode.
182
- const agg = await agent(
183
- 'Call iterate_review({operation:"aggregate", mode:"normal", rounds:' + JSON.stringify([thisRound]) + ', knownIntentional:' + JSON.stringify(knownIntentional) + ', fixedCount:' + fixedCount + '}) and return the report JSON.',
184
- { label: 'review:aggregate:r' + r }
185
- )
207
+ // Deterministic dedupe / known_intentional filter / severity sort for this round.
208
+ // \`fixedCount\` is threaded into the report summary so the client dashboard can
209
+ // show a running "fixes applied" metric for normal mode.
210
+ agg = await agent(
211
+ 'Call iterate_review({operation:"aggregate", mode:"normal", rounds:' + JSON.stringify([thisRound]) + ', knownIntentional:' + JSON.stringify(knownIntentional) + ', fixedCount:' + fixedCount + '}) and return the report JSON.',
212
+ { label: 'review:aggregate:r' + r }
213
+ )
214
+ // reviewer.output_schema_validation (default on): aggregate returns per-round
215
+ // schemaValidation; retry the just-finished round (≤2 times) when invalid.
216
+ schemaInvalid = agg && agg.schemaValidation && agg.schemaValidation.length > 0
217
+ ? agg.schemaValidation[agg.schemaValidation.length - 1].valid === false
218
+ : false
219
+ if (schemaInvalid && retries < 2) {
220
+ retries += 1
221
+ log('retry ' + retries + ': round ' + r + ' output failed schema validation — re-running reviewers with strict-JSON emphasis')
222
+ }
223
+ } while (schemaInvalid && retries <= 2)
186
224
  const findings = (agg && agg.report && agg.report.findings) ? agg.report.findings : thisRound.findings
187
225
  const atomic = findings.filter(f => f.is_atomic === true)
188
226
  const remaining = findings.filter(f => f.is_atomic !== true)
@@ -319,6 +357,7 @@ return {
319
357
  Key rules for normal mode:
320
358
  - Fixers are the ONLY agents allowed to write files, and they must go through \`iterate_fix\` — never edit files directly. That is what gives every change a backup, a diff, and a rollback path. Reviewers read only. Architectural findings are reported, never auto-fixed.
321
359
  - Aggregate the current round deterministically (\`report.findings\`) before fixing, so fixes act on deduped/filtered/sorted findings.
360
+ - **Schema validation & retry**: when \`reviewer.output_schema_validation\` is on (default), retry the round's reviewers up to 2 times when \`aggregate\` reports \`schemaValidation\` valid=false for it, then re-aggregate. Never forward schema-invalid findings into \`iterate_fix\`.
322
361
  - Apply atomic fixes **per file**: one fixer agent handles all findings for a given file serially (so the same file is never edited concurrently); different files are fixed in parallel.
323
362
  - **Resume**: load the checkpoint first; if a previous run left one, continue from \`checkpoint.round + 1\` (its \`fixedCount\` and deduped \`findings\` are carried forward).
324
363
  - **Validate after every round** of fixes; on ANY validation failure, roll back the round's fixes via \`iterate_rollback\` and stop (the checkpoint is left in place so the run can be resumed).
@@ -328,11 +367,12 @@ Key rules for normal mode:
328
367
  - Close with \`iterate_status\` metrics and surface the convergence indicators (fixed count, remaining architectural count, abort reason) in the final summary.
329
368
 
330
369
  ### Finding schema (for reviewer agents)
331
- { "dimension": string, "file": string (relative path), "line": number (optional),
370
+ { "dimension": string, "file": string (relative path), "line": number (REQUIRED for line-targeted issues — the exact line you READ; use 0 for whole-file/module-level issues),
332
371
  "severity": "critical" | "high" | "medium" | "low", "summary": string (one line),
333
372
  "failure_scenario": string (how/when it fails), "suggested_fix": string (the concrete fix),
334
373
  "is_atomic": boolean (true if fix ≤ max_lines within a single file/function) }
335
374
  Atomic = is_atomic true (single file, single function, ≤ config.atomic.max_lines lines change). Architectural = everything else.
375
+ Every finding MUST reference a file the reviewer actually read (read_file) and a real location — never speculate about code that was never inspected. Fabricated paths/lines are poisoned evidence and fail the meta-review evidence gate.
336
376
 
337
377
  ### Workflow meta
338
378
  Always pass \`meta: { name: "iterate", description: "Autonomous iterate loop" }\`.