iterate-plugin 3.5.5 → 3.5.7

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,13 @@ 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
87
+ // Slot index of the current round within this run's rounds array. A resumed
88
+ // run starts at startRound > 1, so the round NUMBER is not a valid array index.
89
+ let roundSlot = -1
83
90
 
84
91
  phase('transcript')
85
92
  // Read any steering nudge written (via iterate_transcript nudge) for this run's reviewers.
@@ -92,9 +99,14 @@ const steering = transRead && typeof transRead.nudge === 'string' && transRead.n
92
99
  phase('review')
93
100
  for (let r = 1; r <= maxRounds; r++) {
94
101
  log('round ' + r + ' of ' + maxRounds + ' — finding NEW issues only')
102
+ roundSlot = rounds.length
95
103
  let agg = null
96
104
  let schemaInvalid = false
97
105
  let attempts = 0
106
+ // Declared OUTSIDE the do-block: it is read after the retry loop exits, and a
107
+ // block-scoped const here raised ReferenceError at runtime (crashing both
108
+ // canonical workflows after the first round).
109
+ let reviewersOk = true
98
110
  do {
99
111
  attempts += 1
100
112
  // Schema validation retry: on the 2nd+ pass, nudge reviewers toward strict JSON.
@@ -117,6 +129,12 @@ for (let r = 1; r <= maxRounds; r++) {
117
129
  JSON.stringify(known) + nudge + '\\nReturn the findings JSON object.'
118
130
  return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
119
131
  }))
132
+ // A per-dimension reviewer subagent resolves \`null\` on child failure; the
133
+ // aggregate below would report an all-failed round as an empty round, and
134
+ // the convergence math would then read "0 new findings". An all-failed
135
+ // round is NOT a clean pass — it is inconclusive, so the convergence gate
136
+ // below refuses it before any "converged" signal fires.
137
+ reviewersOk = raw.length > 0 ? raw.some(x => x !== null && typeof x === 'object') : true
120
138
  const thisRound = {
121
139
  round: r,
122
140
  findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])),
@@ -124,7 +142,7 @@ for (let r = 1; r <= maxRounds; r++) {
124
142
  // gate can compare self-reported reads against the assigned inventory.
125
143
  readFiles: [].concat(...raw.map(x => x && Array.isArray(x.readFiles) ? x.readFiles : [])),
126
144
  }
127
- if (rounds.length >= r) rounds[r - 1] = thisRound; else rounds.push(thisRound)
145
+ if (roundSlot < rounds.length) rounds[roundSlot] = thisRound; else rounds.push(thisRound)
128
146
  // Deterministic aggregate: cross-round dedupe + known_intentional filter + severity sort.
129
147
  agg = await agent(
130
148
  'Call iterate_review({operation:"aggregate", mode:"dry-run", rounds:' + JSON.stringify(rounds) + ', maxReviewRounds:' + maxRounds + ', knownIntentional:' + JSON.stringify(knownIntentional) + '}) and return the report JSON.',
@@ -144,23 +162,30 @@ for (let r = 1; r <= maxRounds; r++) {
144
162
  // final summary can explain WHY the round was inconclusive.
145
163
  log('round ' + r + ' output STILL schema-invalid after 3 attempts — marking round inconclusive (NOT converged)')
146
164
  }
165
+ const roundUnusable = schemaInvalid || !reviewersOk
166
+ if (roundUnusable) lastRoundOk = false
147
167
  // 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
168
+ // list stays bounded and reviewers never see the same issue twice. Never feed
169
+ // an unusable round's empty result — it would erase the still-valid known list
170
+ // and let reviewers re-report everything next round.
171
+ if (!roundUnusable && agg && agg.report && Array.isArray(agg.report.findings)) known = agg.report.findings
150
172
  // 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) {
173
+ // (schema-valid) AND its reviewers actually returned output AND it genuinely
174
+ // found zero NEW findings. If reviewers all failed or their output stayed
175
+ // schema-invalid after retries, the round is INCONCLUSIVE — never report an
176
+ // empty-but-broken round as converged (an invalid/failed round would
177
+ // otherwise masquerade as a clean pass and stop the run early).
178
+ if (!roundUnusable && !schemaInvalid) {
156
179
  const newCount =
157
180
  agg && agg.report && agg.report.convergence
158
181
  ? agg.report.convergence.findingsByRound[r - 1]
159
182
  : thisRound.findings.length
160
183
  if (newCount === 0) {
161
184
  log('round ' + r + ' found 0 new findings — converged')
185
+ lastRoundOk = true
162
186
  break
163
187
  }
188
+ lastRoundOk = true
164
189
  }
165
190
  }
166
191
 
