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.
package/src/review.ts CHANGED
@@ -24,6 +24,7 @@ import type {
24
24
  ReviewReport,
25
25
  ReviewRound,
26
26
  } from './types.ts'
27
+ import { DEFAULT_SCOPE_CHUNK_SIZE, chunkFiles } from './review-scope.ts'
27
28
 
28
29
  /** Severity ordering: lower rank = more severe. */
29
30
  export const SEVERITY_RANK: Record<ReviewFinding['severity'], number> = {
@@ -312,9 +313,201 @@ export function findingsSchema(): Record<string, unknown> {
312
313
  ],
313
314
  },
314
315
  },
316
+ readFiles: {
317
+ type: 'array',
318
+ items: { type: 'string' },
319
+ description:
320
+ '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.',
321
+ },
315
322
  },
316
- required: ['findings'],
323
+ required: ['findings', 'readFiles'],
324
+ }
325
+ }
326
+
327
+ // ─── Output schema validation ──────────────────────────────────────────────
328
+ //
329
+ // `config.reviewer.output_schema_validation` (default true) turns on a
330
+ // deterministic schema gate at the `aggregate` boundary: reviewer subagent
331
+ // outputs are parsed as JSON by the orchestrator, but models sometimes return
332
+ // malformed findings (missing fields, wrong types, out-of-range severity).
333
+ // Before any finding reaches the deterministic core (dedupe/sort/report) —
334
+ // which would crash on e.g. a missing `summary` — we validate every entry
335
+ // against the same shape `findingsSchema()` describes and surface the issues
336
+ // so the workflow can retry that round (≤2 times) with a strict-JSON nudge.
337
+ // Schema-invalid findings are dropped from the report; the workflow never
338
+ // forwards them into fixes.
339
+
340
+ /** Fields every finding object MUST carry (mirrors findingsSchema().required). */
341
+ export const REQUIRED_FINDING_FIELDS = [
342
+ 'dimension',
343
+ 'file',
344
+ 'severity',
345
+ 'summary',
346
+ 'failure_scenario',
347
+ 'suggested_fix',
348
+ 'is_atomic',
349
+ ] as const
350
+
351
+ /** Allowed severity values (mirrors the schema enum). */
352
+ export const SEVERITY_VALUES = ['critical', 'high', 'medium', 'low'] as const
353
+
354
+ /** String-typed finding fields (type check only, presence handled by REQUIRED). */
355
+ const STRING_FINDING_FIELDS = [
356
+ 'dimension',
357
+ 'file',
358
+ 'summary',
359
+ 'failure_scenario',
360
+ 'suggested_fix',
361
+ ] as const
362
+
363
+ /** One schema violation for a single finding entry. */
364
+ export interface FindingSchemaIssue {
365
+ /** Index into the findings array; -1 when the whole `findings` is malformed. */
366
+ index: number
367
+ /** Field path, e.g. "findings[2].severity". */
368
+ field: string
369
+ /** Human-readable reason. */
370
+ message: string
371
+ }
372
+
373
+ /**
374
+ * Validate an arbitrary parsed value against the findings schema shape.
375
+ * Accepts the `{findings: [...]}` wrapper OR a bare findings array, so callers
376
+ * can validate either the raw reviewer output object or a round's findings.
377
+ * Pure and deterministic — never touches the filesystem.
378
+ */
379
+ export function validateFindingsSchema(input: unknown): FindingSchemaIssue[] {
380
+ const raw = (input as { findings?: unknown } | null)?.findings ?? input
381
+ if (!Array.isArray(raw)) {
382
+ return [
383
+ {
384
+ index: -1,
385
+ field: 'findings',
386
+ message: 'expected a JSON array of finding objects',
387
+ },
388
+ ]
317
389
  }
390
+
391
+ const issues: FindingSchemaIssue[] = []
392
+ for (let i = 0; i < raw.length; i++) {
393
+ const item = raw[i]
394
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
395
+ issues.push({
396
+ index: i,
397
+ field: `findings[${i}]`,
398
+ message: 'expected a finding object',
399
+ })
400
+ continue
401
+ }
402
+ const f = item as Record<string, unknown>
403
+
404
+ for (const key of REQUIRED_FINDING_FIELDS) {
405
+ if (f[key] === undefined || f[key] === null) {
406
+ issues.push({
407
+ index: i,
408
+ field: `findings[${i}].${key}`,
409
+ message: `required field "${key}" is missing`,
410
+ })
411
+ }
412
+ }
413
+ for (const key of STRING_FINDING_FIELDS) {
414
+ if (f[key] !== undefined && f[key] !== null && typeof f[key] !== 'string') {
415
+ issues.push({
416
+ index: i,
417
+ field: `findings[${i}].${key}`,
418
+ message: `"${key}" must be a string`,
419
+ })
420
+ }
421
+ }
422
+ if (
423
+ f.severity !== undefined &&
424
+ f.severity !== null &&
425
+ !SEVERITY_VALUES.includes(f.severity as (typeof SEVERITY_VALUES)[number])
426
+ ) {
427
+ issues.push({
428
+ index: i,
429
+ field: `findings[${i}].severity`,
430
+ message: `severity must be one of: ${SEVERITY_VALUES.join(', ')}`,
431
+ })
432
+ }
433
+ if (
434
+ f.is_atomic !== undefined &&
435
+ f.is_atomic !== null &&
436
+ typeof f.is_atomic !== 'boolean'
437
+ ) {
438
+ issues.push({
439
+ index: i,
440
+ field: `findings[${i}].is_atomic`,
441
+ message: 'is_atomic must be a boolean',
442
+ })
443
+ }
444
+ if (
445
+ f.line !== undefined &&
446
+ f.line !== null &&
447
+ (typeof f.line !== 'number' || !Number.isInteger(f.line) || f.line < 0)
448
+ ) {
449
+ issues.push({
450
+ index: i,
451
+ field: `findings[${i}].line`,
452
+ message: 'line must be a non-negative integer (0 = whole-file)',
453
+ })
454
+ }
455
+ }
456
+ return issues
457
+ }
458
+
459
+ /** Per-round schema validation outcome. */
460
+ export interface RoundSchemaValidation {
461
+ round: number
462
+ /** True when every finding in the round conforms to the schema. */
463
+ valid: boolean
464
+ issues: FindingSchemaIssue[]
465
+ }
466
+
467
+ /**
468
+ * Validate every round's findings against the findings schema.
469
+ * Round order matches the input `rounds` array.
470
+ */
471
+ export function validateRoundsSchema(rounds: ReviewRound[]): RoundSchemaValidation[] {
472
+ return rounds.map((r) => {
473
+ const issues = validateFindingsSchema(r.findings)
474
+ return { round: r.round, valid: issues.length === 0, issues }
475
+ })
476
+ }
477
+
478
+ /**
479
+ * Drop schema-invalid findings before they reach the deterministic core.
480
+ *
481
+ * - With a non-null `schemaValidation` (validation enabled): drop every finding
482
+ * flagged by a schema issue; a round-level issue (index -1) empties the round.
483
+ * - With `schemaValidation === null` (validation disabled): still drop entries
484
+ * that are not plain objects, which would crash `findingKey`/dedupe.
485
+ *
486
+ * Round order and round numbers are preserved so downstream convergence math
487
+ * keeps working on the sanitized stream.
488
+ */
489
+ export function sanitizeRounds(
490
+ rounds: ReviewRound[],
491
+ schemaValidation: RoundSchemaValidation[] | null,
492
+ ): ReviewRound[] {
493
+ return rounds.map((r, i) => {
494
+ if (schemaValidation) {
495
+ const issues = schemaValidation[i]?.issues ?? []
496
+ if (issues.some((iss) => iss.index === -1)) return { round: r.round, findings: [] }
497
+ const bad = new Set(issues.map((iss) => iss.index))
498
+ return {
499
+ round: r.round,
500
+ findings: r.findings.filter((_, fi) => !bad.has(fi)),
501
+ }
502
+ }
503
+ return {
504
+ round: r.round,
505
+ findings: r.findings.filter(
506
+ (f): f is ReviewFinding =>
507
+ Boolean(f) && typeof f === 'object' && !Array.isArray(f),
508
+ ),
509
+ }
510
+ })
318
511
  }
