iterate-plugin 3.5.3 → 3.5.5

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.
Files changed (53) hide show
  1. package/dist/atomic-fs.js +22 -4
  2. package/dist/config-loader.js +8 -3
  3. package/dist/config-write.js +50 -9
  4. package/dist/evidence.js +28 -1
  5. package/dist/git-scope.js +14 -5
  6. package/dist/jobs.js +15 -1
  7. package/dist/live.js +39 -22
  8. package/dist/meta-review.js +2 -2
  9. package/dist/review-scope.js +6 -2
  10. package/dist/review.js +7 -6
  11. package/dist/session-hooks.js +48 -4
  12. package/dist/skill-prompt.js +66 -32
  13. package/dist/tools/context.js +5 -1
  14. package/dist/tools/decision-log.js +142 -17
  15. package/dist/tools/defense-events.js +7 -3
  16. package/dist/tools/defense-store.js +14 -7
  17. package/dist/tools/experience-bank.js +6 -3
  18. package/dist/tools/experience-store.js +24 -12
  19. package/dist/tools/fix.js +24 -3
  20. package/dist/tools/prune.js +185 -17
  21. package/dist/tools/review.js +17 -3
  22. package/dist/tools/transcript.js +28 -6
  23. package/dist/tools/triage.js +38 -4
  24. package/dist/transcript.js +68 -34
  25. package/lib/client.js +41 -11
  26. package/lib/parse.js +30 -2
  27. package/package.json +10 -7
  28. package/src/atomic-fs.ts +20 -4
  29. package/src/client/index.ts +23 -3
  30. package/src/config-loader.ts +8 -3
  31. package/src/config-write.ts +59 -9
  32. package/src/evidence.ts +29 -1
  33. package/src/git-scope.ts +14 -3
  34. package/src/jobs.ts +15 -1
  35. package/src/live.ts +41 -22
  36. package/src/meta-review.ts +2 -2
  37. package/src/review-scope.ts +6 -2
  38. package/src/review.ts +7 -6
  39. package/src/session-hooks.ts +50 -4
  40. package/src/skill-prompt.ts +66 -32
  41. package/src/tools/context.ts +5 -1
  42. package/src/tools/decision-log.ts +136 -16
  43. package/src/tools/defense-events.ts +7 -3
  44. package/src/tools/defense-store.ts +14 -7
  45. package/src/tools/experience-bank.ts +6 -3
  46. package/src/tools/experience-store.ts +24 -12
  47. package/src/tools/fix.ts +25 -3
  48. package/src/tools/prune.ts +194 -17
  49. package/src/tools/review.ts +16 -3
  50. package/src/tools/transcript.ts +26 -5
  51. package/src/tools/triage.ts +39 -4
  52. package/src/transcript.ts +68 -31
  53. package/src/types.ts +2 -0
@@ -9,6 +9,7 @@ import * as fs from 'node:fs'
9
9
  import * as path from 'node:path'
10
10
  import { iterateDir } from '../paths.ts'
11
11
  import { writeJsonAtomic } from '../atomic-fs.ts'
12
+ import { hashString } from './fix.ts'
12
13
  import type { ExperienceBank, ExperienceEntry } from '../types.ts'
13
14
 
14
15
  const EXPERIENCE_FILE = 'experience.json'