@@ -196,8 +221,13 @@ return {
196
221
  mode: 'dry-run',
197
222
  goal: report.goal,
198
223
  rounds: rounds.length,
199
- converged: report.convergence.converged,
200
- stoppedReason: report.convergence.stoppedReason,
224
+ // The aggregate's convergence flag is only trustworthy when the last round
225
+ // was usable — an all-failed/schema-invalid final round looks like "0 new"
226
+ // to the convergence math but is an inconclusive run, never a clean pass.
227
+ converged: report.convergence.converged && lastRoundOk,
228
+ stoppedReason: report.convergence.converged && !lastRoundOk
229
+ ? 'inconclusive'
230
+ : report.convergence.stoppedReason,
201
231
  findingsByRound: report.convergence.findingsByRound,
202
232
  totalFindings: report.summary.totalFindings,
203
233
  bySeverity: { critical: report.summary.critical, high: report.summary.high, medium: report.summary.medium, low: report.summary.low },
@@ -259,7 +289,7 @@ const configRes = await agent(
259
289
  const cfg = (configRes && configRes.config) ? configRes.config : null
260
290
  const atomicMaxLines = (cfg && cfg.atomic && cfg.atomic.max_lines) ? cfg.atomic.max_lines : 20
261
291
  const planRes = await agent(
262
- 'Call iterate_review({operation:"plan", mode:"normal", maxReviewRounds:' + (args.maxRounds || 3) + '}) and return the plan JSON.',
292
+ 'Call iterate_review({operation:"plan", mode:"normal"' + (args.maxRounds ? ', maxReviewRounds:' + args.maxRounds : '') + '}) and return the plan JSON.',
263
293
  Object.assign({ label: 'review:plan' }, backend)
264
294
  )
265
295
  const plan = (planRes && planRes.plan) ? planRes.plan : null
@@ -273,9 +303,16 @@ const architectural = (checkpoint && Array.isArray(checkpoint.findings)) ? check
273
303
  let fixedCount = (checkpoint && typeof checkpoint.fixedCount === 'number') ? checkpoint.fixedCount : 0
274
304
  let converged = false
275
305
  let abortedByValidation = false
306
+ let schemaFailed = false // last round produced no usable reviewer output (schema-invalid after retries / all reviewers failed)
276
307
  let failedCommands = []
277
308
  let configErrors = [] // validation commands NOT in validation.commands (config gap, no rollback)
278
309
  const fixRecords = [] // observatory fix records collected round by round
310
+ // Slot index of the current round within this run's rounds array. A resumed
311
+ // run starts at startRound > 1, so the round NUMBER is not a valid array index.
312
+ let roundSlot = -1
313
+ // Highest round number actually executed by THIS run (carried so a resumed run
314
+ // reports its true round, not the count of rounds it happened to run).
315
+ let lastRound = startRound - 1
279
316
 
280
317
  // Read any steering nudge intended for this run's reviewers.
281
318
  const transRead = await agent(
@@ -287,6 +324,8 @@ const steering = transRead && typeof transRead.nudge === 'string' && transRead.n
287
324
  phase('loop')
288
325
  for (let r = startRound; r <= maxRounds; r++) {
289
326
  log('round ' + r + ' of ' + maxRounds + ' — review current state, fix atomics via iterate_fix, validate')
327
+ roundSlot = rounds.length
328
+ lastRound = r
290
329
  // Audit-trail: record the round start (SKILL.md Phase 4 requires per-round records).
291
330
  await agent(
292
331
  'Call iterate_decision_log({operation:"append", type:"round_start", round:' + r + ', data:{maxRounds:' + maxRounds + ', fixedSoFar:' + fixedCount + '}})',
@@ -295,6 +334,10 @@ for (let r = startRound; r <= maxRounds; r++) {
295
334
  let agg = null
296
335
  let schemaInvalid = false
297
336
  let attempts = 0
337
+ // Declared OUTSIDE the do-block: it is read after the retry loop exits, and a
338
+ // block-scoped const here raised ReferenceError at runtime (crashing both
339
+ // canonical workflows after the first round).
340
+ let reviewersOk = true
298
341
  do {
299
342
  attempts += 1
300
343
  // Schema validation retry: on the 2nd+ pass, nudge reviewers toward strict JSON.
@@ -315,12 +358,16 @@ for (let r = startRound; r <= maxRounds; r++) {
315
358
  '\\n Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + nudge + '\\nReturn the findings JSON object.'
316
359
  return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
317
360
  }))
361
+ // ALL reviewers returning null (child failures) means this round produced
362
+ // no usable output — an empty aggregate must not read like a clean
363
+ // "nothing to fix" convergence. Gate the loop exit + convergence on it.
364
+ reviewersOk = raw.length > 0 ? raw.some(x => x !== null && typeof x === 'object') : true
318
365
  const thisRound = {
319
366
  round: r,
320
367
  findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])),
321
368
  readFiles: [].concat(...raw.map(x => x && Array.isArray(x.readFiles) ? x.readFiles : [])),
322
369
  }
323
- if (rounds.length >= r) rounds[r - 1] = thisRound; else rounds.push(thisRound)
370
+ if (roundSlot < rounds.length) rounds[roundSlot] = thisRound; else rounds.push(thisRound)
324
371
 
325
372
  // Deterministic dedupe / known_intentional filter / severity sort for this round.
326
373
  // \`fixedCount\` is threaded into the report summary so the client dashboard can
@@ -338,10 +385,19 @@ for (let r = startRound; r <= maxRounds; r++) {
338
385
  log('retry ' + attempts + ': round ' + r + ' output failed schema validation — re-running reviewers with strict-JSON emphasis')
339
386
  }
340
387
  } 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)')
