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.
@@ -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,10 @@ 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
84
88
 
85
89
  phase('transcript')
86
90
  // Read any steering nudge written (via iterate_transcript nudge) for this run's reviewers.
@@ -118,6 +122,12 @@ for (let r = 1; r <= maxRounds; r++) {
118
122
  JSON.stringify(known) + nudge + '\\nReturn the findings JSON object.'
119
123
  return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
120
124
  }))
125
+ // A per-dimension reviewer subagent resolves \`null\` on child failure; the
126
+ // aggregate below would report an all-failed round as an empty round, and
127
+ // the convergence math would then read "0 new findings". An all-failed
128
+ // round is NOT a clean pass — it is inconclusive, so the convergence gate
129
+ // below refuses it before any "converged" signal fires.
130
+ const reviewersOk = raw.length > 0 ? raw.some(x => x !== null && typeof x === 'object') : true
121
131
  const thisRound = {
122
132
  round: r,
123
133
  findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])),
@@ -145,23 +155,30 @@ for (let r = 1; r <= maxRounds; r++) {
145
155
  // final summary can explain WHY the round was inconclusive.
146
156
  log('round ' + r + ' output STILL schema-invalid after 3 attempts — marking round inconclusive (NOT converged)')
147
157
  }
158
+ const roundUnusable = schemaInvalid || !reviewersOk
159
+ if (roundUnusable) lastRoundOk = false
148
160
  // 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
161
+ // list stays bounded and reviewers never see the same issue twice. Never feed
162
+ // an unusable round's empty result — it would erase the still-valid known list
163
+ // and let reviewers re-report everything next round.
164
+ if (!roundUnusable && agg && agg.report && Array.isArray(agg.report.findings)) known = agg.report.findings
151
165
  // 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) {
166
+ // (schema-valid) AND its reviewers actually returned output AND it genuinely
167
+ // found zero NEW findings. If reviewers all failed or their output stayed
168
+ // schema-invalid after retries, the round is INCONCLUSIVE — never report an
169
+ // empty-but-broken round as converged (an invalid/failed round would
170
+ // otherwise masquerade as a clean pass and stop the run early).
171
+ if (!roundUnusable && !schemaInvalid) {
157
172
  const newCount =
158
173
  agg && agg.report && agg.report.convergence
159
174
  ? agg.report.convergence.findingsByRound[r - 1]
160
175
  : thisRound.findings.length
161
176
  if (newCount === 0) {
162
177
  log('round ' + r + ' found 0 new findings — converged')
178
+ lastRoundOk = true
163
179
  break
164
180
  }
181
+ lastRoundOk = true
165
182
  }
166
183
  }
167
184
 
@@ -197,8 +214,13 @@ return {
197
214
  mode: 'dry-run',
198
215
  goal: report.goal,
199
216
  rounds: rounds.length,
200
- converged: report.convergence.converged,
201
- stoppedReason: report.convergence.stoppedReason,
217
+ // The aggregate's convergence flag is only trustworthy when the last round
218
+ // was usable — an all-failed/schema-invalid final round looks like "0 new"
219
+ // to the convergence math but is an inconclusive run, never a clean pass.
220
+ converged: report.convergence.converged && lastRoundOk,
221
+ stoppedReason: report.convergence.converged && !lastRoundOk
222
+ ? 'inconclusive'
223
+ : report.convergence.stoppedReason,
202
224
  findingsByRound: report.convergence.findingsByRound,
203
225
  totalFindings: report.summary.totalFindings,
204
226
  bySeverity: { critical: report.summary.critical, high: report.summary.high, medium: report.summary.medium, low: report.summary.low },
@@ -260,7 +282,7 @@ const configRes = await agent(
260
282
  const cfg = (configRes && configRes.config) ? configRes.config : null
261
283
  const atomicMaxLines = (cfg && cfg.atomic && cfg.atomic.max_lines) ? cfg.atomic.max_lines : 20
262
284
  const planRes = await agent(
263
- 'Call iterate_review({operation:"plan", mode:"normal", maxReviewRounds:' + (args.maxRounds || 3) + '}) and return the plan JSON.',
285
+ 'Call iterate_review({operation:"plan", mode:"normal"' + (args.maxRounds ? ', maxReviewRounds:' + args.maxRounds : '') + '}) and return the plan JSON.',
264
286
  Object.assign({ label: 'review:plan' }, backend)
265
287
  )
266
288
  const plan = (planRes && planRes.plan) ? planRes.plan : null
@@ -274,6 +296,7 @@ const architectural = (checkpoint && Array.isArray(checkpoint.findings)) ? check
274
296
  let fixedCount = (checkpoint && typeof checkpoint.fixedCount === 'number') ? checkpoint.fixedCount : 0
275
297
  let converged = false
276
298
  let abortedByValidation = false
299
+ let schemaFailed = false // last round produced no usable reviewer output (schema-invalid after retries / all reviewers failed)
277
300
  let failedCommands = []
278
301
  let configErrors = [] // validation commands NOT in validation.commands (config gap, no rollback)
279
302
  const fixRecords = [] // observatory fix records collected round by round
@@ -316,6 +339,10 @@ for (let r = startRound; r <= maxRounds; r++) {
316
339
  '\\n Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + nudge + '\\nReturn the findings JSON object.'
317
340
  return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
318
341
  }))