@@ -38,12 +39,15 @@ function stringArray(v: unknown): string[] {
38
39
  * throw or emit NaN. Returns null for non-object entries; every required field
39
40
  * gets a safe default.
40
41
  */
41
- function normalizeEntry(raw: unknown): ExperienceEntry | null {
42
+ function normalizeEntry(raw: unknown, index: number): ExperienceEntry | null {
42
43
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
43
44
  const e = raw as Record<string, unknown>
44
45
  const pattern = typeof e.pattern === 'string' ? e.pattern : ''
45
46
  const dimension = typeof e.dimension === 'string' ? e.dimension : ''
46
- const id = typeof e.id === 'string' && e.id ? e.id : `exp-${Math.random().toString(36).slice(2, 8)}`
47
+ // Deterministic id (content + position based) so a read→write cycle never
48
+ // churns random ids on entries that were hand-edited without one.
49
+ const derivedId = `exp-${hashString(`${index}|${pattern}|${dimension}`)}`
50
+ const id = typeof e.id === 'string' && e.id ? e.id : derivedId
47
51
  if (!pattern && !dimension) return null
48
52
  const hitCount = typeof e.hitCount === 'number' && Number.isFinite(e.hitCount) ? e.hitCount : 0
49
53
  return {
@@ -70,7 +74,7 @@ export function readExperienceBank(projectRoot: string): ExperienceBank {
70
74
  const parsed = JSON.parse(content) as Partial<ExperienceBank>
71
75
  if (parsed && Array.isArray(parsed.entries)) {
72
76
  const entries = parsed.entries
73
- .map(normalizeEntry)
77
+ .map((raw, i) => normalizeEntry(raw, i))
74
78
  .filter((e): e is ExperienceEntry => e !== null)
75
79
  return {
76
80
  entries,
@@ -167,8 +171,11 @@ export type ExperienceEntryInput = Omit<
167
171
  /**
168
172
  * Add or update an experience entry.
169
173
  *
170
- * An entry with an `id` that already exists, OR a new entry whose
171
- * `pattern`+`dimension` pair matches an existing entry, is treated as a HIT:
174
+ * An entry with an `id` that already exists is treated as an UPDATE + HIT:
175
+ * the caller-supplied fields replace the stored ones and the hitCount is
176
+ * incremented (lastHitAt refreshed) — this fulfills the documented
177
+ * "update a specific entry via add" contract. A NEW entry whose
178
+ * `pattern`+`dimension` pair matches an existing entry is treated as a HIT:
172
179
  * the matching entry's hitCount is incremented (lastHitAt refreshed) so
173
180
  * repeated encounters of the same pattern do not create duplicates. Otherwise
174
181
  * a fresh entry is appended with hitCount 1. Never mutates the input bank.
@@ -186,11 +193,14 @@ export function upsertExperience(
186
193
  : bank.entries.find((e) => e.pattern === entry.pattern && e.dimension === entry.dimension)
187
194
 
188
195
  if (existing) {
189
- const updated: ExperienceEntry = {
190
- ...existing,
191
- hitCount: (existing.hitCount ?? 0) + 1,
192
- lastHitAt: lastUpdated,
193
- }
196
+ // Explicit-id updates REPLACE the editable fields (fulfilling the
197
+ // documented "update a specific entry via add" contract); a pattern+
198
+ // dimension HIT only bumps the hit metadata so a repeat encounter never
199
+ // overwrites the curated entry.
200
+ const byId = typeof entry.id === 'string' && entry.id.length > 0
201
+ const updated: ExperienceEntry = byId
202
+ ? { ...existing, ...entry, id: existing.id, hitCount: (existing.hitCount ?? 0) + 1, lastHitAt: lastUpdated }
203
+ : { ...existing, hitCount: (existing.hitCount ?? 0) + 1, lastHitAt: lastUpdated }
194
204
  return {
195
205
  bank: {
196
206
  ...bank,
@@ -203,14 +213,16 @@ export function upsertExperience(
203
213
  }
204
214
  }
205
215
 
206
- // Add new entry
216
+ // Add new entry. Spread the caller input FIRST so the store-generated
217
+ // `id`/`timestamp`/`hitCount`/`lastHitAt` always win — a hostile or
218
+ // malformed input can never forge its own identity or hit count.
207
219
  const id = entry.id || `exp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
208
220
  const newEntry: ExperienceEntry = {
221
+ ...entry,
209
222
  id,
210
223
  timestamp: lastUpdated,
211
224
  hitCount: 1,
212
225
  lastHitAt: lastUpdated,
213
- ...entry,
214
226
  }
215
227
 
216
228
  return {
package/src/tools/fix.ts CHANGED
@@ -366,6 +366,7 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
366
366
  linesRemoved: { type: 'integer' },
367
367
  diffSummary: { type: 'string' },
368
368
  backupPath: { type: 'string' },
369
+ warning: { type: 'string' },
369
370
  error: { type: 'string' },
370
371
  },
371
372
  },
@@ -456,6 +457,17 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
456
457
  return { ok: false, error: `finding already fixed this run (id: ${id})`, id }
457
458
  }
458
459
 
460
+ // No-op guard: content-identical "fixes" (e.g. a fixer that re-sent the
461
+ // file unchanged) must never burn a backup, a write, or a registry/success
462
+ // record. Placed after the registry check so a re-sent fix of an id that
463
+ // was ALREADY fixed is still reported as "already fixed this run".
464
+ if (added === 0 && removed === 0) {
465
+ return {
466
+ ok: false,
467
+ error: `no changes: the supplied content for ${file} is identical to the current content — apply a real edit`,
468
+ }
469
+ }
470
+
459
471
  const target = resolveProjectFile(projectRoot, file)
460
472
  if (!target.ok) return { ok: false, error: target.reason }
461
473
 
@@ -528,13 +540,16 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
528
540
  return { ok: false, error: `failed to write fix registry: ${String(err)} (file restored from backup)` }
529
541
  }
530
542
 
531
- appendDecisionEntry(projectRoot, {
543
+ const logRes = appendDecisionEntry(projectRoot, {
532
544
  timestamp,
533
545
  round: args.round,
534
546
  type: 'atomic_fix',
535
547
  data: { id, file, finding: finding.summary, linesAdded: added, linesRemoved: removed },
536
548
  })
537
549
 
550
+ // The fix itself succeeded, but a decision-log write failure would leave
551
+ // the audit trail incomplete — surface it (warning, not fatal) so the
552
+ // model/UI knows the record was not persisted.
538
553
  return {
539
554
  ok: true,
540
555
  id,
@@ -544,6 +559,7 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
544
559
  linesRemoved: removed,
545
560
  diffSummary: record.diffSummary,
546
561
  backupPath,
562
+ ...(logRes.error ? { warning: logRes.error } : {}),
547
563
  }
548
564
  })
549
565
  return result
@@ -710,6 +726,7 @@ export function registerRollbackTool(ctx: { tools: { register: (def: ReturnType<
710
726
  ok: { type: 'boolean', required: true },
711
727
  id: { type: 'string' },
712
728
  file: { type: 'string' },
729
+ warning: { type: 'string' },
713
730
  error: { type: 'string' },
714
731
  },
715
732
  },
@@ -749,7 +766,7 @@ export function registerRollbackTool(ctx: { tools: { register: (def: ReturnType<
749
766
  return { ok: false, error: `failed to update fix registry: ${String(err)}` }
750
767
  }
751
768
 
752
- appendDecisionEntry(projectRoot, {
769
+ const logRes = appendDecisionEntry(projectRoot, {
753
770
  timestamp: new Date().toISOString(),
754
771
  round: record.round,
755
772
  type: 'revert',
@@ -762,7 +779,12 @@ export function registerRollbackTool(ctx: { tools: { register: (def: ReturnType<
762
779
  // rollback flow.
763
780
  await markFixRolledBackInTranscript(projectRoot, id)
764
781
 
765
- return { ok: true, id, file: record.finding.file }
782
+ return {
783
+ ok: true,
784
+ id,
785
+ file: record.finding.file,
786
+ ...(logRes.error ? { warning: logRes.error } : {}),
787
+ }
766
788
  },
767
789
  }),
768
790
  )
@@ -13,6 +13,10 @@
13
13
  * - Empty fix rounds (rounds with 0 records).
14
14
  * - Stray temp files left behind by a crashed atomic write (see
15
15
  * `isPrunableTemp` for the recognized naming conventions).
16
+ * - Experience bank entries beyond `MAX_EXPERIENCE_ENTRIES` (newest kept) —
17
+ * the bank grows across sessions with no natural bound; a sweep caps it.
18
+ * - Defense events older than `retainDays` (stale iteration streams that
19
+ * were never cleared).
16
20
  *
17
21
  * Security model:
18
22
  * - Only operates under the resolved project `.iterate/` directory.
@@ -26,16 +30,29 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
26
30
  import type { JsonValue } from '@deepseek-ai/dsh-util-values'
27
31
  import { resolveProjectRootForExec } from '../config-loader.ts'
28
32
  import { writeTextAtomic, writeJsonAtomic } from '../atomic-fs.ts'
29
- import { readDecisionEntries, appendDecisionEntry } from './decision-log.ts'
33
+ import { readDecisionEntries, appendDecisionEntry, acquireLogLock } from './decision-log.ts'
30
34
  import { readRegistry, removeRecord, recomputeRoundCounts } from './fix.ts'
35
+ import { readExperienceBank, writeExperienceBank } from './experience-store.ts'
36
+ import { readDefenseEvents, writeDefenseEvents } from './defense-store.ts'
31
37
  import { iterateDir, fixesDir, checkpointPath, fixRegistryPath } from '../paths.ts'
32
- import type { FixRegistry } from '../types.ts'
38
+ import type { DefenseEventStream, ExperienceBank, FixRegistry } from '../types.ts'
33
39
 
34
40
  /** Default retention for decision-log entries (in days). */
35
41
  const DEFAULT_RETAIN_DAYS = 30
36
42
  const MIN_RETAIN_DAYS = 1
37
43
  const MAX_RETAIN_DAYS = 365
38
44
 
45
+ /**
46
+ * Upper bound on experience-bank entries retained by a prune sweep. The bank
47
+ * accumulates curated knowledge across sessions and each entry is small, so a
48
+ * generous cap drops only the oldest tail of a pathologically large bank
49
+ * (never recent learning).
50
+ */
51
+ export const MAX_EXPERIENCE_ENTRIES = 2000
52
+
53
+ /** How many compare-and-append retries bound the decision-log rewrite race. */
54
+ const MAX_LOG_REWRITE_RETRIES = 3
55
+
39
56
  /** Clamp retainDays to a sane range. */
40
57
  export function clampRetainDays(days: number | undefined): number {
41
58
  if (typeof days !== 'number' || !Number.isInteger(days) || days <= 0) {
@@ -71,6 +88,62 @@ export function isPrunableTemp(name: string): boolean {
71
88
  return name.startsWith('.') && name.includes('.tmp-')
72
89
  }
73
90
 
91
+ /**
92
+ * Bound the experience bank to its newest `cap` entries (by timestamp; ties
93
+ * keep their original order via stable sort). Returns the trimmed bank plus
94
+ * how many entries would be dropped. Never mutates the input bank.
95
+ */
96
+ export function sweepExperienceBank(bank: ExperienceBank, cap: number): { bank: ExperienceBank; removed: number } {
97
+ const size = Number.isFinite(cap) && (cap as number) >= 0 ? Math.floor(cap) : MAX_EXPERIENCE_ENTRIES
98
+ const total = bank.entries.length
99
+ if (total <= size) return { bank, removed: 0 }
100
+ const kept = [...bank.entries]
101
+ .sort((a, b) => (b.timestamp ?? '').localeCompare(a.timestamp ?? ''))
102
+ .slice(0, size)
103
+ // Preserve the original (insertion) order of the surviving entries.
104
+ const keptIds = new Set(kept.map((e) => e.id))
105
+ const ordered = bank.entries.filter((e) => keptIds.has(e.id))
106
+ return {
107
+ bank: { ...bank, entries: ordered, lastUpdated: new Date().toISOString() },
108
+ removed: total - ordered.length,
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Drop defense events older than `cutoff` from the stream using an ISO
114
+ * timestamp comparison, recomputing the counts so they can never drift from
115
+ * the surviving events. Never mutates the input stream.
116
+ */
117
+ export function sweepDefenseEvents(
118
+ stream: DefenseEventStream,
119
+ cutoff: string,
120
+ ): { stream: DefenseEventStream; removed: number } {
121
+ const kept = stream.events.filter((e) => e.timestamp >= cutoff)
122
+ if (kept.length === stream.events.length) return { stream, removed: 0 }
123
+ return {
124
+ stream: {
125
+ events: kept,
126
+ lastUpdated: new Date().toISOString(),
127
+ counts: computeDefenseCounts(kept),
128
+ },
129
+ removed: stream.events.length - kept.length,
130
+ }
131
+ }
132
+
133
+ /** Recompute per-type defense counts from a list of events. */
134
+ function computeDefenseCounts(events: DefenseEventStream['events']): DefenseEventStream['counts'] {
135
+ const counts: DefenseEventStream['counts'] = {
136
+ precondition_failed: 0,
137
+ rollback: 0,
138
+ invariant_violated: 0,
139
+ assumption_falsified: 0,
140
+ }
141
+ for (const e of events) {
142
+ if (e.type in counts) counts[e.type]++
143
+ }
144
+ return counts
145
+ }
146
+
74
147
  /**
75
148
  * Inspect the runtime state and report what would be pruned.
76
149
  * Pure (no deletions). Returns a structured report.
@@ -86,6 +159,10 @@ export function inspectPrune(
86
159
  emptyRounds: number[]
87
160
  totalLogEntries: number
88
161
  registryRounds: number
162
+ totalExperiences: number
163
+ experienceOversize: number
164
+ totalDefenseEvents: number
165
+ staleDefenseEvents: number
89
166
  } {
90
167
  const cutoff = cutoffTimestamp(retainDays)
91
168
 
@@ -142,6 +219,17 @@ export function inspectPrune(
142
219
  }
143
220
  const staleTemps = iterateEntries.filter((f) => isPrunableTemp(f)).sort()
144
221
 
222
+ // 6. Experience-bank oversize (entries beyond the entry cap). The bank grows
223
+ // across sessions with no natural bound — a sweep reports the tail that a
224
+ // prune would drop (newest `MAX_EXPERIENCE_ENTRIES` preserved).
225
+ const bank = readExperienceBank(projectRoot)
226
+ const sweptBank = sweepExperienceBank(bank, MAX_EXPERIENCE_ENTRIES)
227
+
228
+ // 7. Defense events older than retainDays (a previous iteration's stream
229
+ // that was never cleared).
230
+ const defenseStream = readDefenseEvents(projectRoot)
231
+ const sweptDefense = sweepDefenseEvents(defenseStream, cutoff)
232
+
145
233
  return {
146
234
  oldLogEntries,
147
235
  hasCheckpoint,
@@ -150,6 +238,10 @@ export function inspectPrune(
150
238
  emptyRounds,
151
239
  totalLogEntries: entries.length,
152
240
  registryRounds: registry.rounds.length,
241
+ totalExperiences: bank.entries.length,
242
+ experienceOversize: sweptBank.removed,
243
+ totalDefenseEvents: defenseStream.events.length,
244
+ staleDefenseEvents: sweptDefense.removed,
153
245
  }
154
246
  }
155
247
 
@@ -157,6 +249,54 @@ export function inspectPrune(
157
249
  * Actually prune the runtime artifacts (only called when dryRun=false).
158
250
  * Returns a detailed report of what was deleted.
159
251
  */
252
+ /**
253
+ * Rewrite the decision log to the entries younger than `cutoff`.
254
+ *
255
+ * The read-before-rewrite is a TOCTOU hot spot: a concurrent worker can append
256
+ * a fresh audit line between our read and our atomic rewrite, and an atomic
257
+ * rename would silently discard it. So instead of a single read+write we use a
258
+ * bounded compare-and-append loop — after each rewrite we re-read the log and
259
+ * retry whenever the file grew (a concurrent append slipped in). The loop
260
+ * terminates when the file is stable or after `MAX_LOG_REWRITE_RETRIES`, and a
261
+ * failure here is surfaced as a structured error instead of truncating the log.
262
+ *
263
+ * @returns the number of entries removed (best-effort) or 0 on error.
264
+ */
265
+ function rewriteDecisionLogKeepingRecent(projectRoot: string, cutoff: string): { deleted: number; error?: string } {
266
+ const logPath = join(iterateDir(projectRoot), 'decision-log.jsonl')
267
+ // Take the cross-process log lock for the ENTIRE loop: with the mutex held,
268
+ // no concurrent appender can slip a fresh line into the read-before-rewrite
269
+ // window, so the first attempt is nearly always the only one. (The retry
270
+ // loop stays as defense-in-depth for the lock-unavailable path.)
271
+ const release = acquireLogLock(projectRoot)
272
+ try {
273
+ for (let attempt = 0; attempt < MAX_LOG_REWRITE_RETRIES; attempt++) {
274
+ try {
275
+ const entries = readDecisionEntries(projectRoot)
276
+ const kept = entries.filter((e) => e.timestamp >= cutoff)
277
+ const deleted = entries.length - kept.length
278
+ if (deleted === 0) return { deleted: 0 }
279
+ writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n')
280
+ // A concurrent appender (cross-process, lock-unavailable path) may have
281
+ // landed new lines after our read. If the log grew during the write
282
+ // window, re-read and prune again instead of accepting a lost audit trail.
283
+ const after = readDecisionEntries(projectRoot)
284
+ if (after.length <= kept.length) return { deleted }
285
+ } catch (err) {
286
+ return { deleted: 0, error: `failed to rewrite decision log: ${String(err)}` }
287
+ }
288
+ }
289
+ return { deleted: 0, error: `failed to rewrite decision log after ${MAX_LOG_REWRITE_RETRIES} attempts` }
290
+ } finally {
291
+ release()
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Execute the prune: apply every cleanable item reported by `inspectPrune`.
297
+ * Every step is individually hedged so a single disk failure degrades to a
298
+ * reported error rather than aborting the whole cleanup.
299
+ */
160
300
  export function executePrune(
161
301
  projectRoot: string,
162
302
  retainDays: number,
@@ -167,6 +307,8 @@ export function executePrune(
167
307
  deletedBackups: string[]
168
308
  deletedTemps: string[]
169
309
  trimmedEmptyRounds: number
310
+ deletedExperiences: number
311
+ deletedDefenseEvents: number
170
312
  errors: string[]
171
313
  } {
172
314
  const cutoff = cutoffTimestamp(retainDays)
@@ -176,22 +318,19 @@ export function executePrune(
176
318
  deletedBackups: [] as string[],
177
319
  deletedTemps: [] as string[],
178
320
  trimmedEmptyRounds: 0,
321
+ deletedExperiences: 0,
322
+ deletedDefenseEvents: 0,
179
323
  errors: [] as string[],
180
324
  }
181
325
 
182
326
  // 1. Rewrite the decision log, keeping only recent entries. Atomic
183
- // (temp + rename) so a crash mid-write can never truncate the log.
184
- try {
185
- const entries = readDecisionEntries(projectRoot)
186
- const kept = entries.filter((e) => e.timestamp >= cutoff)
187
- result.deletedLogEntries = entries.length - kept.length
188
- if (result.deletedLogEntries > 0) {
189
- const logPath = join(iterateDir(projectRoot), 'decision-log.jsonl')
190
- writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n')
191
- }
192
- } catch (err) {
193
- result.errors.push(`failed to rewrite decision log: ${String(err)}`)
194
- result.deletedLogEntries = 0
327
+ // (temp + rename) so a crash mid-write can never truncate the log; the
328
+ // bounded compare-and-append loop guards against a concurrent appender
329
+ // slipping a fresh audit line into the read-before-rewrite window.
330
+ {
331
+ const { deleted, error } = rewriteDecisionLogKeepingRecent(projectRoot, cutoff)
332
+ result.deletedLogEntries = deleted
333
+ if (error) result.errors.push(error)
195
334
  }
196
335
 
197
336
  // 2. Remove checkpoint.
@@ -244,6 +383,34 @@ export function executePrune(
244
383
  }
245
384
  }
246
385
 
386
+ // 6. Trim the experience bank to its entry cap (newest wins).
387
+ if (report.experienceOversize > 0) {
388
+ try {
389
+ const bank = readExperienceBank(projectRoot)
390
+ const { bank: swept, removed } = sweepExperienceBank(bank, MAX_EXPERIENCE_ENTRIES)
391
+ if (removed > 0) {
392
+ writeExperienceBank(projectRoot, swept)
393
+ result.deletedExperiences = removed
394
+ }
395
+ } catch (err) {
396
+ result.errors.push(`failed to trim experience bank: ${String(err)}`)
397
+ }
398
+ }
399
+
400
+ // 7. Drop defense events older than retainDays (recounted on write).
401
+ if (report.staleDefenseEvents > 0) {
402
+ try {
403
+ const stream = readDefenseEvents(projectRoot)
404
+ const { stream: swept, removed } = sweepDefenseEvents(stream, cutoff)
405
+ if (removed > 0) {
406
+ writeDefenseEvents(projectRoot, swept)
407
+ result.deletedDefenseEvents = removed
408
+ }
409
+ } catch (err) {
410
+ result.errors.push(`failed to sweep defense events: ${String(err)}`)
411
+ }
412
+ }
413
+
247
414
  return result
248
415
  }
249
416
 
@@ -260,7 +427,8 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
260
427
  'Inspect or clean up old iterate runtime artifacts (.iterate/). ' +
261
428
  'Defaults to dry-run (report-only, no deletion). Pass `dryRun: false` to actually prune. ' +
262
429
  'Manages: old decision-log entries, stale checkpoints, orphaned fix backups, empty fix rounds, ' +
263
- 'and stray temp files left by crashed atomic writes. ' +
430
+ 'stray temp files left by crashed atomic writes, experience-bank entries beyond the entry cap, ' +
431
+ 'and stale defense events. ' +
264
432
  'Each deletion is logged to the decision log.',
265
433
  parameters: {
266
434
  dryRun: {
@@ -302,6 +470,8 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
302
470
  ` Stale backups to delete: ${(report?.staleBackups as string[] | undefined)?.length ?? 0}`,
303
471
  ` Stray temp files to delete: ${(report?.staleTemps as string[] | undefined)?.length ?? 0}`,
304
472
  ` Empty rounds to trim: ${(report?.emptyRounds as number[] | undefined)?.length ?? 0}`,
473
+ ` Experience entries to drop (over ${report?.totalExperiences ?? '?'} cap): ${report?.experienceOversize ?? 0} of ${report?.totalExperiences ?? '?'}`,
474
+ ` Stale defense events to remove: ${report?.staleDefenseEvents ?? 0} (of ${report?.totalDefenseEvents ?? '?'})`,
305
475
  '',
306
476
  'Pass dryRun:false to execute the prune.',
307
477
  ]
@@ -314,6 +484,8 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
314
484
  ` Deleted ${(result?.deletedBackups as string[] | undefined)?.length ?? 0} stale backups.`,
315
485
  ` Deleted ${(result?.deletedTemps as string[] | undefined)?.length ?? 0} stray temp files.`,
316
486
  ` Trimmed ${result?.trimmedEmptyRounds ?? 0} empty rounds.`,
487
+ ` Trimmed ${result?.deletedExperiences ?? 0} experience entries (over the entry cap).`,
488
+ ` Removed ${result?.deletedDefenseEvents ?? 0} stale defense events.`,
317
489
  ]
318
490
  const errs = (result?.errors as string[] | undefined) ?? []
319
491
  if (errs.length > 0) {
@@ -344,8 +516,10 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
344
516
 
345
517
  const result = executePrune(projectRoot, retainDays, report)
346
518
 
347
- // Log the prune to the decision log.
348
- appendDecisionEntry(projectRoot, {
519
+ // Log the prune to the decision log. The mutations already happened, so
520
+ // a failed log append is reported via `errors` — a silent audit miss
521
+ // (F2) must never pretend every deletion was recorded.
522
+ const logRes = appendDecisionEntry(projectRoot, {
349
523
  timestamp: new Date().toISOString(),
350
524
  round: 0,
351
525
  type: 'decision',
@@ -356,8 +530,11 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
356
530
  deletedCheckpoint: result.deletedCheckpoint,
357
531
  deletedBackups: result.deletedBackups.length,
358
532
  trimmedEmptyRounds: result.trimmedEmptyRounds,
533
+ deletedExperiences: result.deletedExperiences,
534
+ deletedDefenseEvents: result.deletedDefenseEvents,
359
535
  },
360
536
  })
537
+ if (logRes.error) result.errors.push(logRes.error)
361
538
 
362
539
  return {
363
540
  ok: true,
@@ -265,9 +265,22 @@ export function registerReviewTool(ctx: { tools: { register: (def: ReturnType<ty
265
265
  const coverageEnabled = config.reviewer?.coverage_validation !== false
266
266
  let coverage: CoverageResult | null = null
267
267
  if (coverageEnabled) {
268
- const assigned = collectScopeFiles(projectRoot, {
269
- scope: config.review?.scope === 'changed-only' ? 'changed-only' : 'full',
270
- })
268
+ // Build the assigned inventory the SAME way `plan` builds it: a
269
+ // changed-only scope uses the resolved git-diff file set, and
270
+ // falls back to the FULL walk when git is unavailable or nothing
271
+ // changed (mirrors buildReviewPlan's effective-scope decision), so
272
+ // the coverage ratio reflects the files reviewers were actually
273
+ // told to read — never a vacuous 1.0 on an empty inventory.
274
+ let assigned: string[]
275
+ if (config.review?.scope === 'changed-only') {
276
+ const gitScope = await resolveChangedFiles(projectRoot, config.git?.target_branch ?? 'main')
277
+ assigned =
278
+ gitScope.changedFiles.length > 0
279
+ ? collectScopeFiles(projectRoot, { scope: 'changed-only', changedFiles: gitScope.changedFiles })
280
+ : collectScopeFiles(projectRoot, { scope: 'full' })
281
+ } else {
282
+ assigned = collectScopeFiles(projectRoot, { scope: 'full' })
283
+ }
271
284
  const readFiles = Array.isArray((source as unknown as { readFiles?: unknown }).readFiles)
272
285
  ? ((source as unknown as { readFiles?: unknown }).readFiles as string[])
273
286
  : null
@@ -160,6 +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
164
  text: { type: 'string', description: 'For `nudge`: steering text to set (or null to clear).' },
164
165
  path: { type: 'string', description: 'Project root directory (default: current working directory).' },
165
166
  },
@@ -231,13 +232,24 @@ export function registerTranscriptTool(ctx: {
231
232
  }
232
233
  // Rehydrating a parsed-but-malformed manifest (e.g. `rounds: [null]`
233
234
  // or a non-object) must never crash the nudge — degrade to a fresh
234
- // builder so the steering text still lands.
235
+ // builder so the steering text still lands. The fallback must keep
236
+ // the ORIGINAL run identity (mode/taskMode/goal/maxRounds) so a
237
+ // stray malformed field can never silently rewrite a dry-run run
238
+ // into a `normal` one.
239
+ const fallbackOpts = {
240
+ project: projectRoot,
241
+ mode: (manifest?.mode === 'dry-run' || manifest?.mode === 'normal' ? manifest.mode : null) as 'dry-run' | 'normal' | null,
242
+ taskMode: (manifest?.taskMode === 'code' || manifest?.taskMode === 'iterate' ? manifest.taskMode : null) as 'code' | 'iterate' | null,
243
+ approval,
244
+ goal: typeof manifest?.goal === 'string' ? manifest.goal : '',
245
+ maxRounds: typeof manifest?.maxRounds === 'number' ? manifest.maxRounds : 0,
246
+ }
235
247
  let builder: ReviewTranscriptBuilder
236
248
  if (manifest) {
237
249
  try {
238
250
  builder = rehydrateBuilder(manifest, approval)
239
251
  } catch {
240
- builder = new ReviewTranscriptBuilder({ project: projectRoot, mode: 'normal', approval })
252
+ builder = new ReviewTranscriptBuilder(fallbackOpts)
241
253
  }
242
254
  } else {
243
255
  builder = new ReviewTranscriptBuilder({ project: projectRoot, mode: 'normal', approval })
@@ -305,9 +317,18 @@ export function registerTranscriptTool(ctx: {
305
317
  if (record) builder.fix(record)
306
318
  }
307
319
  }
308
- // Convergence "found nothing → settled" marker when the trend ends on 0.
320
+ // End state: an explicit stoppedReason wins; otherwise derive it from
321
+ // the trend. A run that settled closes as "converged"; a run that did
322
+ // real work but never trended to 0 closes as "max_rounds_reached";
323
+ // a capture with no rounds stays "active" (nothing recorded yet).
324
+ const explicitReason =
325
+ typeof args.stoppedReason === 'string' && args.stoppedReason.trim()
326
+ ? args.stoppedReason.trim()
327
+ : ''
309
328
  const last = convergence[convergence.length - 1]
310
- if (convergence.length > 0 && last === 0) builder.finish()
329
+ if (explicitReason) builder.finish(explicitReason)
330
+ else if (convergence.length > 0 && last === 0) builder.finish('converged')
331
+ else if (roundsExecuted > 0) builder.finish('max_rounds_reached')
311
332
 
312
333
  const persisted = await persistChecked(file, builder.serialize())
313
334
  if (!persisted.ok) {
@@ -353,7 +374,7 @@ function rehydrateBuilder(manifest: TranscriptManifest, approval: 'ask' | 'deny'
353
374
  if (Array.isArray(manifest.fixes)) for (const fx of manifest.fixes) builder.fix(fx as TranscriptFix)
354
375
  if (Array.isArray(manifest.timeline)) for (const e of manifest.timeline) builder.decision(e)
355
376
  builder.setNudge(manifest.nudge?.text ?? null)
356
- if (!manifest.active) builder.finish()
377
+ if (!manifest.active) builder.finish(manifest.stoppedReason ?? undefined)
357
378
  return builder
358
379
  }
359
380
 
@@ -1,5 +1,5 @@
1
- import { copyFileSync, existsSync, readFileSync, rmSync } from 'node:fs'
2
- import { join } from 'node:path'
1
+ import { copyFileSync, existsSync, readdirSync, readFileSync, rmSync } from 'node:fs'
2
+ import { join, dirname, basename } from 'node:path'
3
3
  import { defineTool } from '@deepseek-ai/dsh-tools'
4
4
  import type { JsonValue } from '@deepseek-ai/dsh-util-values'
5
5
  import yaml from 'js-yaml'
@@ -16,6 +16,9 @@ const KNOWN_INTENTIONAL_KEY = 'known_intentional'
16
16
  /** Max entries per single `apply` call. */
17
17
  const MAX_ENTRIES = 500
18
18
 
19
+ /** How many timestamped config backups are retained (older ones are removed). */
20
+ export const MAX_TRIAGE_BACKUPS = 5
21
+
19
22
  /** Whole-file marker line (matches review.ts filterKnownIntentional semantics). */
20
23
  const WHOLE_FILE_LINE = 0
21
24
 
@@ -171,6 +174,33 @@ export function backupSuffix(now = new Date()): string {
171
174
  return now.toISOString().replace(/[:.]/g, '-')
172
175
  }
173
176
 
177
+ /**
178
+ * Bound the timestamped config backups: after a fresh one is written, delete
179
+ * every older `config.bak-*` file beyond the newest `keep`. Best-effort — a
180
+ * filesystem failure here must never fail the apply that just succeeded
181
+ * (backups are a safety net, not a requirement).
182
+ * @returns the absolute paths of the backups that were removed.
183
+ */
184
+ export function pruneOldConfigBackups(configPath: string, keep = MAX_TRIAGE_BACKUPS): string[] {
185
+ const removed: string[] = []
186
+ try {
187
+ const dir = dirname(configPath)
188
+ const prefix = `${basename(configPath)}.bak-`
189
+ const matches = readdirSync(dir)
190
+ .filter((f) => f.startsWith(prefix))
191
+ .sort()
192
+ // Keep the newest `keep`; remove everything older.
193
+ const doomed = matches.slice(0, Math.max(0, matches.length - keep))
194
+ for (const f of doomed) {
195
+ rmSync(join(dir, f), { force: true })
196
+ removed.push(join(dir, f))
197
+ }
198
+ } catch {
199
+ // Best-effort cleanup — never surface a cleanup failure.
200
+ }
201
+ return removed
202
+ }
203
+
174
204
  // ─── File I/O ───────────────────────────────────────────────────────────────
175
205
 
176
206
  /** Load the raw config object (empty when the file is missing). */
@@ -178,8 +208,9 @@ function readConfigFile(configPath: string): Record<string, unknown> {
178
208
  if (!existsSync(configPath)) return {}
179
209
  const content = readFileSync(configPath, 'utf-8')
180
210
  const parsed = yaml.load(content)
181
- if (!parsed || typeof parsed !== 'object') {
182
- // 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
183
214
  // treated as empty: writing over it would destroy user data. Callers
184
215
  // surface this as an error and refuse to write.
185
216
  throw new Error('existing iterate.config.yaml is not a valid YAML mapping')
@@ -248,6 +279,10 @@ function applyEntries(
248
279
  }
249
280
  }
250
281
 
282
+ // Success: bound the accumulation of timestamped backups so a long-lived
283
+ // project never collects an unbounded pile of config snapshots.
284
+ if (backupPath) pruneOldConfigBackups(configPath)
285
+
251
286
  return { ok: true, added, skipped, count: merged.length, configPath, backupPath }
252
287
  }
253
288