iterate-plugin 3.5.5 → 3.5.6

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.
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join, resolve, sep } from 'node:path';
4
4
  import yaml from 'js-yaml';
@@ -201,7 +201,27 @@ export function resolveProjectRoot(input, sessionCwd) {
201
201
  if (raw.includes('\0')) {
202
202
  return { ok: false, reason: 'Refusing project root containing NUL bytes.' };
203
203
  }
204
- const root = raw ? resolve(raw) : resolve(effectiveCwd(sessionCwd));
204
+ const rootLexical = raw ? resolve(raw) : resolve(effectiveCwd(sessionCwd));
205
+ // Collapse symlinks (documented contract: "collapsing `..` and symlinks") —
206
+ // a path through a symlinked directory must resolve to its REAL location so
207
+ // two aliases of the same project share one `.iterate/` state, and tools can
208
+ // never read outside the resolved root via a link. Only when the path
209
+ // exists: a not-yet-created target (dirs created through the tools) keeps
210
+ // the lexical path so the caller can still write into it. The filesystem
211
+ // root check runs against the NORMALIZED path, so a symlink to `/` is
212
+ // refused too.
213
+ let root = rootLexical;
214
+ try {
215
+ if (existsSync(rootLexical)) {
216
+ const real = realpathSync(rootLexical);
217
+ if (real)
218
+ root = real;
219
+ }
220
+ }
221
+ catch {
222
+ // realpath can throw on a broken link or a permission fence — keep the
223
+ // lexical path (existing behavior) rather than failing the resolution.
224
+ }
205
225
  if (!root || root === sep) {
206
226
  return { ok: false, reason: 'Refusing filesystem root as project root.' };
207
227
  }
@@ -25,6 +25,8 @@ export const MAX_MAX_ROUNDS = 100;
25
25
  export const MAX_ATOMIC_MAX_LINES = 10_000;
26
26
  /** Upper bound on `atomic.max_adjacent_methods`. */
27
27
  export const MAX_MAX_ADJACENT_METHODS = 200;
28
+ /** Upper bound on `reviewer.scope_chunk_size` (files per reviewer task batch). */
29
+ export const MAX_SCOPE_CHUNK_SIZE = 1000;
28
30
  /** Keep at most this many timestamped config backups (older ones are removed). */
29
31
  export const MAX_CONFIG_BACKUPS = 5;
30
32
  /**
@@ -78,6 +80,32 @@ export function validateConfigUpdates(updates) {
78
80
  errors.push(`updates.max_rounds must be an integer between 1 and ${MAX_MAX_ROUNDS}`);
79
81
  }
80
82
  }
83
+ if ('reasoning_effort' in updates) {
84
+ if (updates.reasoning_effort !== undefined &&
85
+ updates.reasoning_effort !== 'low' &&
86
+ updates.reasoning_effort !== 'medium' &&
87
+ updates.reasoning_effort !== 'high') {
88
+ errors.push('updates.reasoning_effort must be "low", "medium", or "high"');
89
+ }
90
+ }
91
+ if ('reviewer' in updates) {
92
+ const rv = updates.reviewer;
93
+ if (!rv || typeof rv !== 'object') {
94
+ errors.push('updates.reviewer must be an object');
95
+ }
96
+ else {
97
+ for (const boolKey of ['output_schema_validation', 'evidence_validation', 'coverage_validation']) {
98
+ if (rv[boolKey] !== undefined && typeof rv[boolKey] !== 'boolean') {
99
+ errors.push(`updates.reviewer.${boolKey} must be a boolean`);
100
+ }
101
+ }
102
+ if (rv.scope_chunk_size !== undefined &&
103
+ (typeof rv.scope_chunk_size !== 'number' || !Number.isInteger(rv.scope_chunk_size) ||
104
+ rv.scope_chunk_size < 1 || rv.scope_chunk_size > MAX_SCOPE_CHUNK_SIZE)) {
105
+ errors.push(`updates.reviewer.scope_chunk_size must be an integer between 1 and ${MAX_SCOPE_CHUNK_SIZE}`);
106
+ }
107
+ }
108
+ }
81
109
  if ('review' in updates) {
82
110
  const r = updates.review;
83
111
  if (!r || typeof r !== 'object') {
package/dist/evidence.js CHANGED
@@ -83,7 +83,11 @@ export function verifyLineBounds(line, text) {
83
83
  if (line === undefined || line === null || line === WHOLE_FILE_LINE) {
84
84
  return { inBounds: true, lineTotal };
85
85
  }
86
- if (line < 1)
86
+ // Only a positive INTEGER is a valid line anchor. A fractional line (e.g.
87
+ // 3.7 from a bypassed schema) or a negative value is out of bounds — a
88
+ // fractional line "within" the file would otherwise be accepted and poison
89
+ // the evidence trail with a location nothing can jump to.
90
+ if (line < 1 || !Number.isInteger(line))
87
91
  return { inBounds: false, lineTotal };
88
92
  return { inBounds: line <= lineTotal, lineTotal };
89
93
  }
@@ -129,7 +129,11 @@ export function metaReviewReport(report) {
129
129
  ? reportRounds[reportRounds.length - 1].round
130
130
  : null;
131
131
  const lastRoundNew = lastRecordedRound !== null && lastRecordedRound > 0
132
- ? Number(findingsByRound[lastRecordedRound - 1] ?? 0)
132
+ // Over-cap rounds are FOLDED by aggregateRounds into the final slot, so a
133
+ // reported round number may exceed the array length — read the SAME
134
+ // clamped index buildReviewReport/computeConvergence use, or a real
135
+ // over-cap round would be mis-read as 0 new (flag mismatch).
136
+ ? Number(findingsByRound[Math.min(lastRecordedRound, findingsByRound.length) - 1] ?? 0)
133
137
  : null;
134
138
  const expectedConverged = lastRoundNew === 0;
135
139
  if (report.convergence?.converged !== expectedConverged) {
package/dist/review.js CHANGED
@@ -144,14 +144,17 @@ export function aggregateRounds(rounds, maxReviewRounds) {
144
144
  }
145
145
  // Clamp the allocation bound so a hostile round number cannot OOM the tool.
146
146
  const effectiveMax = Math.min(maxRound, Math.max(1, roundCap * 2));
147
- const findingsByRound = [];
148
- for (let r = 1; r <= effectiveMax; r++) {
149
- let count = 0;
150
- for (const key of firstRoundByKey.keys()) {
151
- if (firstRoundByKey.get(key) === r)
152
- count++;
153
- }
154
- findingsByRound.push(count);
147
+ const findingsByRound = new Array(effectiveMax).fill(0);
148
+ for (const key of firstRoundByKey.keys()) {
149
+ const firstRound = firstRoundByKey.get(key) ?? 1;
150
+ // Fold rounds BEYOND the allocation bound into the highest slot instead of
151
+ // dropping them: a finding first seen in an over-cap round was previously
152
+ // invisible to `findingsByRound`, so a run whose final rounds exceeded the
153
+ // cap looked "converged" even though a real round found new issues. Folding
154
+ // keeps the counts truthful (the sum still equals totalFindings) while the
155
+ // memory bound stays fixed.
156
+ const idx = Math.min(firstRound, effectiveMax) - 1;
157
+ findingsByRound[idx] = (findingsByRound[idx] || 0) + 1;
155
158
  }
156
159
  return { findings: dedupeFindings(merged), findingsByRound, firstRoundByKey };
157
160
  }
@@ -246,7 +249,12 @@ export function buildReviewReport(input) {
246
249
  if (typeof r.round === 'number' && r.round > lastRound)
247
250
  lastRound = r.round;
248
251
  }
249
- const lastRoundCount = lastRound > 0 ? (findingsByRound[lastRound - 1] ?? 0) : 0;
252
+ // `lastRound` can exceed the array length when over-cap rounds were FOLDED
253
+ // into the last slot (see aggregateRounds). Read the same clamped index
254
+ // computeConvergence uses, so a real over-cap round is never mis-read as 0
255
+ // new findings (which would falsely report "converged").
256
+ const idx = Math.min(lastRound, findingsByRound.length) - 1;
257
+ const lastRoundCount = lastRound > 0 && idx >= 0 ? (findingsByRound[idx] ?? 0) : 0;
250
258
  const converged = filteredRounds.length > 0 && lastRoundCount === 0;
251
259
  // Attach the normal-mode fix count to the summary (dry-run leaves it absent).
252
260
  const computed = summarize(sorted);
@@ -517,6 +525,9 @@ export function attachmentClause(attachments) {
517
525
  export function reviewerTaskPrompt(input) {
518
526
  const parts = [];
519
527
  parts.push(`You are the "${input.dimension}" reviewer for the iterate review.`, `Goal: ${input.goal}`, `Scope: ${input.scope === 'full' ? 'entire codebase' : 'changed files only'}.`);
528
+ if (input.effort) {
529
+ parts.push(`Reasoning effort for this review pass: ${input.effort}.`);
530
+ }
520
531
  if (input.focus) {
521
532
  parts.push(`FOCUS: ${input.focus}`);
522
533
  }
@@ -574,6 +585,11 @@ export function buildReviewPlan(input) {
574
585
  const language = input.config.language === 'zh' ? 'Chinese (中文)' : 'English';
575
586
  const goal = input.config.goal ?? '';
576
587
  const configuredScope = input.config.review?.scope ?? 'full';
588
+ const reasoningEffort = input.config.reasoning_effort === 'low' ||
589
+ input.config.reasoning_effort === 'medium' ||
590
+ input.config.reasoning_effort === 'high'
591
+ ? input.config.reasoning_effort
592
+ : null;
577
593
  const dimensions = Array.isArray(input.config.dimensions) ? input.config.dimensions : [];
578
594
  const maxLines = input.config.atomic?.max_lines ?? 20;
579
595
  const changedFiles = Array.isArray(input.changedFiles) ? input.changedFiles : [];
@@ -632,6 +648,7 @@ export function buildReviewPlan(input) {
632
648
  scopeFiles: batch,
633
649
  focus: focusMap.get(d),
634
650
  attachments,
651
+ effort: reasoningEffort ?? undefined,
635
652
  }),
636
653
  findingsSchema: findingsSchema(),
637
654
  });
@@ -647,5 +664,6 @@ export function buildReviewPlan(input) {
647
664
  changedFiles: effectiveChangedOnly ? changedFiles : [],
648
665
  fallbackToFull,
649
666
  attachments,
667
+ reasoningEffort,
650
668
  };
651
669
  }
@@ -19,7 +19,7 @@ You have the iterate plugin installed, which registers these tools:
19
19
  - \`iterate_diff\` — show the accumulated diff for a fixed file (vs its original backup) or a per-file summary of all fixes
20
20
  - \`iterate_rollback\` — revert a fix by id: restore the file from its backup, remove the fix from the registry, log a \`revert\` entry. Use when a round's validation fails
21
21
  - \`iterate_checkpoint\` — save / load / clear an iteration checkpoint (\`.iterate/checkpoint.json\`) so a long run can resume where it left off
22
- - \`iterate_status\` — summarize the current run: mode, round, fixes applied, architectural remaining, decision-log size, checkpoint presence, and whether the run was interrupted (a checkpoint left on disk means the previous run was interrupted and can be resumed)
22
+ - \`iterate_status\` — summarize the current run: mode, round, fixes applied, architectural remaining, decision-log size, checkpoint presence, and whether the run was interrupted (a checkpoint on disk with no newer decision-log activity means the previous run was interrupted and can be resumed)
23
23
  - \`iterate_history\` — inspect the runtime state in detail: decision-log entries and applied fixes (optionally scoped to a round or a fixed file)
24
24
  - \`iterate_prune\` — remove stale runtime artifacts (\`.iterate/\` entries). Defaults to a read-only dry-run that reports what WOULD be removed; pass \`dryRun:false\` to actually prune.
25
25
  - \`iterate_transcript\` — runtime observatory file (\`.iterate/transcript.json\`). \`read\` fetches the persisted manifest including any steering \`nudge\` for this run's reviewers; \`capture\` (call once after the final report) persists the per-reviewer threads, convergence trend, findings, fixes, checkpoint, and timeline so the client observatory panel reflects the run; \`nudge\` sets/clears steering text the next round's reviewers read. Purely local, never touches source files.
@@ -80,6 +80,10 @@ const maxRounds = plan.maxReviewRounds
80
80
  const knownIntentional = (plan.knownIntentional || []) // config personalization filter, applied in aggregate
81
81
  let known = [] // cumulative DEDUPED findings fed back to reviewers
82
82
  const rounds = [] // raw per-round findings
83
+ // Tracks whether the LAST executed round produced usable reviewer output. A
84
+ // round where every reviewer subagent failed (or output stayed schema-invalid
85
+ // after retries) is INCONCLUSIVE — it must never make the run look converged.
86
+ let lastRoundOk = true
83
87
 
84
88
  phase('transcript')
85
89
  // Read any steering nudge written (via iterate_transcript nudge) for this run's reviewers.
@@ -117,6 +121,12 @@ for (let r = 1; r <= maxRounds; r++) {
117
121
  JSON.stringify(known) + nudge + '\\nReturn the findings JSON object.'
118
122
  return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
119
123
  }))
124
+ // A per-dimension reviewer subagent resolves \`null\` on child failure; the
125
+ // aggregate below would report an all-failed round as an empty round, and
126
+ // the convergence math would then read "0 new findings". An all-failed
127
+ // round is NOT a clean pass — it is inconclusive, so the convergence gate
128
+ // below refuses it before any "converged" signal fires.
129
+ const reviewersOk = raw.length > 0 ? raw.some(x => x !== null && typeof x === 'object') : true
120
130
  const thisRound = {
121
131
  round: r,
122
132
  findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])),
@@ -144,23 +154,30 @@ for (let r = 1; r <= maxRounds; r++) {
144
154
  // final summary can explain WHY the round was inconclusive.
145
155
  log('round ' + r + ' output STILL schema-invalid after 3 attempts — marking round inconclusive (NOT converged)')
146
156
  }
157
+ const roundUnusable = schemaInvalid || !reviewersOk
158
+ if (roundUnusable) lastRoundOk = false
147
159
  // Feed the DEDUPED + already-filtered set back (not raw findings) so the known
148
- // list stays bounded and reviewers never see the same issue twice.
149
- if (agg && agg.report && Array.isArray(agg.report.findings)) known = agg.report.findings
160
+ // list stays bounded and reviewers never see the same issue twice. Never feed
161
+ // an unusable round's empty result — it would erase the still-valid known list
162
+ // and let reviewers re-report everything next round.
163
+ if (!roundUnusable && agg && agg.report && Array.isArray(agg.report.findings)) known = agg.report.findings
150
164
  // A round counts as "converged" ONLY when the aggregate accepted it
151
- // (schema-valid) AND it genuinely found zero NEW findings. If reviewers all
152
- // failed or their output stayed schema-invalid after retries, the round is
153
- // INCONCLUSIVE — never report an empty-but-broken round as converged (an
154
- // invalid/failed round must fail the run, not masquerade as a clean pass).
155
- if (!schemaInvalid) {
165
+ // (schema-valid) AND its reviewers actually returned output AND it genuinely
166
+ // found zero NEW findings. If reviewers all failed or their output stayed
167
+ // schema-invalid after retries, the round is INCONCLUSIVE — never report an
168
+ // empty-but-broken round as converged (an invalid/failed round would
169
+ // otherwise masquerade as a clean pass and stop the run early).
170
+ if (!roundUnusable && !schemaInvalid) {
156
171
  const newCount =
157
172
  agg && agg.report && agg.report.convergence
158
173
  ? agg.report.convergence.findingsByRound[r - 1]
159
174
  : thisRound.findings.length
160
175
  if (newCount === 0) {
161
176
  log('round ' + r + ' found 0 new findings — converged')
177
+ lastRoundOk = true
162
178
  break
163
179
  }
180
+ lastRoundOk = true
164
181
  }
165
182
  }
166
183
 
@@ -196,8 +213,13 @@ return {
196
213
  mode: 'dry-run',
197
214
  goal: report.goal,
198
215
  rounds: rounds.length,
199
- converged: report.convergence.converged,
200
- stoppedReason: report.convergence.stoppedReason,
216
+ // The aggregate's convergence flag is only trustworthy when the last round
217
+ // was usable — an all-failed/schema-invalid final round looks like "0 new"
218
+ // to the convergence math but is an inconclusive run, never a clean pass.
219
+ converged: report.convergence.converged && lastRoundOk,
220
+ stoppedReason: report.convergence.converged && !lastRoundOk
221
+ ? 'inconclusive'
222
+ : report.convergence.stoppedReason,
201
223
  findingsByRound: report.convergence.findingsByRound,
202
224
  totalFindings: report.summary.totalFindings,
203
225
  bySeverity: { critical: report.summary.critical, high: report.summary.high, medium: report.summary.medium, low: report.summary.low },
@@ -259,7 +281,7 @@ const configRes = await agent(
259
281
  const cfg = (configRes && configRes.config) ? configRes.config : null
260
282
  const atomicMaxLines = (cfg && cfg.atomic && cfg.atomic.max_lines) ? cfg.atomic.max_lines : 20
261
283
  const planRes = await agent(
262
- 'Call iterate_review({operation:"plan", mode:"normal", maxReviewRounds:' + (args.maxRounds || 3) + '}) and return the plan JSON.',
284
+ 'Call iterate_review({operation:"plan", mode:"normal"' + (args.maxRounds ? ', maxReviewRounds:' + args.maxRounds : '') + '}) and return the plan JSON.',
263
285
  Object.assign({ label: 'review:plan' }, backend)
264
286
  )
265
287
  const plan = (planRes && planRes.plan) ? planRes.plan : null
@@ -273,6 +295,7 @@ const architectural = (checkpoint && Array.isArray(checkpoint.findings)) ? check
273
295
  let fixedCount = (checkpoint && typeof checkpoint.fixedCount === 'number') ? checkpoint.fixedCount : 0
274
296
  let converged = false
275
297
  let abortedByValidation = false
298
+ let schemaFailed = false // last round produced no usable reviewer output (schema-invalid after retries / all reviewers failed)
276
299
  let failedCommands = []
277
300
  let configErrors = [] // validation commands NOT in validation.commands (config gap, no rollback)
278
301
  const fixRecords = [] // observatory fix records collected round by round
@@ -315,6 +338,10 @@ for (let r = startRound; r <= maxRounds; r++) {
315
338
  '\\n Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + nudge + '\\nReturn the findings JSON object.'
316
339
  return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
317
340
  }))
341
+ // ALL reviewers returning null (child failures) means this round produced
342
+ // no usable output — an empty aggregate must not read like a clean
343
+ // "nothing to fix" convergence. Gate the loop exit + convergence on it.
344
+ const reviewersOk = raw.length > 0 ? raw.some(x => x !== null && typeof x === 'object') : true
318
345
  const thisRound = {
319
346
  round: r,
320
347
  findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])),
@@ -338,10 +365,19 @@ for (let r = startRound; r <= maxRounds; r++) {
338
365
  log('retry ' + attempts + ': round ' + r + ' output failed schema validation — re-running reviewers with strict-JSON emphasis')
339
366
  }
340
367
  } while (schemaInvalid && attempts < 3)
341
- if (schemaInvalid) {
342
- // Bounded exit: never loop forever. Surface the failure so the run's
343
- // final summary can explain WHY the round was inconclusive.
344
- log('round ' + r + ' output STILL schema-invalid after 3 attempts — marking round inconclusive (NOT converged)')
368
+ if (schemaInvalid || !reviewersOk) {
369
+ // Bounded exit: never loop forever, and never let an all-failed/empty round
370
+ // read like a clean convergence. Stop the run WITHOUT applying fixes for
371
+ // this round, record the failure in the audit trail, and leave the
372
+ // checkpoint in place so a user/DM can resume or re-roll after fixing the
373
+ // reviewer configuration.
374
+ schemaFailed = true
375
+ log('round ' + r + ' produced NO usable reviewer output (schema-invalid after retries or all reviewers failed) — stopping run (NOT converged)')
376
+ await agent(
377
+ 'Call iterate_decision_log({operation:"append", type:"round_failed", round:' + r + ', data:{reason:"' + (schemaInvalid ? 'schema_invalid' : 'no_usable_reviewer_output') + '", rolledBack:0}})',
378
+ Object.assign({ label: 'log:schemaFailed:r' + r }, backend)
379
+ )
380
+ break
345
381
  }
346
382
  const findings = (agg && agg.report && agg.report.findings) ? agg.report.findings : thisRound.findings
347
383
  const atomic = findings.filter(f => f.is_atomic === true)
@@ -475,7 +511,7 @@ const statusRes = await agent(
475
511
  { label: 'status:final' }
476
512
  )
477
513
  const status = (statusRes && statusRes.ok) ? statusRes : null
478
- if (!abortedByValidation) {
514
+ if (!abortedByValidation && !schemaFailed) {
479
515
  // Iteration finished cleanly → clear the checkpoint so the next run starts fresh.
480
516
  await agent(
481
517
  'Call iterate_checkpoint({ operation: "clear" }) and return {ok, existed}.',
@@ -494,9 +530,11 @@ if (!abortedByValidation) {
494
530
  fixedCount: fixedCount,
495
531
  resumeCount: effectiveResumeCount,
496
532
  }
497
- const stopReason = abortedByValidation
498
- ? (configErrors.length > 0 ? 'aborted_by_config' : 'aborted_by_validation')
499
- : (converged ? 'converged' : 'max_rounds_reached')
533
+ const stopReason = schemaFailed
534
+ ? 'schema_invalid'
535
+ : abortedByValidation
536
+ ? (configErrors.length > 0 ? 'aborted_by_config' : 'aborted_by_validation')
537
+ : (converged ? 'converged' : 'max_rounds_reached')
500
538
  await agent(
501
539
  'Call iterate_transcript({operation:"capture", mode:"normal", goal:' + JSON.stringify(plan.goal) + ', maxRounds:' + maxRounds + ', roundsExecuted:' + rounds.length + ', findingsByRound:' + JSON.stringify(rounds.map(rr => (rr.findings && rr.findings.length) ? rr.findings.length : 0)) + ', stoppedReason:"' + stopReason + '", fixes:' + JSON.stringify(fixRecords) + ', checkpoint:' + JSON.stringify(obsCheckpoint) + ', rounds:' + JSON.stringify(rounds.map(rr => ({ round: rr.round, findings: rr.findings, readFiles: rr.readFiles }))) + '}). Return {operation:"ok"}.',
502
540
  { label: 'transcript:capture' }
@@ -508,6 +546,7 @@ return {
508
546
  maxRounds: maxRounds,
509
547
  converged: converged,
510
548
  abortedByValidation: abortedByValidation,
549
+ schemaFailed: schemaFailed,
511
550
  failedCommands: failedCommands,
512
551
  configErrors: configErrors,
513
552
  stoppedReason: stopReason,
@@ -528,12 +567,12 @@ return {
528
567
  Key rules for normal mode:
529
568
  - 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.
530
569
  - Aggregate the current round deterministically (\`report.findings\`) before fixing, so fixes act on deduped/filtered/sorted findings.
531
- - **Schema validation & retry**: when \`reviewer.output_schema_validation\` is on (default), retry the round's reviewers up to 2 times (3 attempts total) when \`aggregate\` reports \`schemaValidation\` valid=false for it, then re-aggregate. Never forward schema-invalid findings into \`iterate_fix\`. If the output is still schema-invalid after the 3rd attempt, mark the round inconclusive (never report it as a clean convergence).
570
+ - **Schema validation & retry**: when \`reviewer.output_schema_validation\` is on (default), retry the round's reviewers up to 2 times (3 attempts total) when \`aggregate\` reports \`schemaValidation\` valid=false for it, then re-aggregate. Never forward schema-invalid findings into \`iterate_fix\`. If the output is still schema-invalid after the 3rd attempt — or every reviewer subagent failed outright (no usable output at all) — the round is INCONCLUSIVE: \`break\` the loop, log a \`round_failed\` entry, set \`schemaFailed = true\`, and report \`stoppedReason:"schema_invalid"\`/\`"no_usable_reviewer_output"\`. Never report an inconclusive round as a clean convergence, and keep the checkpoint so the run can be resumed after the reviewer configuration is fixed.
532
571
  - 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.
533
572
  - **Resume**: load the checkpoint first; if a previous run left one, continue from \`checkpoint.round + 1\` (its \`fixedCount\` and deduped \`findings\` are carried forward).
534
573
  - **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). EXCEPTION: \`iterate_validate\` returning \`allowed:false\` means the command is simply not in \`validation.commands\` — a config gap, not broken code. In that case abort WITHOUT rolling back the round's fixes and surface the missing command + file so the user can fix the trust list.
535
574
  - **Checkpoint after every round**; clear it only when the iteration completes cleanly.
536
- - Stop when a round produces nothing to fix (converged) or maxReviewRounds is reached.
575
+ - Stop when a round produces nothing to fix (converged), maxReviewRounds is reached, validation aborts the run, or a round yields no usable reviewer output (\`stoppedReason:"schema_invalid"\`).
537
576
  - Every round, every rollback, and the final report go to the append-only decision log.
538
577
  - Close with \`iterate_status\` metrics and surface the convergence indicators (fixed count, remaining architectural count, abort reason) in the final summary.
539
578
 
@@ -148,9 +148,16 @@ export function computeStatus(input) {
148
148
  findingsCount: Array.isArray(checkpoint?.findings) ? checkpoint.findings.length : 0,
149
149
  totalDecisionLogEntries: entries.length,
150
150
  hasCheckpoint: checkpoint !== null,
151
- // A checkpoint left on disk means the previous run was interrupted before
152
- // it could clear it — this is the durable "interruption" signal.
153
- interrupted: checkpoint !== null,
151
+ // A checkpoint left on disk is the durable "the run did not finish" signal
152
+ // — EXCEPT when a decision-log entry is NEWER than the checkpoint: the
153
+ // workflow saves a checkpoint at the end of each round (right after that
154
+ // round's review_result log), so a live run proceeding past its last
155
+ // checkpoint would otherwise be flagged as "interrupted" for its whole
156
+ // duration. A fresh checkpoint (or no log at all) is genuinely leftover.
157
+ interrupted: checkpoint !== null &&
158
+ !(lastEntry != null &&
159
+ checkpoint.updatedAt &&
160
+ (Date.parse(checkpoint.updatedAt) || 0) >= (Date.parse(lastEntry.timestamp) || 0)),
154
161
  resumeCount: checkpoint?.resumeCount ?? 0,
155
162
  checkpoint,
156
163
  lastUpdated,
@@ -315,7 +322,7 @@ export function registerStatusTool(ctx) {
315
322
  findingsCount: { type: 'integer' },
316
323
  totalDecisionLogEntries: { type: 'integer' },
317
324
  hasCheckpoint: { type: 'boolean' },
318
- interrupted: { type: 'boolean', description: 'True when a checkpoint exists, meaning the previous run was interrupted before finishing.' },
325
+ interrupted: { type: 'boolean', description: 'True when a checkpoint exists and no decision-log entry is newer than it — the previous run left a checkpoint that nothing has resumed or progressed past.' },
319
326
  resumeCount: { type: 'integer', description: 'How many times the current checkpoint has already been resumed.' },
320
327
  lastUpdated: { oneOf: [{ type: 'string' }, { type: 'null' }] },
321
328
  qualityGate: { type: 'json', description: 'v3.0: persisted quality-gate snapshot (.iterate/quality-gate.json), when present.' },
@@ -84,7 +84,7 @@ export function registerHistoryTool(ctx) {
84
84
  },
85
85
  limit: {
86
86
  type: 'integer',
87
- description: `Max entries to return (default: ${DEFAULT_LIMIT}, cap: ${MAX_LIMIT}). Newest first.`,
87
+ description: `Max entries to return (default: ${DEFAULT_LIMIT}, cap: ${MAX_LIMIT}). Returns the newest window in chronological order.`,
88
88
  },
89
89
  path: {
90
90
  type: 'string',
@@ -23,7 +23,7 @@
23
23
  * - dryRun=true by default — the caller must explicitly opt into deletion.
24
24
  * - Each deletion is logged to the decision log (when not dry-run).
25
25
  */
26
- import { existsSync, readdirSync, rmSync, unlinkSync } from 'node:fs';
26
+ import { existsSync, readdirSync, rmSync, unlinkSync, statSync } from 'node:fs';
27
27
  import { join } from 'node:path';
28
28
  import { defineTool } from '@deepseek-ai/dsh-tools';
29
29
  import { resolveProjectRootForExec } from "../config-loader.js";
@@ -143,8 +143,22 @@ export function inspectPrune(projectRoot, retainDays) {
143
143
  // 1. Decision-log entries older than retainDays.
144
144
  const entries = readDecisionEntries(projectRoot);
145
145
  const oldLogEntries = entries.filter((e) => e.timestamp < cutoff).length;
146
- // 2. Checkpoint presence.
146
+ // 2. Checkpoint presence. A checkpoint is only STALE once it is older than
147
+ // retainDays — a fresh checkpoint means a run was interrupted recently and
148
+ // may still be resumed, so `hasCheckpoint` is kept as pure existence while
149
+ // `checkpointStale` decides whether a prune actually deletes it.
147
150
  const hasCheckpoint = existsSync(checkpointPath(projectRoot));
151
+ let checkpointStale = false;
152
+ if (hasCheckpoint) {
153
+ try {
154
+ const st = statSync(checkpointPath(projectRoot));
155
+ if (st && st.mtimeMs < Date.parse(cutoff))
156
+ checkpointStale = true;
157
+ }
158
+ catch {
159
+ checkpointStale = true; // unreadable mtime — treat as garbage / deletable
160
+ }
161
+ }
148
162
  // 3. Stale fix backups: .bak files whose fix-id prefix is not in the registry.
149
163
  const registry = readRegistry(projectRoot);
150
164
  const activeIds = new Set();
@@ -204,6 +218,7 @@ export function inspectPrune(projectRoot, retainDays) {
204
218
  return {
205
219
  oldLogEntries,
206
220
  hasCheckpoint,
221
+ checkpointStale,
207
222
  staleBackups,
208
223
  staleTemps,
209
224
  emptyRounds,
@@ -292,8 +307,10 @@ export function executePrune(projectRoot, retainDays, report) {
292
307
  if (error)
293
308
  result.errors.push(error);
294
309
  }
295
- // 2. Remove checkpoint.
296
- if (report.hasCheckpoint) {
310
+ // 2. Remove checkpoint — ONLY when it is actually stale (older than
311
+ // retainDays). A fresh checkpoint is a live resume point for a recently
312
+ // interrupted run; pruning it would silently destroy the ability to resume.
313
+ if (report.checkpointStale) {
297
314
  try {
298
315
  rmSync(checkpointPath(projectRoot), { force: true });
299
316
  result.deletedCheckpoint = true;
@@ -422,7 +439,7 @@ export function registerPruneTool(ctx) {
422
439
  const lines = [
423
440
  `[dry-run] prune report (retainDays=${value.retainDays}):`,
424
441
  ` Decision-log entries to remove: ${report?.oldLogEntries ?? '?'} (of ${report?.totalLogEntries ?? '?'})`,
425
- ` Checkpoint to delete: ${report?.hasCheckpoint ? 'yes' : 'none'}`,
442
+ ` Checkpoint to delete: ${report?.checkpointStale ? 'yes (stale)' : report?.hasCheckpoint ? 'no (fresh — resume point, kept)' : 'none'}`,
426
443
  ` Stale backups to delete: ${report?.staleBackups?.length ?? 0}`,
427
444
  ` Stray temp files to delete: ${report?.staleTemps?.length ?? 0}`,
428
445
  ` Empty rounds to trim: ${report?.emptyRounds?.length ?? 0}`,
@@ -148,7 +148,7 @@ export function registerTranscriptTool(ctx) {
148
148
  description: 'For `capture`: array of applied fixes [{id, file, round, summary, linesAdded, linesRemoved, success}].',
149
149
  },
150
150
  refReadFiles: { type: 'json', description: 'For `capture`: flat array of all read files across rounds (optional).' },
151
- stoppedReason: { type: 'string', description: 'For `capture`: why the run ended — "converged" | "max_rounds_reached" | "aborted_by_validation". When omitted, derived from the convergence trend (trailing 0 = converged, otherwise = max_rounds_reached once a round ran).' },
151
+ stoppedReason: { type: 'string', description: 'For `capture`: why the run ended — "converged" | "max_rounds_reached" | "aborted_by_validation" | "aborted_by_config" | "schema_invalid" | "no_usable_reviewer_output" | "inconclusive". When omitted, derived from the convergence trend (trailing 0 = converged, otherwise = max_rounds_reached once a round ran).' },
152
152
  text: { type: 'string', description: 'For `nudge`: steering text to set (or null to clear).' },
153
153
  path: { type: 'string', description: 'Project root directory (default: current working directory).' },
154
154
  },
@@ -344,13 +344,24 @@ function rehydrateBuilder(manifest, approval) {
344
344
  maxRounds: manifest.maxRounds,
345
345
  });
346
346
  for (const r of Array.isArray(manifest.rounds) ? manifest.rounds : []) {
347
+ // A malformed/null round row must be skipped, never a throw (the caller
348
+ // wraps rehydrate in a fallback, but preserving sibling rows is better).
349
+ if (!r || typeof r !== 'object')
350
+ continue;
347
351
  builder.roundStart(r.round, manifest.maxRounds);
348
352
  for (const t of Array.isArray(r.threads) ? r.threads : []) {
349
- builder.reviewerStart(t.dimension || 'review', t.attempt || 1);
350
- builder.reviewerMessage((t.messages ?? []).join('\n'));
351
- builder.reviewerRead(t.readFiles ?? []);
352
- for (const f of t.findings ?? [])
353
- builder.reviewerFindings([f]);
353
+ // restoreThread (not reviewerStart + reviewerMessage(join)) preserves the
354
+ // message ARRAY boundaries and the thread's own dimension: rehydration
355
+ // previously collapsed each thread's messages into one string and, once a
356
+ // round hit the per-round thread cap, silently DROPPED the extra threads
357
+ // / mis-merged their findings into the previous dimension's thread.
358
+ builder.restoreThread(r.round, {
359
+ dimension: t.dimension,
360
+ attempt: t.attempt,
361
+ messages: t.messages,
362
+ readFiles: t.readFiles,
363
+ findings: t.findings,
364
+ });
354
365
  }
355
366
  }
356
367
  for (let idx = 0; idx < (manifest.convergence ?? []).length; idx += 1) {
@@ -82,7 +82,9 @@ function normalizeFinding(input) {
82
82
  const severity = sev === 'critical' || sev === 'high' || sev === 'medium' || sev === 'low'
83
83
  ? sev
84
84
  : 'low';
85
- const line = typeof f.line === 'number' && Number.isFinite(f.line) ? f.line : 0;
85
+ // Only a non-negative integer is a real line anchor; a fractional/malformed
86
+ // value (e.g. 3.7 from a bypassed schema) must not leak into the manifest.
87
+ const line = typeof f.line === 'number' && Number.isInteger(f.line) && f.line >= 0 ? f.line : 0;
86
88
  return {
87
89
  dimension,
88
90
  file,
@@ -219,7 +221,9 @@ export class ReviewTranscriptBuilder {
219
221
  const att = typeof attempt === 'number' && Number.isFinite(attempt) ? Math.floor(attempt) : 1;
220
222
  this.roundStart(this.round);
221
223
  const live = this.rounds[this.round - 1];
222
- if (live && this.threadCount(live) < MAX_THREADS_PER_ROUND) {
224
+ if (!live)
225
+ return;
226
+ if (this.threadCount(live) < MAX_THREADS_PER_ROUND) {
223
227
  live.threads.push({
224
228
  dimension: dim || 'review',
225
229
  attempt: att > 0 ? att : 1,
@@ -228,6 +232,78 @@ export class ReviewTranscriptBuilder {
228
232
  findings: [],
229
233
  });
230
234
  }
235
+ else {
236
+ // Thread cap reached: route the extra dimension/retry into ONE shared
237
+ // "other" overflow thread instead of silently dropping it. A dropped
238
+ // reviewerStart used to leave `currentThread()` pointing at the PREVIOUS
239
+ // dimension's thread, so its findings were mis-attributed to that
240
+ // dimension. The overflow thread stays bounded (at most ONE extra thread
241
+ // per round) while keeping over-cap findings attributable to "other".
242
+ let overflow = live.threads.find((t) => t.dimension === 'other');
243
+ if (!overflow) {
244
+ overflow = {
245
+ dimension: 'other',
246
+ attempt: att > 0 ? att : 1,
247
+ messages: [],
248
+ readFiles: [],
249
+ findings: [],
250
+ };
251
+ live.threads.push(overflow);
252
+ }
253
+ }
254
+ this.touch();
255
+ }
256
+ /**
257
+ * Restore ONE persisted thread during rehydration, preserving the message
258
+ * ARRAY boundaries (a rehydrated manifest is re-serialized on the next nudge
259
+ * edit, so collapsed messages would silently rewrite the evidence) and the
260
+ * thread's own dimension (never merging it into a previous dimension's
261
+ * thread). Bypasses the live per-round cap — the manifest already enforced
262
+ * it at capture time — but every restored field is hard-bounded so a hostile
263
+ * hand-edited manifest can never blow up memory.
264
+ */
265
+ restoreThread(round, thread) {
266
+ const r = typeof round === 'number' && Number.isFinite(round)
267
+ ? Math.min(Math.floor(round), MAX_ROUNDS)
268
+ : 1;
269
+ this.roundStart(r);
270
+ const live = this.rounds[this.round - 1];
271
+ if (!live)
272
+ return;
273
+ if (!thread || typeof thread !== 'object')
274
+ return;
275
+ // Hostile-manifest guard: keep the live hard bound (with headroom for the
276
+ // single overflow thread) and newest-first truncate beyond it.
277
+ if (live.threads.length >= MAX_THREADS_PER_ROUND * 2 + 1)
278
+ return;
279
+ const dim = typeof thread.dimension === 'string' && thread.dimension.trim()
280
+ ? thread.dimension.trim()
281
+ : 'review';
282
+ const att = typeof thread.attempt === 'number' && Number.isFinite(thread.attempt)
283
+ ? Math.floor(thread.attempt)
284
+ : 1;
285
+ const t = {
286
+ dimension: dim,
287
+ attempt: att > 0 ? att : 1,
288
+ messages: [],
289
+ readFiles: [],
290
+ findings: [],
291
+ };
292
+ const messages = Array.isArray(thread.messages) ? thread.messages : [];
293
+ for (const m of messages.slice(0, MAX_MESSAGES_PER_THREAD)) {
294
+ if (typeof m === 'string' && m.trim())
295
+ t.messages.push(m);
296
+ }
297
+ if (Array.isArray(thread.readFiles)) {
298
+ t.readFiles.push(...dedupePaths(thread.readFiles));
299
+ }
300
+ if (Array.isArray(thread.findings)) {
301
+ for (const f of thread.findings) {
302
+ if (normalizeFinding(f))
303
+ pushThreadFinding(t, f);
304
+ }
305
+ }
306
+ live.threads.push(t);
231
307
  this.touch();
232
308
  }
233
309
  /** Append narration (assistant text) to the current reviewer thread. */