319
512
 
320
513
  /**
@@ -331,6 +524,21 @@ export function reviewerTaskPrompt(input: {
331
524
  outputLanguage: string
332
525
  /** Atomic fix threshold from config.atomic. */
333
526
  maxLines: number
527
+ /**
528
+ * Files to review under `changed-only` scope (relative paths, resolved via
529
+ * git diff against `git.target_branch`). Omitted/empty for `full` scope or
530
+ * when no changes were detected (auto-fallback to full).
531
+ */
532
+ changedFiles?: string[]
533
+ /**
534
+ * The exact file inventory this reviewer is RESPONSIBLE for. When provided,
535
+ * a mandatory COVERAGE RULE is injected: the reviewer must actually open
536
+ * every listed file with read_file and return a `readFiles` array of what it
537
+ * opened (the enforcement half of "每个子 agent 必须逐文件读取自己负责的审查
538
+ * 范围"). Used for the chunked full-scope case; takes precedence over
539
+ * `changedFiles`.
540
+ */
541
+ scopeFiles?: string[]
334
542
  }): string {
335
543
  const parts: string[] = []
336
544
  parts.push(
@@ -338,6 +546,29 @@ export function reviewerTaskPrompt(input: {
338
546
  `Goal: ${input.goal}`,
339
547
  `Scope: ${input.scope === 'full' ? 'entire codebase' : 'changed files only'}.`,
340
548
  )
549
+ if (input.scopeFiles && input.scopeFiles.length > 0) {
550
+ parts.push(
551
+ 'COVERAGE RULE (mandatory): below is the exact file inventory you are ' +
552
+ 'assigned to review. You MUST open EVERY file in this inventory with ' +
553
+ 'the read_file tool before judging it — do not skip, skim-declare, or ' +
554
+ 'assume any file without reading it. Files you did not actually open ' +
555
+ 'are considered un-reviewed and will lower your coverage score. ' +
556
+ 'Return a `readFiles` array listing every file you actually opened.',
557
+ 'Assigned file inventory:',
558
+ input.scopeFiles.map((p) => `- ${p}`).join('\n'),
559
+ )
560
+ } else if (
561
+ input.scope === 'changed-only' &&
562
+ input.changedFiles &&
563
+ input.changedFiles.length > 0
564
+ ) {
565
+ parts.push(
566
+ 'Changed files to review (review ONLY these files; they are the diff against ' +
567
+ 'the target branch). You MUST open EVERY listed file with read_file ' +
568
+ 'before judging it — never skip or assume a file:',
569
+ input.changedFiles.map((p) => `- ${p}`).join('\n'),
570
+ )
571
+ }
341
572
  if (input.mode === 'dry-run') {
342
573
  parts.push(
343
574
  'MODE: dry-run / pure review. You MUST NOT modify, create, or delete ANY file. Read-only analysis only.',
@@ -352,10 +583,20 @@ export function reviewerTaskPrompt(input: {
352
583
  parts.push('This is round 1 — report every issue you find in this dimension.')
353
584
  }
354
585
  parts.push(
355
- `Return a JSON object: {"findings": [...]}.`,
586
+ 'EVIDENCE RULE (mandatory): read every file you report on with the ' +
587
+ 'read_file tool BEFORE judging it. NEVER report a location you did not ' +
588
+ 'actually read — speculation about code you never inspected is a ' +
589
+ 'disqualifying failure, and fabricated line numbers are treated as ' +
590
+ 'poisoned evidence. Anchor every finding to real code.',
591
+ )
592
+ parts.push(
593
+ `Return a JSON object: {"findings": [...], "readFiles": [...]}.`,
356
594
  `Each finding: dimension (must be "${input.dimension}"), file (relative path), ` +
357
- 'line (optional integer), severity (critical/high/medium/low), summary (one line), ' +
358
- 'failure_scenario (how/when it fails, specific evidence), suggested_fix (the concrete fix), ' +
595
+ 'line (REQUIRED positive integer the exact line you READ for an ' +
596
+ 'anchored, line-targeted issue; use 0 for whole-file/module-level ' +
597
+ 'issues), severity (critical/high/medium/low), summary (one line), ' +
598
+ 'failure_scenario (how/when it fails, backed by the code you actually ' +
599
+ 'read), suggested_fix (the concrete fix), ' +
359
600
  `is_atomic (true if the fix is <= ${input.maxLines} lines within a SINGLE file/function, else false).`,
360
601
  `Write summaries and details in ${input.outputLanguage}.`,
361
602
  )
@@ -372,6 +613,20 @@ export function buildReviewPlan(input: {
372
613
  mode: 'normal' | 'dry-run'
373
614
  maxReviewRounds: number
374
615
  knownIntentional?: KnownIntentional[]
616
+ /**
617
+ * Files changed against `git.target_branch` (relative paths), resolved by the
618
+ * tool when `review.scope` is `changed-only`. When the configured scope is
619
+ * `changed-only` but no changes are detected, the plan auto-falls back to
620
+ * `full` (mirrors SKILL.md: "无改动文件时自动 fallback 为 full").
621
+ */
622
+ changedFiles?: string[]
623
+ /**
624
+ * Pre-collected source inventory for a `full`-scope review. When provided,
625
+ * it is split into `config.reviewer.scope_chunk_size` batches and every
626
+ * (dimension × batch) pair gets its own reviewer task owning a bounded,
627
+ * complete inventory it must open file-by-file.
628
+ */
629
+ scopeFiles?: string[]
375
630
  }): {
376
631
  mode: 'normal' | 'dry-run'
377
632
  goal: string
@@ -379,33 +634,73 @@ export function buildReviewPlan(input: {
379
634
  dimensions: { id: string; reviewerPrompt: string; findingsSchema: Record<string, unknown> }[]
380
635
  maxReviewRounds: number
381
636
  knownIntentional: KnownIntentional[]
637
+ /** Files reviewed under `changed-only` scope (empty for `full` / fallback). */
638
+ changedFiles: string[]
639
+ /** True when scope was `changed-only` but no changes were found. */
640
+ fallbackToFull: boolean
382
641
  } {
383
642
  // Defensive reads: a malformed config (e.g. `dimensions` as a non-array, or
384
643
  // `review`/`atomic` missing) must degrade to sane defaults instead of
385
644
  // throwing an uncaught TypeError inside the tool's `execute`.
386
645
  const language = input.config.language === 'zh' ? 'Chinese (中文)' : 'English'
387
646
  const goal = input.config.goal ?? ''
388
- const scope = input.config.review?.scope ?? 'full'
647
+ const configuredScope = input.config.review?.scope ?? 'full'
389
648
  const dimensions = Array.isArray(input.config.dimensions) ? input.config.dimensions : []
390
649
  const maxLines = input.config.atomic?.max_lines ?? 20
650
+ const changedFiles = Array.isArray(input.changedFiles) ? input.changedFiles : []
651
+ // changed-only with zero detected changes → auto-fallback to full scope.
652
+ const effectiveChangedOnly = configuredScope === 'changed-only' && changedFiles.length > 0
653
+ const scope: 'full' | 'changed-only' = effectiveChangedOnly ? 'changed-only' : 'full'
654
+ const fallbackToFull = configuredScope === 'changed-only' && changedFiles.length === 0
655
+
656
+ // Scope batching (coverage enforcement): changed-only is a single batch
657
+ // owning the full delta; full scope splits the collected inventory into
658
+ // per-chunk reviewer tasks when scopeFiles is supplied.
659
+ const chunkSize = Number(input.config.reviewer?.scope_chunk_size)
660
+ const perChunk = Number.isFinite(chunkSize) && chunkSize > 0 ? chunkSize : DEFAULT_SCOPE_CHUNK_SIZE
661
+ let batches: (string[] | undefined)[]
662
+ if (effectiveChangedOnly) {
663
+ batches = [undefined]
664
+ } else if (input.scopeFiles && input.scopeFiles.length > 0) {
665
+ batches = chunkFiles(input.scopeFiles, perChunk).filter((b) => b.length > 0)
666
+ } else {
667
+ batches = [undefined]
668
+ }
669
+
670
+ const dimensionTasks: {
671
+ id: string
672
+ reviewerPrompt: string
673
+ findingsSchema: Record<string, unknown>
674
+ }[] = []
675
+ for (const d of dimensions) {
676
+ batches.forEach((batch, index) => {
677
+ const dimensionId = batches.length === 1 ? d : `${d}#${index + 1}`
678
+ dimensionTasks.push({
679
+ id: dimensionId,
680
+ reviewerPrompt: reviewerTaskPrompt({
681
+ dimension: d,
682
+ goal,
683
+ scope,
684
+ mode: input.mode,
685
+ alreadyKnown: [],
686
+ outputLanguage: language,
687
+ maxLines,
688
+ changedFiles: effectiveChangedOnly ? changedFiles : undefined,
689
+ scopeFiles: batch,
690
+ }),
691
+ findingsSchema: findingsSchema(),
692
+ })
693
+ })
694
+ }
695
+
391
696
  return {
392
697
  mode: input.mode,
393
698
  goal,
394
699
  scope,
395
- dimensions: dimensions.map((d) => ({
396
- id: d,
397
- reviewerPrompt: reviewerTaskPrompt({
398
- dimension: d,
399
- goal,
400
- scope,
401
- mode: input.mode,
402
- alreadyKnown: [],
403
- outputLanguage: language,
404
- maxLines,
405
- }),
406
- findingsSchema: findingsSchema(),
407
- })),
700
+ dimensions: dimensionTasks,
408
701
  maxReviewRounds: input.maxReviewRounds,
409
702
  knownIntentional: input.knownIntentional ?? [],
703
+ changedFiles: effectiveChangedOnly ? changedFiles : [],
704
+ fallbackToFull,
410
705
  }
411
706
  }
@@ -14,7 +14,7 @@ You have the iterate plugin installed, which registers these tools:
14
14
  - \`iterate_validate\` — run a whitelisted validation command
15
15
  - \`iterate_decision_log\` — append to the decision log, or read entries back for review
16
16
  - \`iterate_context\` — read SKILL.md / ITERATE.md project context
17
- - \`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.
17
+ - \`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.
18
18
  - \`iterate_triage\` — manage "known_intentional" entries in the config (list / apply, with dedupe + backup + rollback)
19
19
  - \`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\`
20
20
  - \`iterate_diff\` — show the accumulated diff for a fixed file (vs its original backup) or a per-file summary of all fixes
@@ -62,18 +62,36 @@ const rounds = [] // raw per-round findings
62
62
  phase('review')
63
63
  for (let r = 1; r <= maxRounds; r++) {
64
64
  log('round ' + r + ' of ' + maxRounds + ' — finding NEW issues only')
65
- const raw = await parallel(dims.map(dim => () => agent(
66
- 'Review dimension "' + dim + '". Already-known findings (do NOT re-report): ' +
67
- JSON.stringify(known) + '\\nReturn the findings JSON object.',
68
- { label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }
69
- )))
70
- const thisRound = { round: r, findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])) }
71
- rounds.push(thisRound)
72
- // Deterministic aggregate: cross-round dedupe + known_intentional filter + severity sort.
73
- const agg = await agent(
74
- 'Call iterate_review({operation:"aggregate", mode:"dry-run", rounds:' + JSON.stringify(rounds) + ', maxReviewRounds:' + maxRounds + ', knownIntentional:' + JSON.stringify(knownIntentional) + '}) and return the report JSON.',
75
- { label: 'review:aggregate:r' + r }
76
- )
65
+ let agg = null
66
+ let schemaInvalid = false
67
+ let retries = 0
68
+ do {
69
+ // Schema validation retry: on the 2nd+ pass, nudge reviewers toward strict JSON.
70
+ const nudge = retries > 0
71
+ ? '\\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).'
72
+ : ''
73
+ const raw = await parallel(dims.map(dim => () => agent(
74
+ 'Review dimension "' + dim + '". Already-known findings (do NOT re-report): ' +
75
+ JSON.stringify(known) + nudge + '\\nReturn the findings JSON object.',
76
+ { label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }
77
+ )))
78
+ const thisRound = { round: r, findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])) }
79
+ if (rounds.length >= r) rounds[r - 1] = thisRound; else rounds.push(thisRound)
80
+ // Deterministic aggregate: cross-round dedupe + known_intentional filter + severity sort.
81
+ agg = await agent(
82
+ 'Call iterate_review({operation:"aggregate", mode:"dry-run", rounds:' + JSON.stringify(rounds) + ', maxReviewRounds:' + maxRounds + ', knownIntentional:' + JSON.stringify(knownIntentional) + '}) and return the report JSON.',
83
+ { label: 'review:aggregate:r' + r }
84
+ )
85
+ // reviewer.output_schema_validation (default on): aggregate returns per-round
86
+ // schemaValidation; retry the just-finished round (≤2 times) when invalid.
87
+ schemaInvalid = agg && agg.schemaValidation && agg.schemaValidation.length > 0
88
+ ? agg.schemaValidation[agg.schemaValidation.length - 1].valid === false
89
+ : false
90
+ if (schemaInvalid && retries < 2) {
91
+ retries += 1
92
+ log('retry ' + retries + ': round ' + r + ' output failed schema validation — re-running reviewers with strict-JSON emphasis')
93
+ }
94
+ } while (schemaInvalid && retries <= 2)
77
95
  // Feed the DEDUPED + already-filtered set back (not raw findings) so the known
78
96
  // list stays bounded and reviewers never see the same issue twice.
79
97
  if (agg && agg.report && Array.isArray(agg.report.findings)) known = agg.report.findings
@@ -122,10 +140,12 @@ return {
122
140
 
123
141
  Key rules for dry-run:
124
142
  - **NEVER call a fixer / never edit files / never create branches or worktree.** Reviewers read only.
143
+ - **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.
125
144
  - Each round feeds the already-known findings to reviewers so they hunt NEW issues only → that is what drives convergence.
145
+ - **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.
126
146
  - Stop when a round reports 0 new findings (converged) or maxReviewRounds is reached.
127
147
  - The report (with per-round convergence stats + suggested fix priorities) is the deliverable.
128
- - **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.
148
+ - **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.
129
149
  - Only a single \`report\` entry may be appended to the decision log; nothing else is written.
130
150
 
131
151
  ### Normal-mode workflow (autonomous closed loop)
@@ -169,21 +189,39 @@ let failedCommands = []
169
189
  phase('loop')
170
190
  for (let r = startRound; r <= maxRounds; r++) {
171
191
  log('round ' + r + ' of ' + maxRounds + ' — review current state, fix atomics via iterate_fix, validate')
172
- const raw = await parallel(dims.map(dim => () => agent(
173
- 'Review dimension "' + dim + '" on the CURRENT code state (previous atomic findings are fixed). ' +
174
- 'Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + '\\nReturn the findings JSON object.',
175
- { label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }
176
- )))
177
- const thisRound = { round: r, findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])) }
178
- rounds.push(thisRound)
192
+ let agg = null
193
+ let schemaInvalid = false
194
+ let retries = 0
195
+ do {
196
+ // Schema validation retry: on the 2nd+ pass, nudge reviewers toward strict JSON.
197
+ const nudge = retries > 0
198
+ ? '\\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).'
199
+ : ''
200
+ const raw = await parallel(dims.map(dim => () => agent(
201
+ 'Review dimension "' + dim + '" on the CURRENT code state (previous atomic findings are fixed). ' +
202
+ 'Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + nudge + '\\nReturn the findings JSON object.',
203
+ { label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }
204
+ )))
205
+ const thisRound = { round: r, findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])) }
206
+ if (rounds.length >= r) rounds[r - 1] = thisRound; else rounds.push(thisRound)
179
207
 
180
- // Deterministic dedupe / known_intentional filter / severity sort for this round.
181
- // \`fixedCount\` is threaded into the report summary so the client dashboard can
182
- // show a running "fixes applied" metric for normal mode.
183
- const agg = await agent(
184
- 'Call iterate_review({operation:"aggregate", mode:"normal", rounds:' + JSON.stringify([thisRound]) + ', knownIntentional:' + JSON.stringify(knownIntentional) + ', fixedCount:' + fixedCount + '}) and return the report JSON.',
185
- { label: 'review:aggregate:r' + r }
186
- )
208
+ // Deterministic dedupe / known_intentional filter / severity sort for this round.
209
+ // \`fixedCount\` is threaded into the report summary so the client dashboard can
210
+ // show a running "fixes applied" metric for normal mode.
211
+ agg = await agent(
212
+ 'Call iterate_review({operation:"aggregate", mode:"normal", rounds:' + JSON.stringify([thisRound]) + ', knownIntentional:' + JSON.stringify(knownIntentional) + ', fixedCount:' + fixedCount + '}) and return the report JSON.',
213
+ { label: 'review:aggregate:r' + r }
214
+ )
215
+ // reviewer.output_schema_validation (default on): aggregate returns per-round
216
+ // schemaValidation; retry the just-finished round (≤2 times) when invalid.
217
+ schemaInvalid = agg && agg.schemaValidation && agg.schemaValidation.length > 0
218
+ ? agg.schemaValidation[agg.schemaValidation.length - 1].valid === false
219
+ : false
220
+ if (schemaInvalid && retries < 2) {
221
+ retries += 1
222
+ log('retry ' + retries + ': round ' + r + ' output failed schema validation — re-running reviewers with strict-JSON emphasis')
223
+ }
224
+ } while (schemaInvalid && retries <= 2)
187
225
  const findings = (agg && agg.report && agg.report.findings) ? agg.report.findings : thisRound.findings
188
226
  const atomic = findings.filter(f => f.is_atomic === true)
189
227
  const remaining = findings.filter(f => f.is_atomic !== true)
@@ -320,6 +358,7 @@ return {
320
358
  Key rules for normal mode:
321
359
  - 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.
322
360
  - Aggregate the current round deterministically (\`report.findings\`) before fixing, so fixes act on deduped/filtered/sorted findings.
361
+ - **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\`.
323
362
  - 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.
324
363
  - **Resume**: load the checkpoint first; if a previous run left one, continue from \`checkpoint.round + 1\` (its \`fixedCount\` and deduped \`findings\` are carried forward).
325
364
  - **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).
@@ -329,11 +368,12 @@ Key rules for normal mode:
329
368
  - Close with \`iterate_status\` metrics and surface the convergence indicators (fixed count, remaining architectural count, abort reason) in the final summary.
330
369
 
331
370
  ### Finding schema (for reviewer agents)
332
- { "dimension": string, "file": string (relative path), "line": number (optional),
371
+ { "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),
333
372
  "severity": "critical" | "high" | "medium" | "low", "summary": string (one line),
334
373
  "failure_scenario": string (how/when it fails), "suggested_fix": string (the concrete fix),
335
374
  "is_atomic": boolean (true if fix ≤ max_lines within a single file/function) }
336
375
  Atomic = is_atomic true (single file, single function, ≤ config.atomic.max_lines lines change). Architectural = everything else.
376
+ 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.
337
377
 
338
378
  ### Workflow meta
339
379
  Always pass \`meta: { name: "iterate", description: "Autonomous iterate loop" }\`.
package/src/tools/fix.ts CHANGED
@@ -22,6 +22,7 @@ import { join } from 'node:path'
22
22
  import { defineTool } from '@deepseek-ai/dsh-tools'
23
23
  import type { JsonValue } from '@deepseek-ai/dsh-session'
24
24
  import { loadEffectiveConfig, resolveProjectRoot } from '../config-loader.ts'
25
+ import { countTouchedMethods } from '../method-scope.ts'
25
26
  import { fixBackupPath, fixRegistryPath, fixesDir } from '../paths.ts'
26
27
  import { appendDecisionEntry } from './decision-log.ts'
27
28
  import type { FileDiffHunk, FixRecord, FixRegistry, ReviewFinding } from '../types.ts'
@@ -228,7 +229,7 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
228
229
  description:
229
230
  'Apply ONE atomic fix to a file. Pass the target relative `file`, the finding that motivated ' +
230
231
  'the fix, the NEW full `content` of that file (after your edit), and the current `round`. ' +
231
- 'The tool backs up the original, enforces the atomic `max_lines` threshold (unless `force`), ' +
232
+ 'The tool backs up the original, enforces the atomic `max_lines` and `max_adjacent_methods` thresholds (unless `force`), ' +
232
233
  'writes the new content, and records the fix for later diff/rollback. ' +
233
234
  'This is the ONLY sanctioned way to apply fixes in normal mode.',
234
235
  parameters: {
@@ -289,6 +290,7 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
289
290
  const projectRoot = resolved.root
290
291
  const { config } = loadEffectiveConfig(projectRoot)
291
292
  const maxLines = config.atomic?.max_lines ?? 20
293
+ const maxAdjacentMethods = config.atomic?.max_adjacent_methods ?? 3
292
294
 
293
295
  const file = typeof args.file === 'string' ? args.file : ''
294
296
  if (!file) return { ok: false, error: 'file is required' }
@@ -316,6 +318,7 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
316
318
  const current = readProjectFile(projectRoot, file)
317
319
  if (!current.ok) return { ok: false, error: current.reason }
318
320
 
321
+ const hunks = diffLines(current.content, args.content)
319
322
  const { added, removed } = countChangedLines(current.content, args.content)
320
323
  if (!args.force && (added > maxLines || removed > maxLines)) {
321
324
  return {
@@ -325,6 +328,15 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
325
328
  }
326
329
  }
327
330
 
331
+ const touchedMethods = countTouchedMethods(current.content, args.content, hunks)
332
+ if (!args.force && touchedMethods > maxAdjacentMethods) {
333
+ return {
334
+ ok: false,
335
+ error: `Change to ${file} touches ${touchedMethods} adjacent method(s), exceeds atomic.max_adjacent_methods (${maxAdjacentMethods}). ` +
336
+ 'Split it into smaller atomic fixes or pass force:true if this is a deliberate multi-method change.',
337
+ }
338
+ }
339
+
328
340
  const id = fixId(finding)
329
341
  const registry = readRegistry(projectRoot)
330
342
  if (findFixRecord(registry, id)) {
@@ -349,7 +361,6 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
349
361
  return { ok: false, error: `failed to write file: ${String(err)}` }
350
362
  }
351
363
 
352
- const hunks = diffLines(current.content, args.content)
353
364
  const record: FixRecord = {
354
365
  id,
355
366
  timestamp,