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.
package/src/review.ts CHANGED
@@ -162,13 +162,17 @@ export function aggregateRounds(
162
162
  // Clamp the allocation bound so a hostile round number cannot OOM the tool.
163
163
  const effectiveMax = Math.min(maxRound, Math.max(1, roundCap * 2))
164
164
 
165
- const findingsByRound: number[] = []
166
- for (let r = 1; r <= effectiveMax; r++) {
167
- let count = 0
168
- for (const key of firstRoundByKey.keys()) {
169
- if (firstRoundByKey.get(key) === r) count++
170
- }
171
- findingsByRound.push(count)
165
+ const findingsByRound: number[] = new Array(effectiveMax).fill(0)
166
+ for (const key of firstRoundByKey.keys()) {
167
+ const firstRound = firstRoundByKey.get(key) ?? 1
168
+ // Fold rounds BEYOND the allocation bound into the highest slot instead of
169
+ // dropping them: a finding first seen in an over-cap round was previously
170
+ // invisible to `findingsByRound`, so a run whose final rounds exceeded the
171
+ // cap looked "converged" even though a real round found new issues. Folding
172
+ // keeps the counts truthful (the sum still equals totalFindings) while the
173
+ // memory bound stays fixed.
174
+ const idx = Math.min(firstRound, effectiveMax) - 1
175
+ findingsByRound[idx] = (findingsByRound[idx] || 0) + 1
172
176
  }
173
177
 
174
178
  return { findings: dedupeFindings(merged), findingsByRound, firstRoundByKey }
@@ -282,8 +286,13 @@ export function buildReviewReport(input: {
282
286
  for (const r of filteredRounds) {
283
287
  if (typeof r.round === 'number' && r.round > lastRound) lastRound = r.round
284
288
  }
289
+ // `lastRound` can exceed the array length when over-cap rounds were FOLDED
290
+ // into the last slot (see aggregateRounds). Read the same clamped index
291
+ // computeConvergence uses, so a real over-cap round is never mis-read as 0
292
+ // new findings (which would falsely report "converged").
293
+ const idx = Math.min(lastRound, findingsByRound.length) - 1
285
294
  const lastRoundCount =
286
- lastRound > 0 ? (findingsByRound[lastRound - 1] ?? 0) : 0
295
+ lastRound > 0 && idx >= 0 ? (findingsByRound[idx] ?? 0) : 0
287
296
  const converged = filteredRounds.length > 0 && lastRoundCount === 0
288
297
 
289
298
  // Attach the normal-mode fix count to the summary (dry-run leaves it absent).
@@ -630,6 +639,12 @@ export function reviewerTaskPrompt(input: {
630
639
  * every dimension reviewer inspects/considers them before judging.
631
640
  */
632
641
  attachments?: ReviewAttachment[]
642
+ /**
643
+ * Configured reasoning effort ('low' | 'medium' | 'high') to apply to this
644
+ * review pass. Injected as a directive so the harness can honor it when
645
+ * running each reviewer subagent. Omitted → follow the provider default.
646
+ */
647
+ effort?: 'low' | 'medium' | 'high'
633
648
  }): string {
634
649
  const parts: string[] = []
635
650
  parts.push(
@@ -637,6 +652,9 @@ export function reviewerTaskPrompt(input: {
637
652
  `Goal: ${input.goal}`,
638
653
  `Scope: ${input.scope === 'full' ? 'entire codebase' : 'changed files only'}.`,
639
654
  )
655
+ if (input.effort) {
656
+ parts.push(`Reasoning effort for this review pass: ${input.effort}.`)
657
+ }
640
658
  if (input.focus) {
641
659
  parts.push(`FOCUS: ${input.focus}`)
642
660
  }
@@ -744,6 +762,12 @@ export function buildReviewPlan(input: {
744
762
  fallbackToFull: boolean
745
763
  /** The attachments threaded into every reviewer prompt (empty when none). */
746
764
  attachments: ReviewAttachment[]
765
+ /**
766
+ * Configured reasoning effort carried on the plan for the orchestrator to
767
+ * honor (null when unset → provider default). Also injected as a directive
768
+ * into every dimension's reviewer prompt.
769
+ */
770
+ reasoningEffort: 'low' | 'medium' | 'high' | null
747
771
  } {
748
772
  // Defensive reads: a malformed config (e.g. `dimensions` as a non-array, or
749
773
  // `review`/`atomic` missing) must degrade to sane defaults instead of
@@ -751,6 +775,12 @@ export function buildReviewPlan(input: {
751
775
  const language = input.config.language === 'zh' ? 'Chinese (中文)' : 'English'
752
776
  const goal = input.config.goal ?? ''
753
777
  const configuredScope = input.config.review?.scope ?? 'full'
778
+ const reasoningEffort =
779
+ input.config.reasoning_effort === 'low' ||
780
+ input.config.reasoning_effort === 'medium' ||
781
+ input.config.reasoning_effort === 'high'
782
+ ? input.config.reasoning_effort
783
+ : null
754
784
  const dimensions = Array.isArray(input.config.dimensions) ? input.config.dimensions : []
755
785
  const maxLines = input.config.atomic?.max_lines ?? 20
756
786
  const changedFiles = Array.isArray(input.changedFiles) ? input.changedFiles : []
@@ -816,6 +846,7 @@ export function buildReviewPlan(input: {
816
846
  scopeFiles: batch,
817
847
  focus: focusMap.get(d),
818
848
  attachments,
849
+ effort: reasoningEffort ?? undefined,
819
850
  }),
820
851
  findingsSchema: findingsSchema(),
821
852
  })
@@ -832,5 +863,6 @@ export function buildReviewPlan(input: {
832
863
  changedFiles: effectiveChangedOnly ? changedFiles : [],
833
864
  fallbackToFull,
834
865
  attachments,
866
+ reasoningEffort,
835
867
  }
836
868
  }
@@ -20,7 +20,7 @@ You have the iterate plugin installed, which registers these tools:
20
20
  - \`iterate_diff\` — show the accumulated diff for a fixed file (vs its original backup) or a per-file summary of all fixes
21
21
  - \`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
22
22
  - \`iterate_checkpoint\` — save / load / clear an iteration checkpoint (\`.iterate/checkpoint.json\`) so a long run can resume where it left off
23
- - \`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)
23
+ - \`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)
24
24
  - \`iterate_history\` — inspect the runtime state in detail: decision-log entries and applied fixes (optionally scoped to a round or a fixed file)
25
25
  - \`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.
26
26
  - \`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.
@@ -81,6 +81,13 @@ const maxRounds = plan.maxReviewRounds
81
81
  const knownIntentional = (plan.knownIntentional || []) // config personalization filter, applied in aggregate
82
82
  let known = [] // cumulative DEDUPED findings fed back to reviewers
83
83
  const rounds = [] // raw per-round findings
84
+ // Tracks whether the LAST executed round produced usable reviewer output. A
85
+ // round where every reviewer subagent failed (or output stayed schema-invalid
86
+ // after retries) is INCONCLUSIVE — it must never make the run look converged.
87
+ let lastRoundOk = true
88
+ // Slot index of the current round within this run's rounds array. A resumed
89
+ // run starts at startRound > 1, so the round NUMBER is not a valid array index.
90
+ let roundSlot = -1
84
91
 
85
92
  phase('transcript')
86
93
  // Read any steering nudge written (via iterate_transcript nudge) for this run's reviewers.
@@ -93,9 +100,14 @@ const steering = transRead && typeof transRead.nudge === 'string' && transRead.n
93
100
  phase('review')
94
101
  for (let r = 1; r <= maxRounds; r++) {
95
102
  log('round ' + r + ' of ' + maxRounds + ' — finding NEW issues only')
103
+ roundSlot = rounds.length
96
104
  let agg = null
97
105
  let schemaInvalid = false
98
106
  let attempts = 0
107
+ // Declared OUTSIDE the do-block: it is read after the retry loop exits, and a
108
+ // block-scoped const here raised ReferenceError at runtime (crashing both
109
+ // canonical workflows after the first round).
110
+ let reviewersOk = true
99
111
  do {
100
112
  attempts += 1
101
113
  // Schema validation retry: on the 2nd+ pass, nudge reviewers toward strict JSON.
@@ -118,6 +130,12 @@ for (let r = 1; r <= maxRounds; r++) {
118
130
  JSON.stringify(known) + nudge + '\\nReturn the findings JSON object.'
119
131
  return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
120
132
  }))
133
+ // A per-dimension reviewer subagent resolves \`null\` on child failure; the
134
+ // aggregate below would report an all-failed round as an empty round, and
135
+ // the convergence math would then read "0 new findings". An all-failed
136
+ // round is NOT a clean pass — it is inconclusive, so the convergence gate
137
+ // below refuses it before any "converged" signal fires.
138
+ reviewersOk = raw.length > 0 ? raw.some(x => x !== null && typeof x === 'object') : true
121
139
  const thisRound = {
122
140
  round: r,
123
141
  findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])),
@@ -125,7 +143,7 @@ for (let r = 1; r <= maxRounds; r++) {
125
143
  // gate can compare self-reported reads against the assigned inventory.
126
144
  readFiles: [].concat(...raw.map(x => x && Array.isArray(x.readFiles) ? x.readFiles : [])),
127
145
  }
128
- if (rounds.length >= r) rounds[r - 1] = thisRound; else rounds.push(thisRound)
146
+ if (roundSlot < rounds.length) rounds[roundSlot] = thisRound; else rounds.push(thisRound)
129
147
  // Deterministic aggregate: cross-round dedupe + known_intentional filter + severity sort.
130
148
  agg = await agent(
131
149
  'Call iterate_review({operation:"aggregate", mode:"dry-run", rounds:' + JSON.stringify(rounds) + ', maxReviewRounds:' + maxRounds + ', knownIntentional:' + JSON.stringify(knownIntentional) + '}) and return the report JSON.',
@@ -145,23 +163,30 @@ for (let r = 1; r <= maxRounds; r++) {
145
163
  // final summary can explain WHY the round was inconclusive.
146
164
  log('round ' + r + ' output STILL schema-invalid after 3 attempts — marking round inconclusive (NOT converged)')
147
165
  }
166
+ const roundUnusable = schemaInvalid || !reviewersOk
167
+ if (roundUnusable) lastRoundOk = false
148
168
  // Feed the DEDUPED + already-filtered set back (not raw findings) so the known
149
- // list stays bounded and reviewers never see the same issue twice.
150
- if (agg && agg.report && Array.isArray(agg.report.findings)) known = agg.report.findings
169
+ // list stays bounded and reviewers never see the same issue twice. Never feed
170
+ // an unusable round's empty result — it would erase the still-valid known list
171
+ // and let reviewers re-report everything next round.
172
+ if (!roundUnusable && agg && agg.report && Array.isArray(agg.report.findings)) known = agg.report.findings
151
173
  // A round counts as "converged" ONLY when the aggregate accepted it
152
- // (schema-valid) AND it genuinely found zero NEW findings. If reviewers all
153
- // failed or their output stayed schema-invalid after retries, the round is
154
- // INCONCLUSIVE — never report an empty-but-broken round as converged (an
155
- // invalid/failed round must fail the run, not masquerade as a clean pass).
156
- if (!schemaInvalid) {
174
+ // (schema-valid) AND its reviewers actually returned output AND it genuinely
175
+ // found zero NEW findings. If reviewers all failed or their output stayed
176
+ // schema-invalid after retries, the round is INCONCLUSIVE — never report an
177
+ // empty-but-broken round as converged (an invalid/failed round would
178
+ // otherwise masquerade as a clean pass and stop the run early).
179
+ if (!roundUnusable && !schemaInvalid) {
157
180
  const newCount =
158
181
  agg && agg.report && agg.report.convergence
159
182
  ? agg.report.convergence.findingsByRound[r - 1]
160
183
  : thisRound.findings.length
161
184
  if (newCount === 0) {
162
185
  log('round ' + r + ' found 0 new findings — converged')
186
+ lastRoundOk = true
163
187
  break
164
188
  }
189
+ lastRoundOk = true
165
190
  }
166
191
  }
167
192
 
@@ -197,8 +222,13 @@ return {
197
222
  mode: 'dry-run',
198
223
  goal: report.goal,
199
224
  rounds: rounds.length,
200
- converged: report.convergence.converged,
201
- stoppedReason: report.convergence.stoppedReason,
225
+ // The aggregate's convergence flag is only trustworthy when the last round
226
+ // was usable — an all-failed/schema-invalid final round looks like "0 new"
227
+ // to the convergence math but is an inconclusive run, never a clean pass.
228
+ converged: report.convergence.converged && lastRoundOk,
229
+ stoppedReason: report.convergence.converged && !lastRoundOk
230
+ ? 'inconclusive'
231
+ : report.convergence.stoppedReason,
202
232
  findingsByRound: report.convergence.findingsByRound,
203
233
  totalFindings: report.summary.totalFindings,
204
234
  bySeverity: { critical: report.summary.critical, high: report.summary.high, medium: report.summary.medium, low: report.summary.low },
@@ -260,7 +290,7 @@ const configRes = await agent(
260
290
  const cfg = (configRes && configRes.config) ? configRes.config : null
261
291
  const atomicMaxLines = (cfg && cfg.atomic && cfg.atomic.max_lines) ? cfg.atomic.max_lines : 20
262
292
  const planRes = await agent(
263
- 'Call iterate_review({operation:"plan", mode:"normal", maxReviewRounds:' + (args.maxRounds || 3) + '}) and return the plan JSON.',
293
+ 'Call iterate_review({operation:"plan", mode:"normal"' + (args.maxRounds ? ', maxReviewRounds:' + args.maxRounds : '') + '}) and return the plan JSON.',
264
294
  Object.assign({ label: 'review:plan' }, backend)
265
295
  )
266
296
  const plan = (planRes && planRes.plan) ? planRes.plan : null
@@ -274,9 +304,16 @@ const architectural = (checkpoint && Array.isArray(checkpoint.findings)) ? check
274
304
  let fixedCount = (checkpoint && typeof checkpoint.fixedCount === 'number') ? checkpoint.fixedCount : 0
275
305
  let converged = false
276
306
  let abortedByValidation = false
307
+ let schemaFailed = false // last round produced no usable reviewer output (schema-invalid after retries / all reviewers failed)
277
308
  let failedCommands = []
278
309
  let configErrors = [] // validation commands NOT in validation.commands (config gap, no rollback)
279
310
  const fixRecords = [] // observatory fix records collected round by round
311
+ // Slot index of the current round within this run's rounds array. A resumed
312
+ // run starts at startRound > 1, so the round NUMBER is not a valid array index.
313
+ let roundSlot = -1
314
+ // Highest round number actually executed by THIS run (carried so a resumed run
315
+ // reports its true round, not the count of rounds it happened to run).
316
+ let lastRound = startRound - 1
280
317
 
281
318
  // Read any steering nudge intended for this run's reviewers.
282
319
  const transRead = await agent(
@@ -288,6 +325,8 @@ const steering = transRead && typeof transRead.nudge === 'string' && transRead.n
288
325
  phase('loop')
289
326
  for (let r = startRound; r <= maxRounds; r++) {
290
327
  log('round ' + r + ' of ' + maxRounds + ' — review current state, fix atomics via iterate_fix, validate')
328
+ roundSlot = rounds.length
329
+ lastRound = r
291
330
  // Audit-trail: record the round start (SKILL.md Phase 4 requires per-round records).
292
331
  await agent(
293
332
  'Call iterate_decision_log({operation:"append", type:"round_start", round:' + r + ', data:{maxRounds:' + maxRounds + ', fixedSoFar:' + fixedCount + '}})',
@@ -296,6 +335,10 @@ for (let r = startRound; r <= maxRounds; r++) {
296
335
  let agg = null
297
336
  let schemaInvalid = false
298
337
  let attempts = 0
338
+ // Declared OUTSIDE the do-block: it is read after the retry loop exits, and a
339
+ // block-scoped const here raised ReferenceError at runtime (crashing both
340
+ // canonical workflows after the first round).
341
+ let reviewersOk = true
299
342
  do {
300
343
  attempts += 1
301
344
  // Schema validation retry: on the 2nd+ pass, nudge reviewers toward strict JSON.
@@ -316,12 +359,16 @@ for (let r = startRound; r <= maxRounds; r++) {
316
359
  '\\n Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + nudge + '\\nReturn the findings JSON object.'
317
360
  return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
318
361
  }))
362
+ // ALL reviewers returning null (child failures) means this round produced
363
+ // no usable output — an empty aggregate must not read like a clean
364
+ // "nothing to fix" convergence. Gate the loop exit + convergence on it.
365
+ reviewersOk = raw.length > 0 ? raw.some(x => x !== null && typeof x === 'object') : true
319
366
  const thisRound = {
320
367
  round: r,
321
368
  findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])),
322
369
  readFiles: [].concat(...raw.map(x => x && Array.isArray(x.readFiles) ? x.readFiles : [])),
323
370
  }
324
- if (rounds.length >= r) rounds[r - 1] = thisRound; else rounds.push(thisRound)
371
+ if (roundSlot < rounds.length) rounds[roundSlot] = thisRound; else rounds.push(thisRound)
325
372
 
326
373
  // Deterministic dedupe / known_intentional filter / severity sort for this round.
327
374
  // \`fixedCount\` is threaded into the report summary so the client dashboard can
@@ -339,10 +386,19 @@ for (let r = startRound; r <= maxRounds; r++) {
339
386
  log('retry ' + attempts + ': round ' + r + ' output failed schema validation — re-running reviewers with strict-JSON emphasis')
340
387
  }
341
388
  } while (schemaInvalid && attempts < 3)
342
- if (schemaInvalid) {
343
- // Bounded exit: never loop forever. Surface the failure so the run's
344
- // final summary can explain WHY the round was inconclusive.
345
- log('round ' + r + ' output STILL schema-invalid after 3 attempts — marking round inconclusive (NOT converged)')
389
+ if (schemaInvalid || !reviewersOk) {
390
+ // Bounded exit: never loop forever, and never let an all-failed/empty round
391
+ // read like a clean convergence. Stop the run WITHOUT applying fixes for
392
+ // this round, record the failure in the audit trail, and leave the
393
+ // checkpoint in place so a user/DM can resume or re-roll after fixing the
394
+ // reviewer configuration.
395
+ schemaFailed = true
396
+ log('round ' + r + ' produced NO usable reviewer output (schema-invalid after retries or all reviewers failed) — stopping run (NOT converged)')
397
+ await agent(
398
+ 'Call iterate_decision_log({operation:"append", type:"round_failed", round:' + r + ', data:{reason:"' + (schemaInvalid ? 'schema_invalid' : 'no_usable_reviewer_output') + '", rolledBack:0}})',
399
+ Object.assign({ label: 'log:schemaFailed:r' + r }, backend)
400
+ )
401
+ break
346
402
  }
347
403
  const findings = (agg && agg.report && agg.report.findings) ? agg.report.findings : thisRound.findings
348
404
  const atomic = findings.filter(f => f.is_atomic === true)
@@ -467,7 +523,7 @@ for (let r = startRound; r <= maxRounds; r++) {
467
523
 
468
524
  phase('report')
469
525
  await agent(
470
- 'Call iterate_decision_log({operation:"append", type:"report", round:' + rounds.length +
526
+ 'Call iterate_decision_log({operation:"append", type:"report", round:' + lastRound +
471
527
  ', data:{mode:"normal", fixed:' + fixedCount + ', architectural:' + architectural.length + '}})',
472
528
  { label: 'report:log' }
473
529
  )
@@ -476,7 +532,7 @@ const statusRes = await agent(
476
532
  { label: 'status:final' }
477
533
  )
478
534
  const status = (statusRes && statusRes.ok) ? statusRes : null
479
- if (!abortedByValidation) {
535
+ if (!abortedByValidation && !schemaFailed) {
480
536
  // Iteration finished cleanly → clear the checkpoint so the next run starts fresh.
481
537
  await agent(
482
538
  'Call iterate_checkpoint({ operation: "clear" }) and return {ok, existed}.',
@@ -490,25 +546,28 @@ if (!abortedByValidation) {
490
546
  // would hide the very thing F5 is meant to resume. Reflect what is on disk.
491
547
  const obsCheckpoint = {
492
548
  mode: 'normal',
493
- round: rounds.length,
549
+ round: lastRound,
494
550
  maxRounds: maxRounds,
495
551
  fixedCount: fixedCount,
496
552
  resumeCount: effectiveResumeCount,
497
553
  }
498
- const stopReason = abortedByValidation
499
- ? (configErrors.length > 0 ? 'aborted_by_config' : 'aborted_by_validation')
500
- : (converged ? 'converged' : 'max_rounds_reached')
554
+ const stopReason = schemaFailed
555
+ ? 'schema_invalid'
556
+ : abortedByValidation
557
+ ? (configErrors.length > 0 ? 'aborted_by_config' : 'aborted_by_validation')
558
+ : (converged ? 'converged' : 'max_rounds_reached')
501
559
  await agent(
502
- '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"}.',
560
+ '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"}.',
503
561
  { label: 'transcript:capture' }
504
562
  )
505
563
  return {
506
564
  mode: 'normal',
507
565
  goal: plan.goal,
508
- roundsExecuted: rounds.length,
566
+ roundsExecuted: lastRound,
509
567
  maxRounds: maxRounds,
510
568
  converged: converged,
511
569
  abortedByValidation: abortedByValidation,
570
+ schemaFailed: schemaFailed,
512
571
  failedCommands: failedCommands,
513
572
  configErrors: configErrors,
514
573
  stoppedReason: stopReason,
@@ -529,12 +588,12 @@ return {
529
588
  Key rules for normal mode:
530
589
  - 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.
531
590
  - Aggregate the current round deterministically (\`report.findings\`) before fixing, so fixes act on deduped/filtered/sorted findings.
532
- - **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).
591
+ - **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.
533
592
  - 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.
534
593
  - **Resume**: load the checkpoint first; if a previous run left one, continue from \`checkpoint.round + 1\` (its \`fixedCount\` and deduped \`findings\` are carried forward).
535
594
  - **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.
536
595
  - **Checkpoint after every round**; clear it only when the iteration completes cleanly.
537
- - Stop when a round produces nothing to fix (converged) or maxReviewRounds is reached.
596
+ - 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"\`).
538
597
  - Every round, every rollback, and the final report go to the append-only decision log.
539
598
  - Close with \`iterate_status\` metrics and surface the convergence indicators (fixed count, remaining architectural count, abort reason) in the final summary.
540
599
 
@@ -177,9 +177,17 @@ export function computeStatus(input: {
177
177
  findingsCount: Array.isArray(checkpoint?.findings) ? checkpoint.findings.length : 0,
178
178
  totalDecisionLogEntries: entries.length,
179
179
  hasCheckpoint: checkpoint !== null,
180
- // A checkpoint left on disk means the previous run was interrupted before
181
- // it could clear it — this is the durable "interruption" signal.
182
- interrupted: checkpoint !== null,
180
+ // A checkpoint left on disk is the durable "the run did not finish" signal
181
+ // — EXCEPT when a decision-log entry is NEWER than the checkpoint: the
182
+ // workflow saves a checkpoint at the end of each round (right after that
183
+ // round's review_result log), so a live run proceeding past its last
184
+ // checkpoint would otherwise be flagged as "interrupted" for its whole
185
+ // duration. A fresh checkpoint (or no log at all) is genuinely leftover.
186
+ interrupted:
187
+ checkpoint !== null &&
188
+ !(lastEntry != null &&
189
+ checkpoint.updatedAt &&
190
+ (Date.parse(checkpoint.updatedAt) || 0) >= (Date.parse(lastEntry.timestamp) || 0)),
183
191
  resumeCount: checkpoint?.resumeCount ?? 0,
184
192
  checkpoint,
185
193
  lastUpdated,
@@ -354,7 +362,7 @@ export function registerStatusTool(ctx: { tools: { register: (def: ReturnType<ty
354
362
  findingsCount: { type: 'integer' },
355
363
  totalDecisionLogEntries: { type: 'integer' },
356
364
  hasCheckpoint: { type: 'boolean' },
357
- interrupted: { type: 'boolean', description: 'True when a checkpoint exists, meaning the previous run was interrupted before finishing.' },
365
+ 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.' },
358
366
  resumeCount: { type: 'integer', description: 'How many times the current checkpoint has already been resumed.' },
359
367
  lastUpdated: { oneOf: [{ type: 'string' }, { type: 'null' }] },
360
368
  qualityGate: { type: 'json', description: 'v3.0: persisted quality-gate snapshot (.iterate/quality-gate.json), when present.' },
@@ -121,6 +121,18 @@ function recountEntries(filePath: string): number {
121
121
  }
122
122
  }
123
123
 
124
+ /**
125
+ * Drop the cached entry count for a log file. The cache is keyed by the log
126
+ * path and assumes "same byte size ⇒ same entry count"; a prune rewrite
127
+ * (temp + atomic rename) replaces the file with a DIFFERENT number of entries,
128
+ * so only a stale-cache invalidation keeps the fast path honest. Without it, a
129
+ * rewrite that lands on the exact same byte size as the previous append would
130
+ * make the next append report a wrong `count`.
131
+ */
132
+ export function invalidateLogCountCache(filePath: string): void {
133
+ entryCountCache.delete(filePath)
134
+ }
135
+
124
136
  /** All valid DecisionLogEntry `type` values (must stay in sync with Types). */
125
137
  const VALID_ENTRY_TYPES = new Set<DecisionLogEntry['type']>([
126
138
  'round_start',
@@ -102,7 +102,7 @@ export function registerHistoryTool(ctx: { tools: { register: (def: ReturnType<t
102
102
  },
103
103
  limit: {
104
104
  type: 'integer',
105
- description: `Max entries to return (default: ${DEFAULT_LIMIT}, cap: ${MAX_LIMIT}). Newest first.`,
105
+ description: `Max entries to return (default: ${DEFAULT_LIMIT}, cap: ${MAX_LIMIT}). Returns the newest window in chronological order.`,
106
106
  },
107
107
  path: {
108
108
  type: 'string',
@@ -24,13 +24,13 @@
24
24
  * - Each deletion is logged to the decision log (when not dry-run).
25
25
  */
26
26
 
27
- import { existsSync, readdirSync, rmSync, unlinkSync } from 'node:fs'
27
+ import { existsSync, readdirSync, rmSync, unlinkSync, statSync } from 'node:fs'
28
28
  import { join } from 'node:path'
29
29
  import { defineTool } from '@deepseek-ai/dsh-tools'
30
30
  import type { JsonValue } from '@deepseek-ai/dsh-util-values'
31
31
  import { resolveProjectRootForExec } from '../config-loader.ts'
32
32
  import { writeTextAtomic, writeJsonAtomic } from '../atomic-fs.ts'
33
- import { readDecisionEntries, appendDecisionEntry, acquireLogLock } from './decision-log.ts'
33
+ import { readDecisionEntries, appendDecisionEntry, acquireLogLock, invalidateLogCountCache } from './decision-log.ts'
34
34
  import { readRegistry, removeRecord, recomputeRoundCounts } from './fix.ts'
35
35
  import { readExperienceBank, writeExperienceBank } from './experience-store.ts'
36
36
  import { readDefenseEvents, writeDefenseEvents } from './defense-store.ts'
@@ -154,6 +154,7 @@ export function inspectPrune(
154
154
  ): {
155
155
  oldLogEntries: number
156
156
  hasCheckpoint: boolean
157
+ checkpointStale: boolean
157
158
  staleBackups: string[]
158
159
  staleTemps: string[]
159
160
  emptyRounds: number[]
@@ -170,8 +171,20 @@ export function inspectPrune(
170
171
  const entries = readDecisionEntries(projectRoot)
171
172
  const oldLogEntries = entries.filter((e) => e.timestamp < cutoff).length
172
173
 
173
- // 2. Checkpoint presence.
174
+ // 2. Checkpoint presence. A checkpoint is only STALE once it is older than
175
+ // retainDays — a fresh checkpoint means a run was interrupted recently and
176
+ // may still be resumed, so `hasCheckpoint` is kept as pure existence while
177
+ // `checkpointStale` decides whether a prune actually deletes it.
174
178
  const hasCheckpoint = existsSync(checkpointPath(projectRoot))
179
+ let checkpointStale = false
180
+ if (hasCheckpoint) {
181
+ try {
182
+ const st = statSync(checkpointPath(projectRoot))
183
+ if (st && st.mtimeMs < Date.parse(cutoff)) checkpointStale = true
184
+ } catch {
185
+ checkpointStale = true // unreadable mtime — treat as garbage / deletable
186
+ }
187
+ }
175
188
 
176
189
  // 3. Stale fix backups: .bak files whose fix-id prefix is not in the registry.
177
190
  const registry = readRegistry(projectRoot)
@@ -233,6 +246,7 @@ export function inspectPrune(
233
246
  return {
234
247
  oldLogEntries,
235
248
  hasCheckpoint,
249
+ checkpointStale,
236
250
  staleBackups,
237
251
  staleTemps,
238
252
  emptyRounds,
@@ -277,6 +291,12 @@ function rewriteDecisionLogKeepingRecent(projectRoot: string, cutoff: string): {
277
291
  const deleted = entries.length - kept.length
278
292
  if (deleted === 0) return { deleted: 0 }
279
293
  writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n')
294
+ // The append-side entry-count cache is keyed by path and assumes "same
295
+ // byte size ⇒ same count"; the rename above replaced the file with a
296
+ // different number of entries, so drop the stale cache or the next
297
+ // append can report a wrong entryCount when the sizes coincidentally
298
+ // match.
299
+ invalidateLogCountCache(logPath)
280
300
  // A concurrent appender (cross-process, lock-unavailable path) may have
281
301
  // landed new lines after our read. If the log grew during the write
282
302
  // window, re-read and prune again instead of accepting a lost audit trail.
@@ -333,8 +353,10 @@ export function executePrune(
333
353
  if (error) result.errors.push(error)
334
354
  }
335
355
 
336
- // 2. Remove checkpoint.
337
- if (report.hasCheckpoint) {
356
+ // 2. Remove checkpoint — ONLY when it is actually stale (older than
357
+ // retainDays). A fresh checkpoint is a live resume point for a recently
358
+ // interrupted run; pruning it would silently destroy the ability to resume.
359
+ if (report.checkpointStale) {
338
360
  try {
339
361
  rmSync(checkpointPath(projectRoot), { force: true })
340
362
  result.deletedCheckpoint = true
@@ -466,7 +488,7 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
466
488
  const lines = [
467
489
  `[dry-run] prune report (retainDays=${value.retainDays}):`,
468
490
  ` Decision-log entries to remove: ${report?.oldLogEntries ?? '?'} (of ${report?.totalLogEntries ?? '?'})`,
469
- ` Checkpoint to delete: ${report?.hasCheckpoint ? 'yes' : 'none'}`,
491
+ ` Checkpoint to delete: ${report?.checkpointStale ? 'yes (stale)' : report?.hasCheckpoint ? 'no (fresh — resume point, kept)' : 'none'}`,
470
492
  ` Stale backups to delete: ${(report?.staleBackups as string[] | undefined)?.length ?? 0}`,
471
493
  ` Stray temp files to delete: ${(report?.staleTemps as string[] | undefined)?.length ?? 0}`,
472
494
  ` Empty rounds to trim: ${(report?.emptyRounds as number[] | undefined)?.length ?? 0}`,
@@ -160,7 +160,7 @@ export function registerTranscriptTool(ctx: {
160
160
  description: 'For `capture`: array of applied fixes [{id, file, round, summary, linesAdded, linesRemoved, success}].',
161
161
  },
162
162
  refReadFiles: { type: 'json', description: 'For `capture`: flat array of all read files across rounds (optional).' },
163
- 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).' },
163
+ 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).' },
164
164
  text: { type: 'string', description: 'For `nudge`: steering text to set (or null to clear).' },
165
165
  path: { type: 'string', description: 'Project root directory (default: current working directory).' },
166
166
  },
@@ -358,12 +358,23 @@ function rehydrateBuilder(manifest: TranscriptManifest, approval: 'ask' | 'deny'
358
358
  maxRounds: manifest.maxRounds,
359
359
  })
360
360
  for (const r of Array.isArray(manifest.rounds) ? manifest.rounds : []) {
361
+ // A malformed/null round row must be skipped, never a throw (the caller
362
+ // wraps rehydrate in a fallback, but preserving sibling rows is better).
363
+ if (!r || typeof r !== 'object') continue
361
364
  builder.roundStart(r.round, manifest.maxRounds)
362
365
  for (const t of Array.isArray(r.threads) ? r.threads : []) {
363
- builder.reviewerStart(t.dimension || 'review', t.attempt || 1)
364
- builder.reviewerMessage((t.messages ?? []).join('\n'))
365
- builder.reviewerRead(t.readFiles ?? [])
366
- for (const f of t.findings ?? []) builder.reviewerFindings([f])
366
+ // restoreThread (not reviewerStart + reviewerMessage(join)) preserves the
367
+ // message ARRAY boundaries and the thread's own dimension: rehydration
368
+ // previously collapsed each thread's messages into one string and, once a
369
+ // round hit the per-round thread cap, silently DROPPED the extra threads
370
+ // / mis-merged their findings into the previous dimension's thread.
371
+ builder.restoreThread(r.round, {
372
+ dimension: t.dimension,
373
+ attempt: t.attempt,
374
+ messages: t.messages,
375
+ readFiles: t.readFiles,
376
+ findings: t.findings,
377
+ })
367
378
  }
368
379
  }
369
380
  for (let idx = 0; idx < (manifest.convergence ?? []).length; idx += 1) {