342
+ // ALL reviewers returning null (child failures) means this round produced
343
+ // no usable output — an empty aggregate must not read like a clean
344
+ // "nothing to fix" convergence. Gate the loop exit + convergence on it.
345
+ const reviewersOk = raw.length > 0 ? raw.some(x => x !== null && typeof x === 'object') : true
319
346
  const thisRound = {
320
347
  round: r,
321
348
  findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])),
@@ -339,10 +366,19 @@ for (let r = startRound; r <= maxRounds; r++) {
339
366
  log('retry ' + attempts + ': round ' + r + ' output failed schema validation — re-running reviewers with strict-JSON emphasis')
340
367
  }
341
368
  } 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)')
369
+ if (schemaInvalid || !reviewersOk) {
370
+ // Bounded exit: never loop forever, and never let an all-failed/empty round
371
+ // read like a clean convergence. Stop the run WITHOUT applying fixes for
372
+ // this round, record the failure in the audit trail, and leave the
373
+ // checkpoint in place so a user/DM can resume or re-roll after fixing the
374
+ // reviewer configuration.
375
+ schemaFailed = true
376
+ log('round ' + r + ' produced NO usable reviewer output (schema-invalid after retries or all reviewers failed) — stopping run (NOT converged)')
377
+ await agent(
378
+ 'Call iterate_decision_log({operation:"append", type:"round_failed", round:' + r + ', data:{reason:"' + (schemaInvalid ? 'schema_invalid' : 'no_usable_reviewer_output') + '", rolledBack:0}})',
379
+ Object.assign({ label: 'log:schemaFailed:r' + r }, backend)
380
+ )
381
+ break
346
382
  }
347
383
  const findings = (agg && agg.report && agg.report.findings) ? agg.report.findings : thisRound.findings
348
384
  const atomic = findings.filter(f => f.is_atomic === true)
@@ -476,7 +512,7 @@ const statusRes = await agent(
476
512
  { label: 'status:final' }
477
513
  )
478
514
  const status = (statusRes && statusRes.ok) ? statusRes : null
