iterate-plugin 2.8.0 → 2.8.2

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.
@@ -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
@@ -124,6 +142,7 @@ Key rules for dry-run:
124
142
  - **NEVER call a fixer / never edit files / never create branches or worktree.** Reviewers read only.
125
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.
126
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.
127
146
  - Stop when a round reports 0 new findings (converged) or maxReviewRounds is reached.
128
147
  - The report (with per-round convergence stats + suggested fix priorities) is the deliverable.
129
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.
@@ -170,21 +189,39 @@ let failedCommands = []
170
189
  phase('loop')
171
190
  for (let r = startRound; r <= maxRounds; r++) {
172
191
  log('round ' + r + ' of ' + maxRounds + ' — review current state, fix atomics via iterate_fix, validate')
173
- const raw = await parallel(dims.map(dim => () => agent(
174
- 'Review dimension "' + dim + '" on the CURRENT code state (previous atomic findings are fixed). ' +
175
- 'Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + '\\nReturn the findings JSON object.',
176
- { label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }
177
- )))
178
- const thisRound = { round: r, findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])) }
179
- 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)
180
207
 
181
- // Deterministic dedupe / known_intentional filter / severity sort for this round.
182
- // \`fixedCount\` is threaded into the report summary so the client dashboard can
183
- // show a running "fixes applied" metric for normal mode.
184
- const agg = await agent(
185
- 'Call iterate_review({operation:"aggregate", mode:"normal", rounds:' + JSON.stringify([thisRound]) + ', knownIntentional:' + JSON.stringify(knownIntentional) + ', fixedCount:' + fixedCount + '}) and return the report JSON.',
186
- { label: 'review:aggregate:r' + r }
187
- )
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)
188
225
  const findings = (agg && agg.report && agg.report.findings) ? agg.report.findings : thisRound.findings
189
226
  const atomic = findings.filter(f => f.is_atomic === true)
190
227
  const remaining = findings.filter(f => f.is_atomic !== true)
