iterate-plugin 3.5.4 → 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.
@@ -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 } from './decision-log.ts'
33
+ import { readDecisionEntries, appendDecisionEntry, acquireLogLock } 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,
@@ -264,23 +278,32 @@ export function inspectPrune(
264
278
  */
265
279
  function rewriteDecisionLogKeepingRecent(projectRoot: string, cutoff: string): { deleted: number; error?: string } {
266
280
  const logPath = join(iterateDir(projectRoot), 'decision-log.jsonl')
267
- for (let attempt = 0; attempt < MAX_LOG_REWRITE_RETRIES; attempt++) {
268
- try {
269
- const entries = readDecisionEntries(projectRoot)
270
- const kept = entries.filter((e) => e.timestamp >= cutoff)
271
- const deleted = entries.length - kept.length
272
- if (deleted === 0) return { deleted: 0 }
273
- writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n')
274
- // A concurrent appender may have landed new lines after our read. If the
275
- // log grew during the write window, re-read and prune again instead of
276
- // accepting a lost audit trail.
277
- const after = readDecisionEntries(projectRoot)
278
- if (after.length <= kept.length) return { deleted }
279
- } catch (err) {
280
- return { deleted: 0, error: `failed to rewrite decision log: ${String(err)}` }
281
+ // Take the cross-process log lock for the ENTIRE loop: with the mutex held,
282
+ // no concurrent appender can slip a fresh line into the read-before-rewrite
283
+ // window, so the first attempt is nearly always the only one. (The retry
284
+ // loop stays as defense-in-depth for the lock-unavailable path.)
285
+ const release = acquireLogLock(projectRoot)
286
+ try {
287
+ for (let attempt = 0; attempt < MAX_LOG_REWRITE_RETRIES; attempt++) {
288
+ try {
289
+ const entries = readDecisionEntries(projectRoot)
290
+ const kept = entries.filter((e) => e.timestamp >= cutoff)
291
+ const deleted = entries.length - kept.length
292
+ if (deleted === 0) return { deleted: 0 }
293
+ writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n')
294
+ // A concurrent appender (cross-process, lock-unavailable path) may have
295
+ // landed new lines after our read. If the log grew during the write
296
+ // window, re-read and prune again instead of accepting a lost audit trail.
297
+ const after = readDecisionEntries(projectRoot)
298
+ if (after.length <= kept.length) return { deleted }
299
+ } catch (err) {
300
+ return { deleted: 0, error: `failed to rewrite decision log: ${String(err)}` }
301
+ }
281
302
  }
303
+ return { deleted: 0, error: `failed to rewrite decision log after ${MAX_LOG_REWRITE_RETRIES} attempts` }
304
+ } finally {
305
+ release()
282
306
  }
283
- return { deleted: 0, error: `failed to rewrite decision log after ${MAX_LOG_REWRITE_RETRIES} attempts` }
284
307
  }
285
308
 
286
309
  /**
@@ -324,8 +347,10 @@ export function executePrune(
324
347
  if (error) result.errors.push(error)
325
348
  }
326
349
 
327
- // 2. Remove checkpoint.
328
- 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) {
329
354
  try {
330
355
  rmSync(checkpointPath(projectRoot), { force: true })
331
356
  result.deletedCheckpoint = true
@@ -457,7 +482,7 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
457
482
  const lines = [
458
483
  `[dry-run] prune report (retainDays=${value.retainDays}):`,
459
484
  ` Decision-log entries to remove: ${report?.oldLogEntries ?? '?'} (of ${report?.totalLogEntries ?? '?'})`,
460
- ` Checkpoint to delete: ${report?.hasCheckpoint ? 'yes' : 'none'}`,
485
+ ` Checkpoint to delete: ${report?.checkpointStale ? 'yes (stale)' : report?.hasCheckpoint ? 'no (fresh — resume point, kept)' : 'none'}`,
461
486
  ` Stale backups to delete: ${(report?.staleBackups as string[] | undefined)?.length ?? 0}`,
462
487
  ` Stray temp files to delete: ${(report?.staleTemps as string[] | undefined)?.length ?? 0}`,
463
488
  ` Empty rounds to trim: ${(report?.emptyRounds as number[] | undefined)?.length ?? 0}`,
@@ -507,8 +532,10 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
507
532
 
508
533
  const result = executePrune(projectRoot, retainDays, report)
509
534
 
510
- // Log the prune to the decision log.
511
- appendDecisionEntry(projectRoot, {
535
+ // Log the prune to the decision log. The mutations already happened, so
536
+ // a failed log append is reported via `errors` — a silent audit miss
537
+ // (F2) must never pretend every deletion was recorded.
538
+ const logRes = appendDecisionEntry(projectRoot, {
512
539
  timestamp: new Date().toISOString(),
513
540
  round: 0,
514
541
  type: 'decision',
@@ -523,6 +550,7 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
523
550
  deletedDefenseEvents: result.deletedDefenseEvents,
524
551
  },
525
552
  })
553
+ if (logRes.error) result.errors.push(logRes.error)
526
554
 
527
555
  return {
528
556
  ok: true,
@@ -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) {
@@ -208,8 +208,9 @@ function readConfigFile(configPath: string): Record<string, unknown> {
208
208
  if (!existsSync(configPath)) return {}
209
209
  const content = readFileSync(configPath, 'utf-8')
210
210
  const parsed = yaml.load(content)
211
- if (!parsed || typeof parsed !== 'object') {
212
- // A config that exists but is not a YAML mapping must NOT be silently
211
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
212
+ // A config that exists but is NOT a YAML mapping (including a YAML list,
213
+ // which `typeof [] === 'object'` alone would accept) must NOT be silently
213
214
  // treated as empty: writing over it would destroy user data. Callers
214
215
  // surface this as an error and refuse to write.
215
216
  throw new Error('existing iterate.config.yaml is not a valid YAML mapping')
package/src/transcript.ts CHANGED
@@ -71,6 +71,20 @@ function clampStringList(source: string[], cap: number): string[] {
71
71
  return out
72
72
  }
73
73
 
74
+ /**
75
+ * Deep-clone a decision-timeline payload so it is decoupled from the caller's
76
+ * live object. structuredClone is available in every supported Node release;
77
+ * on the off chance it is missing, fall back to a JSON round-trip (decision
78
+ * payloads are JSON-serializable by construction).
79
+ */
80
+ function cloneDecisionData(data: Record<string, unknown>): Record<string, unknown> {
81
+ try {
82
+ return structuredClone(data)
83
+ } catch {
84
+ return JSON.parse(JSON.stringify(data)) as Record<string, unknown>
85
+ }
86
+ }
87
+
74
88
  /** Normalize a single finding, dropping malformed entries. */
75
89
  function normalizeFinding(input: unknown): TranscriptFinding | null {
76
90
  if (!input || typeof input !== 'object') return null
@@ -84,7 +98,10 @@ function normalizeFinding(input: unknown): TranscriptFinding | null {
84
98
  sev === 'critical' || sev === 'high' || sev === 'medium' || sev === 'low'
85
99
  ? sev
86
100
  : 'low'
87
- 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
88
105
  return {
89
106
  dimension,
90
107
  file,
@@ -113,6 +130,19 @@ interface LiveRound {
113
130
  threads: LiveThread[]
114
131
  }
115
132
 
133
+ /**
134
+ * Append one raw finding to a thread's list, bound to MAX_FINDINGS_PER_THREAD
135
+ * with NEWEST-WINS eviction (drop the oldest findings once the cap is hit).
136
+ * A single oversized fixer agent could otherwise OOM the client with an
137
+ * unbounded finding dump inside one thread.
138
+ */
139
+ function pushThreadFinding(thread: LiveThread, raw: unknown): void {
140
+ thread.findings.push(raw)
141
+ if (thread.findings.length > MAX_FINDINGS_PER_THREAD) {
142
+ thread.findings.splice(0, thread.findings.length - MAX_FINDINGS_PER_THREAD)
143
+ }
144
+ }
145
+
116
146
  /** Merge a report snapshot's findings/readFiles into a thread by dimension. */
117
147
  function mergeReportIntoThread(
118
148
  thread: LiveThread,
@@ -120,8 +150,7 @@ function mergeReportIntoThread(
120
150
  readFiles: readonly unknown[] | undefined,
121
151
  ): void {
122
152
  for (const raw of findings) {
123
- const f = normalizeFinding(raw)
124
- if (f) thread.findings.push(raw)
153
+ if (normalizeFinding(raw)) pushThreadFinding(thread, raw)
125
154
  }
126
155
  for (const r of readFiles ?? []) {
127
156
  if (typeof r === 'string') thread.readFiles.push(r)
@@ -146,6 +175,7 @@ export class ReviewTranscriptBuilder {
146
175
  private readonly rounds: LiveRound[] = []
147
176
  private readonly convergence: number[] = []
148
177
  private readonly globalFindings: TranscriptFinding[] = []
178
+ private readonly globalSeenKeys = new Set<string>()
149
179
  private readonly fixes: TranscriptFix[] = []
150
180
  private checkpoint: TranscriptCheckpoint | null = null
151
181
  private readonly timeline: TranscriptEntry[] = []
@@ -240,7 +270,8 @@ export class ReviewTranscriptBuilder {
240
270
  const att = typeof attempt === 'number' && Number.isFinite(attempt) ? Math.floor(attempt) : 1
241
271
  this.roundStart(this.round)
242
272
  const live = this.rounds[this.round - 1]! as LiveRound | undefined
243
- if (live && this.threadCount(live) < MAX_THREADS_PER_ROUND) {
273
+ if (!live) return
274
+ if (this.threadCount(live) < MAX_THREADS_PER_ROUND) {
244
275
  live.threads.push({
245
276
  dimension: dim || 'review',
246
277
  attempt: att > 0 ? att : 1,
@@ -248,10 +279,89 @@ export class ReviewTranscriptBuilder {
248
279
  readFiles: [],
249
280
  findings: [],
250
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
+ }
251
300
  }
252
301
  this.touch()
253
302
  }
254
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
+ }
360
+ }
361
+ live.threads.push(t)
362
+ this.touch()
363
+ }
364
+
255
365
  /** Append narration (assistant text) to the current reviewer thread. */
256
366
  reviewerMessage(text: string): void {
257
367
  if (typeof text !== 'string' || !text.trim()) return
@@ -282,12 +392,11 @@ export class ReviewTranscriptBuilder {
282
392
  for (const raw of findings) {
283
393
  const f = normalizeFinding(raw)
284
394
  if (f) {
285
- thread.findings.push(raw)
286
- this.globalFindings.push(f)
395
+ pushThreadFinding(thread, raw)
396
+ this.addGlobal(f)
287
397
  }
288
398
  }
289
399
  }
290
- this.reevaluateGlobal()
291
400
  this.touch()
292
401
  }
293
402
 
@@ -299,9 +408,8 @@ export class ReviewTranscriptBuilder {
299
408
  mergeReportIntoThread(thread, findings, readFiles)
300
409
  for (const raw of findings) {
301
410
  const f = normalizeFinding(raw)
302
- if (f) this.globalFindings.push(f)
411
+ if (f) this.addGlobal(f)
303
412
  }
304
- this.reevaluateGlobal()
305
413
  this.touch()
306
414
  }
307
415
 
@@ -390,8 +498,12 @@ export class ReviewTranscriptBuilder {
390
498
  : this.round,
391
499
  type,
392
500
  data:
393
- entry.data && typeof entry.data === 'object'
394
- ? (entry.data as Record<string, unknown>)
501
+ entry.data && typeof entry.data === 'object' && !Array.isArray(entry.data)
502
+ // Deep clone so a caller mutating its own object later can never
503
+ // alias into the timeline (serialize would then emit the mutated
504
+ // values instead of what was actually decided). Decision-log payloads
505
+ // are small JSON, so the clone cost is negligible.
506
+ ? cloneDecisionData(entry.data as Record<string, unknown>)
395
507
  : {},
396
508
  })
397
509
  if (this.timeline.length > MAX_TIMELINE) {
@@ -441,10 +553,9 @@ export class ReviewTranscriptBuilder {
441
553
  maxRounds: this.maxRounds,
442
554
  rounds,
443
555
  convergence: this.convergence,
444
- findings:
445
- this.globalFindings.length > MAX_FINDINGS_TOTAL
446
- ? this.globalFindings.slice(0, MAX_FINDINGS_TOTAL)
447
- : this.globalFindings,
556
+ // addGlobal maintains the newest-wins cap incrementally, so the array is
557
+ // already ≤ MAX_FINDINGS_TOTAL — no post-hoc slice needed.
558
+ findings: this.globalFindings,
448
559
  fixes: this.fixes,
449
560
  checkpoint: this.checkpoint,
450
561
  timeline: this.timeline,
@@ -477,22 +588,24 @@ export class ReviewTranscriptBuilder {
477
588
  return live.threads.length
478
589
  }
479
590
 
480
- /** Recompute the global finding list from per-thread findings (dedup). */
481
- private reevaluateGlobal(): void {
482
- // Rebuild from threads to derive a deterministic global list.
483
- this.globalFindings.length = 0
484
- const seen = new Set<string>()
485
- for (const r of this.rounds) {
486
- for (const t of r.threads) {
487
- for (const raw of t.findings) {
488
- const f = normalizeFinding(raw)
489
- if (!f) continue
490
- const key = `${f.file}\u0000${f.line ?? 0}\u0000${f.dimension}\u0000${f.summary}`
491
- if (seen.has(key)) continue
492
- seen.add(key)
493
- this.globalFindings.push(f)
494
- if (this.globalFindings.length >= MAX_FINDINGS_TOTAL) return
495
- }
591
+ /**
592
+ * Insert one deduplicated finding into the compact global list.
593
+ * NEWEST-WINS: when the global cap (MAX_FINDINGS_TOTAL) is hit, the oldest
594
+ * kept finding is evicted so fresh, still-relevant findings survive — this
595
+ * is the documented contract. Amortized O(1): dedupe via a seen-key set and
596
+ * eviction is one `shift` per overflow insert.
597
+ */
598
+ private addGlobal(f: TranscriptFinding): void {
599
+ const key = `${f.file}\u0000${f.line ?? 0}\u0000${f.dimension}\u0000${f.summary}`
600
+ if (this.globalSeenKeys.has(key)) return
601
+ this.globalSeenKeys.add(key)
602
+ this.globalFindings.push(f)
603
+ if (this.globalFindings.length > MAX_FINDINGS_TOTAL) {
604
+ const evicted = this.globalFindings.shift()
605
+ if (evicted) {
606
+ this.globalSeenKeys.delete(
607
+ `${evicted.file}\u0000${evicted.line ?? 0}\u0000${evicted.dimension}\u0000${evicted.summary}`,
608
+ )
496
609
  }
497
610
  }
498
611
  }
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