479
- if (!abortedByValidation) {
515
+ if (!abortedByValidation && !schemaFailed) {
480
516
  // Iteration finished cleanly → clear the checkpoint so the next run starts fresh.
481
517
  await agent(
482
518
  'Call iterate_checkpoint({ operation: "clear" }) and return {ok, existed}.',
@@ -495,9 +531,11 @@ if (!abortedByValidation) {
495
531
  fixedCount: fixedCount,
496
532
  resumeCount: effectiveResumeCount,
497
533
  }
498
- const stopReason = abortedByValidation
499
- ? (configErrors.length > 0 ? 'aborted_by_config' : 'aborted_by_validation')
500
- : (converged ? 'converged' : 'max_rounds_reached')
534
+ const stopReason = schemaFailed
535
+ ? 'schema_invalid'
536
+ : abortedByValidation
537
+ ? (configErrors.length > 0 ? 'aborted_by_config' : 'aborted_by_validation')
538
+ : (converged ? 'converged' : 'max_rounds_reached')
501
539
  await agent(
502
540
  '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"}.',
503
541
  { label: 'transcript:capture' }
@@ -509,6 +547,7 @@ return {
509
547
  maxRounds: maxRounds,
510
548
  converged: converged,
511
549
  abortedByValidation: abortedByValidation,
550
+ schemaFailed: schemaFailed,
512
551
  failedCommands: failedCommands,
513
552
  configErrors: configErrors,
514
553
  stoppedReason: stopReason,
@@ -529,12 +568,12 @@ return {
529
568
  Key rules for normal mode:
530
569
  - 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
570
  - 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).
571
+ - **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
572
  - 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
573
  - **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
574
  - **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
575
  - **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.
576
+ - 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
577
  - Every round, every rollback, and the final report go to the append-only decision log.
539
578
  - Close with \`iterate_status\` metrics and surface the convergence indicators (fixed count, remaining architectural count, abort reason) in the final summary.
540
579
 
@@ -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.' },
@@ -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,7 +24,7 @@
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'
@@ -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,
@@ -333,8 +347,10 @@ export function executePrune(
333
347
  if (error) result.errors.push(error)
334
348
  }
335
349
 
336
- // 2. Remove checkpoint.
337
- if (report.hasCheckpoint) {
350
+ // 2. Remove checkpoint — ONLY when it is actually stale (older than
351
+ // retainDays). A fresh checkpoint is a live resume point for a recently
352
+ // interrupted run; pruning it would silently destroy the ability to resume.
353
+ if (report.checkpointStale) {
338
354
  try {
339
355
  rmSync(checkpointPath(projectRoot), { force: true })
340
356
  result.deletedCheckpoint = true
@@ -466,7 +482,7 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
466
482
  const lines = [
467
483
  `[dry-run] prune report (retainDays=${value.retainDays}):`,
468
484
  ` Decision-log entries to remove: ${report?.oldLogEntries ?? '?'} (of ${report?.totalLogEntries ?? '?'})`,
469
- ` Checkpoint to delete: ${report?.hasCheckpoint ? 'yes' : 'none'}`,
485
+ ` Checkpoint to delete: ${report?.checkpointStale ? 'yes (stale)' : report?.hasCheckpoint ? 'no (fresh — resume point, kept)' : 'none'}`,
470
486
  ` Stale backups to delete: ${(report?.staleBackups as string[] | undefined)?.length ?? 0}`,
471
487
  ` Stray temp files to delete: ${(report?.staleTemps as string[] | undefined)?.length ?? 0}`,
472
488
  ` 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) {
package/src/transcript.ts CHANGED
@@ -98,7 +98,10 @@ function normalizeFinding(input: unknown): TranscriptFinding | null {
98
98
  sev === 'critical' || sev === 'high' || sev === 'medium' || sev === 'low'
99
99
  ? sev
100
100
  : 'low'
101
- const line = typeof f.line === 'number' && Number.isFinite(f.line) ? f.line : 0
101
+ // Only a non-negative integer is a real line anchor; a fractional/malformed
102
+ // value (e.g. 3.7 from a bypassed schema) must not leak into the manifest.
103
+ const line =
104
+ typeof f.line === 'number' && Number.isInteger(f.line) && f.line >= 0 ? f.line : 0
102
105
  return {
103
106
  dimension,
104
107
  file,
@@ -267,7 +270,8 @@ export class ReviewTranscriptBuilder {
267
270
  const att = typeof attempt === 'number' && Number.isFinite(attempt) ? Math.floor(attempt) : 1
268
271
  this.roundStart(this.round)
269
272
  const live = this.rounds[this.round - 1]! as LiveRound | undefined
270
- if (live && this.threadCount(live) < MAX_THREADS_PER_ROUND) {
273
+ if (!live) return
274
+ if (this.threadCount(live) < MAX_THREADS_PER_ROUND) {
271
275
  live.threads.push({
272
276
  dimension: dim || 'review',
273
277
  attempt: att > 0 ? att : 1,
@@ -275,7 +279,86 @@ export class ReviewTranscriptBuilder {
275
279
  readFiles: [],
276
280
  findings: [],
277
281
  })
282
+ } else {
283
+ // Thread cap reached: route the extra dimension/retry into ONE shared
284
+ // "other" overflow thread instead of silently dropping it. A dropped
285
+ // reviewerStart used to leave `currentThread()` pointing at the PREVIOUS
286
+ // dimension's thread, so its findings were mis-attributed to that
287
+ // dimension. The overflow thread stays bounded (at most ONE extra thread
288
+ // per round) while keeping over-cap findings attributable to "other".
289
+ let overflow = live.threads.find((t) => t.dimension === 'other')
290
+ if (!overflow) {
291
+ overflow = {
292
+ dimension: 'other',
293
+ attempt: att > 0 ? att : 1,
294
+ messages: [],
295
+ readFiles: [],
296
+ findings: [],
297
+ }
298
+ live.threads.push(overflow)
299
+ }
300
+ }
301
+ this.touch()
302
+ }
303
+
304
+ /**
305
+ * Restore ONE persisted thread during rehydration, preserving the message
306
+ * ARRAY boundaries (a rehydrated manifest is re-serialized on the next nudge
307
+ * edit, so collapsed messages would silently rewrite the evidence) and the
308
+ * thread's own dimension (never merging it into a previous dimension's
309
+ * thread). Bypasses the live per-round cap — the manifest already enforced
310
+ * it at capture time — but every restored field is hard-bounded so a hostile
311
+ * hand-edited manifest can never blow up memory.
312
+ */
313
+ restoreThread(
314
+ round: number,
315
+ thread: {
316
+ dimension?: unknown
317
+ attempt?: unknown
318
+ messages?: unknown
319
+ readFiles?: unknown
320
+ findings?: unknown
321
+ },
322
+ ): void {
323
+ const r =
324
+ typeof round === 'number' && Number.isFinite(round)
325
+ ? Math.min(Math.floor(round), MAX_ROUNDS)
326
+ : 1
327
+ this.roundStart(r)
328
+ const live = this.rounds[this.round - 1]
329
+ if (!live) return
330
+ if (!thread || typeof thread !== 'object') return
331
+ // Hostile-manifest guard: keep the live hard bound (with headroom for the
332
+ // single overflow thread) and newest-first truncate beyond it.
333
+ if (live.threads.length >= MAX_THREADS_PER_ROUND * 2 + 1) return
334
+ const dim =
335
+ typeof thread.dimension === 'string' && thread.dimension.trim()
336
+ ? thread.dimension.trim()
337
+ : 'review'
338
+ const att =
339
+ typeof thread.attempt === 'number' && Number.isFinite(thread.attempt)
340
+ ? Math.floor(thread.attempt)
341
+ : 1
342
+ const t: LiveThread = {
343
+ dimension: dim,
344
+ attempt: att > 0 ? att : 1,
345
+ messages: [],
346
+ readFiles: [],
347
+ findings: [],
348
+ }
349
+ const messages = Array.isArray(thread.messages) ? thread.messages : []
350
+ for (const m of messages.slice(0, MAX_MESSAGES_PER_THREAD)) {
351
+ if (typeof m === 'string' && m.trim()) t.messages.push(m)
352
+ }
353
+ if (Array.isArray(thread.readFiles)) {
354
+ t.readFiles.push(...dedupePaths(thread.readFiles as string[]))
355
+ }
356
+ if (Array.isArray(thread.findings)) {
357
+ for (const f of thread.findings) {
358
+ if (normalizeFinding(f)) pushThreadFinding(t, f)
359
+ }
278
360
  }
361
+ live.threads.push(t)
279
362
  this.touch()
280
363
  }
281
364
 
package/src/types.ts CHANGED
@@ -18,9 +18,12 @@ export interface IterateConfig {
18
18
  }
19
19
  /**
20
20
  * LLM reasoning effort for review passes ('low' | 'medium' | 'high').
21
- * Absent → follow the provider default. The harness forwards it into the
22
- * OpenAI-compatible request body; the plugin surfaces it in the settings
23
- * panel and review plan (dsh 0.1.1-rc.7+ exposes the same 'low' effort).
21
+ * Absent → follow the provider default. Accepted by `iterate_config` write
22
+ * validation (config-write), exposed in the settings guide, and carried on
23
+ * the review plan (`buildReviewPlan.reasoningEffort`, plus an effort
24
+ * directive injected into every reviewer prompt) so the orchestrating
25
+ * harness can honor it per reviewer subagent. The plugin itself never reads
26
+ * or modifies the provider request body.
24
27
  */
25
28
  reasoning_effort?: 'low' | 'medium' | 'high'
26
29
  reviewer: {
@@ -226,7 +229,9 @@ export interface IterationStatus {
226
229
  findingsCount: number
227
230
  totalDecisionLogEntries: number
228
231
  hasCheckpoint: boolean
229
- /** True when a checkpoint is present — i.e. the previous run was interrupted before finishing. */
232
+ /** True when a checkpoint is present AND no decision-log entry is newer than
233
+ * it — i.e. the previous run left a checkpoint and nothing has logged since
234
+ * (a live run that saved progress keeps `interrupted=false`). */
230
235
  interrupted: boolean
231
236
  /** How many times the current checkpoint has already been resumed. */
232
237
  resumeCount: number