@@ -321,6 +358,7 @@ return {
321
358
  Key rules for normal mode:
322
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.
323
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\`.
324
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.
325
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).
326
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).
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,
@@ -1,10 +1,22 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools'
2
2
  import type { JsonValue } from '@deepseek-ai/dsh-session'
3
3
  import { loadEffectiveConfig, resolveProjectRoot } from '../config-loader.ts'
4
- import { buildReviewPlan, buildReviewReport } from '../review.ts'
4
+ import {
5
+ buildReviewPlan,
6
+ buildReviewReport,
7
+ sanitizeRounds,
8
+ validateRoundsSchema,
9
+ } from '../review.ts'
5
10
  import { buildFinalReviewReport, metaReviewReport } from '../meta-review.ts'
6
11
  import { evidenceToPlain, verifyFindings } from '../evidence.ts'
12
+ import {
13
+ collectScopeFiles,
14
+ computeCoverage,
15
+ coverageToDict,
16
+ } from '../review-scope.ts'
17
+ import { resolveChangedFiles } from '../git-scope.ts'
7
18
  import type { KnownIntentional, ReviewFinding, ReviewReport, ReviewRound } from '../types.ts'
19
+ import type { CoverageResult } from '../review-scope.ts'
8
20
 
9
21
  /** Default round cap when neither the arg nor config provides one. */
10
22
  const DEFAULT_MAX_REVIEW_ROUNDS = 3
@@ -95,7 +107,21 @@ export function registerReviewTool(ctx: { tools: { register: (def: ReturnType<ty
95
107
  found: { type: 'boolean' },
96
108
  plan: { type: 'json' },
97
109
  report: { type: 'json' },
110
+ schemaValidation: {
111
+ type: 'json',
112
+ description:
113
+ 'For `aggregate`: per-round schema validation results (round, valid, issues). ' +
114
+ 'Present only when reviewer.output_schema_validation is enabled; the workflow ' +
115
+ 'retries rounds with valid=false (≤2 times) before forwarding findings.',
116
+ },
98
117
  evidence: { type: 'json' },
118
+ coverage: {
119
+ type: 'json',
120
+ description:
121
+ 'For `meta-review`: prompt-informative scope coverage result ' +
122
+ '(assigned vs self-reported reads). Present only when ' +
123
+ 'reviewer.coverage_validation is enabled and readFiles were supplied.',
124
+ },
99
125
  finalReport: { type: 'json' },
100
126
  error: { type: 'string' },
101
127
  },
@@ -120,7 +146,23 @@ export function registerReviewTool(ctx: { tools: { register: (def: ReturnType<ty
120
146
  const maxReviewRounds = args.maxReviewRounds ?? config.max_rounds ?? DEFAULT_MAX_REVIEW_ROUNDS
121
147
  const knownIntentional = (config.personalization as { known_intentional?: KnownIntentional[] } | undefined)
122
148
  ?.known_intentional
123
- const plan = buildReviewPlan({ config, mode, maxReviewRounds, knownIntentional })
149
+ // changed-only scope: resolve the changed-file set against
150
+ // git.target_branch before building the plan so reviewers get the
151
+ // concrete file list (and the plan auto-falls back to full when there
152
+ // are no changes). git failures degrade to a full-scope plan.
153
+ let changedFiles: string[] | undefined
154
+ if (config.review?.scope === 'changed-only') {
155
+ const gitScope = await resolveChangedFiles(projectRoot, config.git?.target_branch ?? 'main')
156
+ changedFiles = gitScope.changedFiles
157
+ }
158
+ // Full-codebase review: pre-collect the source inventory so
159
+ // buildReviewPlan can batch it into per-chunk reviewer tasks
160
+ // (coverage enforcement).
161
+ let scopeFiles: string[] | undefined
162
+ if (config.review?.scope === 'full') {
163
+ scopeFiles = collectScopeFiles(projectRoot, { scope: 'full' })
164
+ }
165
+ const plan = buildReviewPlan({ config, mode, maxReviewRounds, knownIntentional, changedFiles, scopeFiles })
124
166
  return { operation: 'plan', mode, found: true, plan: plan as unknown as JsonValue }
125
167
  }
126
168
 
@@ -145,16 +187,34 @@ export function registerReviewTool(ctx: { tools: { register: (def: ReturnType<ty
145
187
  const maxReviewRounds = args.maxReviewRounds ?? config.max_rounds ?? DEFAULT_MAX_REVIEW_ROUNDS
146
188
  const goal = args.goal ?? config.goal ?? ''
147
189
  const dimensions = config.dimensions ?? []
190
+
191
+ // Output schema validation gate (reviewer.output_schema_validation,
192
+ // default true): validate every round's findings against the findings
193
+ // schema, then drop schema-invalid entries before the deterministic
194
+ // core so malformed reviewer output can never crash dedupe/sort or
195
+ // leak into fixes. The `schemaValidation` array is surfaced so the
196
+ // workflow can retry failing rounds (≤2 times) with a strict-JSON
197
+ // nudge. When disabled, non-object entries are still dropped for
198
+ // crash-safety.
199
+ const schemaEnabled = config.reviewer?.output_schema_validation !== false
200
+ const schemaValidation = schemaEnabled ? validateRoundsSchema(rounds) : null
201
+ const cleanRounds = sanitizeRounds(rounds, schemaValidation)
202
+
148
203
  const report = buildReviewReport({
149
204
  mode,
150
205
  goal,
151
206
  dimensions,
152
207
  maxReviewRounds,
153
- rounds,
208
+ rounds: cleanRounds,
154
209
  knownIntentional: args.knownIntentional as KnownIntentional[] | undefined,
155
210
  fixedCount: typeof args.fixedCount === 'number' ? args.fixedCount : undefined,
156
211
  })
157
- return { operation: 'aggregate', mode, report: report as unknown as JsonValue }
212
+ return {
213
+ operation: 'aggregate',
214
+ mode,
215
+ report: report as unknown as JsonValue,
216
+ schemaValidation: schemaValidation as unknown as JsonValue | undefined,
217
+ }
158
218
  }
159
219
 
160
220
  if (args.operation === 'meta-review') {
@@ -173,13 +233,30 @@ export function registerReviewTool(ctx: { tools: { register: (def: ReturnType<ty
173
233
  const evidenceEnabled = config.reviewer?.evidence_validation !== false
174
234
  const findings: ReviewFinding[] = Array.isArray(source.findings) ? source.findings : []
175
235
  const evidence = evidenceEnabled ? verifyFindings(projectRoot, findings) : null
176
- const finalReport = buildFinalReviewReport(source, { evidence })
236
+ // Prompt-informative coverage: compare the reviewer's self-reported
237
+ // reads against the assigned scope inventory (never flips the
238
+ // verdict). Disable via config `reviewer.coverage_validation: false`.
239
+ const coverageEnabled = config.reviewer?.coverage_validation !== false
240
+ let coverage: CoverageResult | null = null
241
+ if (coverageEnabled) {
242
+ const assigned = collectScopeFiles(projectRoot, {
243
+ scope: config.review?.scope === 'changed-only' ? 'changed-only' : 'full',
244
+ })
245
+ const readFiles = Array.isArray((source as unknown as { readFiles?: unknown }).readFiles)
246
+ ? ((source as unknown as { readFiles?: unknown }).readFiles as string[])
247
+ : null
248
+ if (readFiles && readFiles.length > 0) {
249
+ coverage = computeCoverage(assigned, readFiles)
250
+ }
251
+ }
252
+ const finalReport = buildFinalReviewReport(source, { evidence, coverage })
177
253
  return {
178
254
  operation: 'meta-review',
179
255
  mode,
180
256
  found: true,
181
257
  report: audit as unknown as JsonValue,
182
258
  evidence: evidence ? (evidenceToPlain(evidence) as unknown as JsonValue) : null,
259
+ coverage: coverage ? (coverageToDict(coverage) as unknown as JsonValue) : null,
183
260
  finalReport: finalReport as unknown as JsonValue,
184
261
  }
185
262
  }
package/src/types.ts CHANGED
@@ -16,7 +16,12 @@ export interface IterateConfig {
16
16
  command_whitelist: string[]
17
17
  commands: Record<string, string[]>
18
18
  }
19
- reviewer: { output_schema_validation: boolean; evidence_validation: boolean }
19
+ reviewer: {
20
+ output_schema_validation: boolean
21
+ evidence_validation: boolean
22
+ coverage_validation: boolean
23
+ scope_chunk_size: number
24
+ }
20
25
  onboarding?: Record<string, unknown>
21
26
  personalization?: Record<string, unknown>
22
27
  }