388
+ if (schemaInvalid || !reviewersOk) {
389
+ // Bounded exit: never loop forever, and never let an all-failed/empty round
390
+ // read like a clean convergence. Stop the run WITHOUT applying fixes for
391
+ // this round, record the failure in the audit trail, and leave the
392
+ // checkpoint in place so a user/DM can resume or re-roll after fixing the
393
+ // reviewer configuration.
394
+ schemaFailed = true
395
+ log('round ' + r + ' produced NO usable reviewer output (schema-invalid after retries or all reviewers failed) — stopping run (NOT converged)')
396
+ await agent(
397
+ 'Call iterate_decision_log({operation:"append", type:"round_failed", round:' + r + ', data:{reason:"' + (schemaInvalid ? 'schema_invalid' : 'no_usable_reviewer_output') + '", rolledBack:0}})',
398
+ Object.assign({ label: 'log:schemaFailed:r' + r }, backend)
399
+ )
400
+ break
345
401
  }
346
402
  const findings = (agg && agg.report && agg.report.findings) ? agg.report.findings : thisRound.findings
347
403
  const atomic = findings.filter(f => f.is_atomic === true)
@@ -466,7 +522,7 @@ for (let r = startRound; r <= maxRounds; r++) {
466
522
 
467
523
  phase('report')
468
524
  await agent(
469
- 'Call iterate_decision_log({operation:"append", type:"report", round:' + rounds.length +
525
+ 'Call iterate_decision_log({operation:"append", type:"report", round:' + lastRound +
470
526
  ', data:{mode:"normal", fixed:' + fixedCount + ', architectural:' + architectural.length + '}})',
471
527
  { label: 'report:log' }
472
528
  )
@@ -475,7 +531,7 @@ const statusRes = await agent(
475
531
  { label: 'status:final' }
476
532
  )
477
533
  const status = (statusRes && statusRes.ok) ? statusRes : null
478
- if (!abortedByValidation) {
534
+ if (!abortedByValidation && !schemaFailed) {
479
535
  // Iteration finished cleanly → clear the checkpoint so the next run starts fresh.
480
536
  await agent(
481
537
  'Call iterate_checkpoint({ operation: "clear" }) and return {ok, existed}.',
@@ -489,25 +545,28 @@ if (!abortedByValidation) {
489
545
  // would hide the very thing F5 is meant to resume. Reflect what is on disk.
490
546
  const obsCheckpoint = {
491
547
  mode: 'normal',
492
- round: rounds.length,
548
+ round: lastRound,
493
549
  maxRounds: maxRounds,
494
550
  fixedCount: fixedCount,
495
551
  resumeCount: effectiveResumeCount,
496
552
  }
497
- const stopReason = abortedByValidation
498
- ? (configErrors.length > 0 ? 'aborted_by_config' : 'aborted_by_validation')
499
- : (converged ? 'converged' : 'max_rounds_reached')
553
+ const stopReason = schemaFailed
554
+ ? 'schema_invalid'
555
+ : abortedByValidation
556
+ ? (configErrors.length > 0 ? 'aborted_by_config' : 'aborted_by_validation')
557
+ : (converged ? 'converged' : 'max_rounds_reached')
500
558
  await agent(
501
- '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"}.',
559
+ 'Call iterate_transcript({operation:"capture", mode:"normal", goal:' + JSON.stringify(plan.goal) + ', maxRounds:' + maxRounds + ', roundsExecuted:' + lastRound + ', 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
560
  { label: 'transcript:capture' }
503
561
  )
504
562
  return {
505
563
  mode: 'normal',
506
564
  goal: plan.goal,
507
- roundsExecuted: rounds.length,
565
+ roundsExecuted: lastRound,
508
566
  maxRounds: maxRounds,
509
567
  converged: converged,
510
568
  abortedByValidation: abortedByValidation,
569
+ schemaFailed: schemaFailed,
511
570
  failedCommands: failedCommands,
512
571
  configErrors: configErrors,
513
572
  stoppedReason: stopReason,
@@ -528,12 +587,12 @@ return {
528
587
  Key rules for normal mode:
529
588
  - 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
589
  - 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).
590
+ - **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
591
  - 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
592
  - **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
593
  - **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
594
  - **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.
595
+ - 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
596
  - Every round, every rollback, and the final report go to the append-only decision log.
538
597
  - Close with \`iterate_status\` metrics and surface the convergence indicators (fixed count, remaining architectural count, abort reason) in the final summary.
539
598
 
@@ -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.' },
@@ -122,6 +122,17 @@ function recountEntries(filePath) {
122
122
  return 1;
123
123
  }
124
124
  }
125
+ /**
126
+ * Drop the cached entry count for a log file. The cache is keyed by the log
127
+ * path and assumes "same byte size ⇒ same entry count"; a prune rewrite
128
+ * (temp + atomic rename) replaces the file with a DIFFERENT number of entries,
129
+ * so only a stale-cache invalidation keeps the fast path honest. Without it, a
130
+ * rewrite that lands on the exact same byte size as the previous append would
131
+ * make the next append report a wrong `count`.
132
+ */
133
+ export function invalidateLogCountCache(filePath) {
134
+ entryCountCache.delete(filePath);
135
+ }
125
136
  /** All valid DecisionLogEntry `type` values (must stay in sync with Types). */
126
137
  const VALID_ENTRY_TYPES = new Set([
127
138
  'round_start',
@@ -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,12 +23,12 @@
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";
30
30
  import { writeTextAtomic, writeJsonAtomic } from "../atomic-fs.js";
31
- import { readDecisionEntries, appendDecisionEntry, acquireLogLock } from "./decision-log.js";
31
+ import { readDecisionEntries, appendDecisionEntry, acquireLogLock, invalidateLogCountCache } from "./decision-log.js";
32
32
  import { readRegistry, recomputeRoundCounts } from "./fix.js";
33
33
  import { readExperienceBank, writeExperienceBank } from "./experience-store.js";
34
34
  import { readDefenseEvents, writeDefenseEvents } from "./defense-store.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,
@@ -248,6 +263,12 @@ function rewriteDecisionLogKeepingRecent(projectRoot, cutoff) {
248
263
  if (deleted === 0)
249
264
  return { deleted: 0 };
250
265
  writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n');
266
+ // The append-side entry-count cache is keyed by path and assumes "same
267
+ // byte size ⇒ same count"; the rename above replaced the file with a
268
+ // different number of entries, so drop the stale cache or the next
269
+ // append can report a wrong entryCount when the sizes coincidentally
270
+ // match.
271
+ invalidateLogCountCache(logPath);
251
272
  // A concurrent appender (cross-process, lock-unavailable path) may have
252
273
  // landed new lines after our read. If the log grew during the write
253
274
  // window, re-read and prune again instead of accepting a lost audit trail.
@@ -292,8 +313,10 @@ export function executePrune(projectRoot, retainDays, report) {
292
313
  if (error)
293
314
  result.errors.push(error);
294
315
  }
295
- // 2. Remove checkpoint.
296
- if (report.hasCheckpoint) {
316
+ // 2. Remove checkpoint — ONLY when it is actually stale (older than
317
+ // retainDays). A fresh checkpoint is a live resume point for a recently
318
+ // interrupted run; pruning it would silently destroy the ability to resume.
319
+ if (report.checkpointStale) {
297
320
  try {
298
321
  rmSync(checkpointPath(projectRoot), { force: true });
299
322
  result.deletedCheckpoint = true;
@@ -422,7 +445,7 @@ export function registerPruneTool(ctx) {
422
445
  const lines = [
423
446
  `[dry-run] prune report (retainDays=${value.retainDays}):`,
424
447
  ` Decision-log entries to remove: ${report?.oldLogEntries ?? '?'} (of ${report?.totalLogEntries ?? '?'})`,
425
- ` Checkpoint to delete: ${report?.hasCheckpoint ? 'yes' : 'none'}`,
448
+ ` Checkpoint to delete: ${report?.checkpointStale ? 'yes (stale)' : report?.hasCheckpoint ? 'no (fresh — resume point, kept)' : 'none'}`,
426
449
  ` Stale backups to delete: ${report?.staleBackups?.length ?? 0}`,
427
450
  ` Stray temp files to delete: ${report?.staleTemps?.length ?? 0}`,
428
451
  ` Empty rounds to trim: ${report?.emptyRounds?.length ?? 0}`,