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
@@ -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.
@@ -24,13 +28,24 @@ import { join } from 'node:path';
24
28
  import { defineTool } from '@deepseek-ai/dsh-tools';
25
29
  import { resolveProjectRootForExec } from "../config-loader.js";
26
30
  import { writeTextAtomic, writeJsonAtomic } from "../atomic-fs.js";
27
- import { readDecisionEntries, appendDecisionEntry } from "./decision-log.js";
31
+ import { readDecisionEntries, appendDecisionEntry, acquireLogLock } from "./decision-log.js";
28
32
  import { readRegistry, recomputeRoundCounts } from "./fix.js";
33
+ import { readExperienceBank, writeExperienceBank } from "./experience-store.js";
34
+ import { readDefenseEvents, writeDefenseEvents } from "./defense-store.js";
29
35
  import { iterateDir, fixesDir, checkpointPath, fixRegistryPath } from "../paths.js";
30
36
  /** Default retention for decision-log entries (in days). */
31
37
  const DEFAULT_RETAIN_DAYS = 30;
32
38
  const MIN_RETAIN_DAYS = 1;
33
39
  const MAX_RETAIN_DAYS = 365;
40
+ /**
41
+ * Upper bound on experience-bank entries retained by a prune sweep. The bank
42
+ * accumulates curated knowledge across sessions and each entry is small, so a
43
+ * generous cap drops only the oldest tail of a pathologically large bank
44
+ * (never recent learning).
45
+ */
46
+ export const MAX_EXPERIENCE_ENTRIES = 2000;
47
+ /** How many compare-and-append retries bound the decision-log rewrite race. */
48
+ const MAX_LOG_REWRITE_RETRIES = 3;
34
49
  /** Clamp retainDays to a sane range. */
35
50
  export function clampRetainDays(days) {
36
51
  if (typeof days !== 'number' || !Number.isInteger(days) || days <= 0) {
@@ -66,6 +81,59 @@ export function isPrunableTemp(name) {
66
81
  // .<basename>.tmp-<pid>-<rand>: starts with a dot and contains ".tmp-" after it.
67
82
  return name.startsWith('.') && name.includes('.tmp-');
68
83
  }
84
+ /**
85
+ * Bound the experience bank to its newest `cap` entries (by timestamp; ties
86
+ * keep their original order via stable sort). Returns the trimmed bank plus
87
+ * how many entries would be dropped. Never mutates the input bank.
88
+ */
89
+ export function sweepExperienceBank(bank, cap) {
90
+ const size = Number.isFinite(cap) && cap >= 0 ? Math.floor(cap) : MAX_EXPERIENCE_ENTRIES;
91
+ const total = bank.entries.length;
92
+ if (total <= size)
93
+ return { bank, removed: 0 };
94
+ const kept = [...bank.entries]
95
+ .sort((a, b) => (b.timestamp ?? '').localeCompare(a.timestamp ?? ''))
96
+ .slice(0, size);
97
+ // Preserve the original (insertion) order of the surviving entries.
98
+ const keptIds = new Set(kept.map((e) => e.id));
99
+ const ordered = bank.entries.filter((e) => keptIds.has(e.id));
100
+ return {
101
+ bank: { ...bank, entries: ordered, lastUpdated: new Date().toISOString() },
102
+ removed: total - ordered.length,
103
+ };
104
+ }
105
+ /**
106
+ * Drop defense events older than `cutoff` from the stream using an ISO
107
+ * timestamp comparison, recomputing the counts so they can never drift from
108
+ * the surviving events. Never mutates the input stream.
109
+ */
110
+ export function sweepDefenseEvents(stream, cutoff) {
111
+ const kept = stream.events.filter((e) => e.timestamp >= cutoff);
112
+ if (kept.length === stream.events.length)
113
+ return { stream, removed: 0 };
114
+ return {
115
+ stream: {
116
+ events: kept,
117
+ lastUpdated: new Date().toISOString(),
118
+ counts: computeDefenseCounts(kept),
119
+ },
120
+ removed: stream.events.length - kept.length,
121
+ };
122
+ }
123
+ /** Recompute per-type defense counts from a list of events. */
124
+ function computeDefenseCounts(events) {
125
+ const counts = {
126
+ precondition_failed: 0,
127
+ rollback: 0,
128
+ invariant_violated: 0,
129
+ assumption_falsified: 0,
130
+ };
131
+ for (const e of events) {
132
+ if (e.type in counts)
133
+ counts[e.type]++;
134
+ }
135
+ return counts;
136
+ }
69
137
  /**
70
138
  * Inspect the runtime state and report what would be pruned.
71
139
  * Pure (no deletions). Returns a structured report.
@@ -124,6 +192,15 @@ export function inspectPrune(projectRoot, retainDays) {
124
192
  iterateEntries = [];
125
193
  }
126
194
  const staleTemps = iterateEntries.filter((f) => isPrunableTemp(f)).sort();
195
+ // 6. Experience-bank oversize (entries beyond the entry cap). The bank grows
196
+ // across sessions with no natural bound — a sweep reports the tail that a
197
+ // prune would drop (newest `MAX_EXPERIENCE_ENTRIES` preserved).
198
+ const bank = readExperienceBank(projectRoot);
199
+ const sweptBank = sweepExperienceBank(bank, MAX_EXPERIENCE_ENTRIES);
200
+ // 7. Defense events older than retainDays (a previous iteration's stream
201
+ // that was never cleared).
202
+ const defenseStream = readDefenseEvents(projectRoot);
203
+ const sweptDefense = sweepDefenseEvents(defenseStream, cutoff);
127
204
  return {
128
205
  oldLogEntries,
129
206
  hasCheckpoint,
@@ -132,12 +209,67 @@ export function inspectPrune(projectRoot, retainDays) {
132
209
  emptyRounds,
133
210
  totalLogEntries: entries.length,
134
211
  registryRounds: registry.rounds.length,
212
+ totalExperiences: bank.entries.length,
213
+ experienceOversize: sweptBank.removed,
214
+ totalDefenseEvents: defenseStream.events.length,
215
+ staleDefenseEvents: sweptDefense.removed,
135
216
  };
136
217
  }
137
218
  /**
138
219
  * Actually prune the runtime artifacts (only called when dryRun=false).
139
220
  * Returns a detailed report of what was deleted.
140
221
  */
222
+ /**
223
+ * Rewrite the decision log to the entries younger than `cutoff`.
224
+ *
225
+ * The read-before-rewrite is a TOCTOU hot spot: a concurrent worker can append
226
+ * a fresh audit line between our read and our atomic rewrite, and an atomic
227
+ * rename would silently discard it. So instead of a single read+write we use a
228
+ * bounded compare-and-append loop — after each rewrite we re-read the log and
229
+ * retry whenever the file grew (a concurrent append slipped in). The loop
230
+ * terminates when the file is stable or after `MAX_LOG_REWRITE_RETRIES`, and a
231
+ * failure here is surfaced as a structured error instead of truncating the log.
232
+ *
233
+ * @returns the number of entries removed (best-effort) or 0 on error.
234
+ */
235
+ function rewriteDecisionLogKeepingRecent(projectRoot, cutoff) {
236
+ const logPath = join(iterateDir(projectRoot), 'decision-log.jsonl');
237
+ // Take the cross-process log lock for the ENTIRE loop: with the mutex held,
238
+ // no concurrent appender can slip a fresh line into the read-before-rewrite
239
+ // window, so the first attempt is nearly always the only one. (The retry
240
+ // loop stays as defense-in-depth for the lock-unavailable path.)
241
+ const release = acquireLogLock(projectRoot);
242
+ try {
243
+ for (let attempt = 0; attempt < MAX_LOG_REWRITE_RETRIES; attempt++) {
244
+ try {
245
+ const entries = readDecisionEntries(projectRoot);
246
+ const kept = entries.filter((e) => e.timestamp >= cutoff);
247
+ const deleted = entries.length - kept.length;
248
+ if (deleted === 0)
249
+ return { deleted: 0 };
250
+ writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n');
251
+ // A concurrent appender (cross-process, lock-unavailable path) may have
252
+ // landed new lines after our read. If the log grew during the write
253
+ // window, re-read and prune again instead of accepting a lost audit trail.
254
+ const after = readDecisionEntries(projectRoot);
255
+ if (after.length <= kept.length)
256
+ return { deleted };
257
+ }
258
+ catch (err) {
259
+ return { deleted: 0, error: `failed to rewrite decision log: ${String(err)}` };
260
+ }
261
+ }
262
+ return { deleted: 0, error: `failed to rewrite decision log after ${MAX_LOG_REWRITE_RETRIES} attempts` };
263
+ }
264
+ finally {
265
+ release();
266
+ }
267
+ }
268
+ /**
269
+ * Execute the prune: apply every cleanable item reported by `inspectPrune`.
270
+ * Every step is individually hedged so a single disk failure degrades to a
271
+ * reported error rather than aborting the whole cleanup.
272
+ */
141
273
  export function executePrune(projectRoot, retainDays, report) {
142
274
  const cutoff = cutoffTimestamp(retainDays);
143
275
  const result = {
@@ -146,22 +278,19 @@ export function executePrune(projectRoot, retainDays, report) {
146
278
  deletedBackups: [],
147
279
  deletedTemps: [],
148
280
  trimmedEmptyRounds: 0,
281
+ deletedExperiences: 0,
282
+ deletedDefenseEvents: 0,
149
283
  errors: [],
150
284
  };
151
285
  // 1. Rewrite the decision log, keeping only recent entries. Atomic
152
- // (temp + rename) so a crash mid-write can never truncate the log.
153
- try {
154
- const entries = readDecisionEntries(projectRoot);
155
- const kept = entries.filter((e) => e.timestamp >= cutoff);
156
- result.deletedLogEntries = entries.length - kept.length;
157
- if (result.deletedLogEntries > 0) {
158
- const logPath = join(iterateDir(projectRoot), 'decision-log.jsonl');
159
- writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n');
160
- }
161
- }
162
- catch (err) {
163
- result.errors.push(`failed to rewrite decision log: ${String(err)}`);
164
- result.deletedLogEntries = 0;
286
+ // (temp + rename) so a crash mid-write can never truncate the log; the
287
+ // bounded compare-and-append loop guards against a concurrent appender
288
+ // slipping a fresh audit line into the read-before-rewrite window.
289
+ {
290
+ const { deleted, error } = rewriteDecisionLogKeepingRecent(projectRoot, cutoff);
291
+ result.deletedLogEntries = deleted;
292
+ if (error)
293
+ result.errors.push(error);
165
294
  }
166
295
  // 2. Remove checkpoint.
167
296
  if (report.hasCheckpoint) {
@@ -213,6 +342,34 @@ export function executePrune(projectRoot, retainDays, report) {
213
342
  result.errors.push(`failed to delete temp file ${tmp}: ${String(err)}`);
214
343
  }
215
344
  }
345
+ // 6. Trim the experience bank to its entry cap (newest wins).
346
+ if (report.experienceOversize > 0) {
347
+ try {
348
+ const bank = readExperienceBank(projectRoot);
349
+ const { bank: swept, removed } = sweepExperienceBank(bank, MAX_EXPERIENCE_ENTRIES);
350
+ if (removed > 0) {
351
+ writeExperienceBank(projectRoot, swept);
352
+ result.deletedExperiences = removed;
353
+ }
354
+ }
355
+ catch (err) {
356
+ result.errors.push(`failed to trim experience bank: ${String(err)}`);
357
+ }
358
+ }
359
+ // 7. Drop defense events older than retainDays (recounted on write).
360
+ if (report.staleDefenseEvents > 0) {
361
+ try {
362
+ const stream = readDefenseEvents(projectRoot);
363
+ const { stream: swept, removed } = sweepDefenseEvents(stream, cutoff);
364
+ if (removed > 0) {
365
+ writeDefenseEvents(projectRoot, swept);
366
+ result.deletedDefenseEvents = removed;
367
+ }
368
+ }
369
+ catch (err) {
370
+ result.errors.push(`failed to sweep defense events: ${String(err)}`);
371
+ }
372
+ }
216
373
  return result;
217
374
  }
218
375
  /**
@@ -226,7 +383,8 @@ export function registerPruneTool(ctx) {
226
383
  description: 'Inspect or clean up old iterate runtime artifacts (.iterate/). ' +
227
384
  'Defaults to dry-run (report-only, no deletion). Pass `dryRun: false` to actually prune. ' +
228
385
  'Manages: old decision-log entries, stale checkpoints, orphaned fix backups, empty fix rounds, ' +
229
- 'and stray temp files left by crashed atomic writes. ' +
386
+ 'stray temp files left by crashed atomic writes, experience-bank entries beyond the entry cap, ' +
387
+ 'and stale defense events. ' +
230
388
  'Each deletion is logged to the decision log.',
231
389
  parameters: {
232
390
  dryRun: {
@@ -268,6 +426,8 @@ export function registerPruneTool(ctx) {
268
426
  ` Stale backups to delete: ${report?.staleBackups?.length ?? 0}`,
269
427
  ` Stray temp files to delete: ${report?.staleTemps?.length ?? 0}`,
270
428
  ` Empty rounds to trim: ${report?.emptyRounds?.length ?? 0}`,
429
+ ` Experience entries to drop (over ${report?.totalExperiences ?? '?'} cap): ${report?.experienceOversize ?? 0} of ${report?.totalExperiences ?? '?'}`,
430
+ ` Stale defense events to remove: ${report?.staleDefenseEvents ?? 0} (of ${report?.totalDefenseEvents ?? '?'})`,
271
431
  '',
272
432
  'Pass dryRun:false to execute the prune.',
273
433
  ];
@@ -280,6 +440,8 @@ export function registerPruneTool(ctx) {
280
440
  ` Deleted ${result?.deletedBackups?.length ?? 0} stale backups.`,
281
441
  ` Deleted ${result?.deletedTemps?.length ?? 0} stray temp files.`,
282
442
  ` Trimmed ${result?.trimmedEmptyRounds ?? 0} empty rounds.`,
443
+ ` Trimmed ${result?.deletedExperiences ?? 0} experience entries (over the entry cap).`,
444
+ ` Removed ${result?.deletedDefenseEvents ?? 0} stale defense events.`,
283
445
  ];
284
446
  const errs = result?.errors ?? [];
285
447
  if (errs.length > 0) {
@@ -307,8 +469,10 @@ export function registerPruneTool(ctx) {
307
469
  };
308
470
  }
309
471
  const result = executePrune(projectRoot, retainDays, report);
310
- // Log the prune to the decision log.
311
- appendDecisionEntry(projectRoot, {
472
+ // Log the prune to the decision log. The mutations already happened, so
473
+ // a failed log append is reported via `errors` — a silent audit miss
474
+ // (F2) must never pretend every deletion was recorded.
475
+ const logRes = appendDecisionEntry(projectRoot, {
312
476
  timestamp: new Date().toISOString(),
313
477
  round: 0,
314
478
  type: 'decision',
@@ -319,8 +483,12 @@ export function registerPruneTool(ctx) {
319
483
  deletedCheckpoint: result.deletedCheckpoint,
320
484
  deletedBackups: result.deletedBackups.length,
321
485
  trimmedEmptyRounds: result.trimmedEmptyRounds,
486
+ deletedExperiences: result.deletedExperiences,
487
+ deletedDefenseEvents: result.deletedDefenseEvents,
322
488
  },
323
489
  });
490
+ if (logRes.error)
491
+ result.errors.push(logRes.error);
324
492
  return {
325
493
  ok: true,
326
494
  dryRun: false,
@@ -229,9 +229,23 @@ export function registerReviewTool(ctx) {
229
229
  const coverageEnabled = config.reviewer?.coverage_validation !== false;
230
230
  let coverage = null;
231
231
  if (coverageEnabled) {
232
- const assigned = collectScopeFiles(projectRoot, {
233
- scope: config.review?.scope === 'changed-only' ? 'changed-only' : 'full',
234
- });
232
+ // Build the assigned inventory the SAME way `plan` builds it: a
233
+ // changed-only scope uses the resolved git-diff file set, and
234
+ // falls back to the FULL walk when git is unavailable or nothing
235
+ // changed (mirrors buildReviewPlan's effective-scope decision), so
236
+ // the coverage ratio reflects the files reviewers were actually
237
+ // told to read — never a vacuous 1.0 on an empty inventory.
238
+ let assigned;
239
+ if (config.review?.scope === 'changed-only') {
240
+ const gitScope = await resolveChangedFiles(projectRoot, config.git?.target_branch ?? 'main');
241
+ assigned =
242
+ gitScope.changedFiles.length > 0
243
+ ? collectScopeFiles(projectRoot, { scope: 'changed-only', changedFiles: gitScope.changedFiles })
244
+ : collectScopeFiles(projectRoot, { scope: 'full' });
245
+ }
246
+ else {
247
+ assigned = collectScopeFiles(projectRoot, { scope: 'full' });
248
+ }
235
249
  const readFiles = Array.isArray(source.readFiles)
236
250
  ? source.readFiles
237
251
  : null;
@@ -148,6 +148,7 @@ export function registerTranscriptTool(ctx) {
148
148
  description: 'For `capture`: array of applied fixes [{id, file, round, summary, linesAdded, linesRemoved, success}].',
149
149
  },
150
150
  refReadFiles: { type: 'json', description: 'For `capture`: flat array of all read files across rounds (optional).' },
151
+ 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).' },
151
152
  text: { type: 'string', description: 'For `nudge`: steering text to set (or null to clear).' },
152
153
  path: { type: 'string', description: 'Project root directory (default: current working directory).' },
153
154
  },
@@ -218,14 +219,25 @@ export function registerTranscriptTool(ctx) {
218
219
  }
219
220
  // Rehydrating a parsed-but-malformed manifest (e.g. `rounds: [null]`
220
221
  // or a non-object) must never crash the nudge — degrade to a fresh
221
- // builder so the steering text still lands.
222
+ // builder so the steering text still lands. The fallback must keep
223
+ // the ORIGINAL run identity (mode/taskMode/goal/maxRounds) so a
224
+ // stray malformed field can never silently rewrite a dry-run run
225
+ // into a `normal` one.
226
+ const fallbackOpts = {
227
+ project: projectRoot,
228
+ mode: (manifest?.mode === 'dry-run' || manifest?.mode === 'normal' ? manifest.mode : null),
229
+ taskMode: (manifest?.taskMode === 'code' || manifest?.taskMode === 'iterate' ? manifest.taskMode : null),
230
+ approval,
231
+ goal: typeof manifest?.goal === 'string' ? manifest.goal : '',
232
+ maxRounds: typeof manifest?.maxRounds === 'number' ? manifest.maxRounds : 0,
233
+ };
222
234
  let builder;
223
235
  if (manifest) {
224
236
  try {
225
237
  builder = rehydrateBuilder(manifest, approval);
226
238
  }
227
239
  catch {
228
- builder = new ReviewTranscriptBuilder({ project: projectRoot, mode: 'normal', approval });
240
+ builder = new ReviewTranscriptBuilder(fallbackOpts);
229
241
  }
230
242
  }
231
243
  else {
@@ -292,10 +304,20 @@ export function registerTranscriptTool(ctx) {
292
304
  builder.fix(record);
293
305
  }
294
306
  }
295
- // Convergence "found nothing → settled" marker when the trend ends on 0.
307
+ // End state: an explicit stoppedReason wins; otherwise derive it from
308
+ // the trend. A run that settled closes as "converged"; a run that did
309
+ // real work but never trended to 0 closes as "max_rounds_reached";
310
+ // a capture with no rounds stays "active" (nothing recorded yet).
311
+ const explicitReason = typeof args.stoppedReason === 'string' && args.stoppedReason.trim()
312
+ ? args.stoppedReason.trim()
313
+ : '';
296
314
  const last = convergence[convergence.length - 1];
297
- if (convergence.length > 0 && last === 0)
298
- builder.finish();
315
+ if (explicitReason)
316
+ builder.finish(explicitReason);
317
+ else if (convergence.length > 0 && last === 0)
318
+ builder.finish('converged');
319
+ else if (roundsExecuted > 0)
320
+ builder.finish('max_rounds_reached');
299
321
  const persisted = await persistChecked(file, builder.serialize());
300
322
  if (!persisted.ok) {
301
323
  return { operation: 'capture', found: true, updated: false, error: persisted.error };
@@ -346,7 +368,7 @@ function rehydrateBuilder(manifest, approval) {
346
368
  builder.decision(e);
347
369
  builder.setNudge(manifest.nudge?.text ?? null);
348
370
  if (!manifest.active)
349
- builder.finish();
371
+ builder.finish(manifest.stoppedReason ?? undefined);
350
372
  return builder;
351
373
  }
352
374
  /** Atomically persist a manifest (unique temp + rename) under `.iterate/`. */
@@ -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 yaml from 'js-yaml';
5
5
  import { resolveProjectRootForExec } from "../config-loader.js";
@@ -10,6 +10,8 @@ const PERSONALIZATION_KEY = 'personalization';
10
10
  const KNOWN_INTENTIONAL_KEY = 'known_intentional';
11
11
  /** Max entries per single `apply` call. */
12
12
  const MAX_ENTRIES = 500;
13
+ /** How many timestamped config backups are retained (older ones are removed). */
14
+ export const MAX_TRIAGE_BACKUPS = 5;
13
15
  /** Whole-file marker line (matches review.ts filterKnownIntentional semantics). */
14
16
  const WHOLE_FILE_LINE = 0;
15
17
  // ─── Pure helpers (exported for unit tests) ─────────────────────────────────
@@ -148,6 +150,33 @@ export function readKnownIntentional(config) {
148
150
  export function backupSuffix(now = new Date()) {
149
151
  return now.toISOString().replace(/[:.]/g, '-');
150
152
  }
153
+ /**
154
+ * Bound the timestamped config backups: after a fresh one is written, delete
155
+ * every older `config.bak-*` file beyond the newest `keep`. Best-effort — a
156
+ * filesystem failure here must never fail the apply that just succeeded
157
+ * (backups are a safety net, not a requirement).
158
+ * @returns the absolute paths of the backups that were removed.
159
+ */
160
+ export function pruneOldConfigBackups(configPath, keep = MAX_TRIAGE_BACKUPS) {
161
+ const removed = [];
162
+ try {
163
+ const dir = dirname(configPath);
164
+ const prefix = `${basename(configPath)}.bak-`;
165
+ const matches = readdirSync(dir)
166
+ .filter((f) => f.startsWith(prefix))
167
+ .sort();
168
+ // Keep the newest `keep`; remove everything older.
169
+ const doomed = matches.slice(0, Math.max(0, matches.length - keep));
170
+ for (const f of doomed) {
171
+ rmSync(join(dir, f), { force: true });
172
+ removed.push(join(dir, f));
173
+ }
174
+ }
175
+ catch {
176
+ // Best-effort cleanup — never surface a cleanup failure.
177
+ }
178
+ return removed;
179
+ }
151
180
  // ─── File I/O ───────────────────────────────────────────────────────────────
152
181
  /** Load the raw config object (empty when the file is missing). */
153
182
  function readConfigFile(configPath) {
@@ -155,8 +184,9 @@ function readConfigFile(configPath) {
155
184
  return {};
156
185
  const content = readFileSync(configPath, 'utf-8');
157
186
  const parsed = yaml.load(content);
158
- if (!parsed || typeof parsed !== 'object') {
159
- // A config that exists but is not a YAML mapping must NOT be silently
187
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
188
+ // A config that exists but is NOT a YAML mapping (including a YAML list,
189
+ // which `typeof [] === 'object'` alone would accept) must NOT be silently
160
190
  // treated as empty: writing over it would destroy user data. Callers
161
191
  // surface this as an error and refuse to write.
162
192
  throw new Error('existing iterate.config.yaml is not a valid YAML mapping');
@@ -213,6 +243,10 @@ function applyEntries(projectRoot, incoming) {
213
243
  error: `Failed to write config: ${String(err)}${rollbackError}`,
214
244
  };
215
245
  }
246
+ // Success: bound the accumulation of timestamped backups so a long-lived
247
+ // project never collects an unbounded pile of config snapshots.
248
+ if (backupPath)
249
+ pruneOldConfigBackups(configPath);
216
250
  return { ok: true, added, skipped, count: merged.length, configPath, backupPath };
217
251
  }
218
252
  /**
@@ -54,6 +54,20 @@ function clampStringList(source, cap) {
54
54
  }
55
55
  return out;
56
56
  }
57
+ /**
58
+ * Deep-clone a decision-timeline payload so it is decoupled from the caller's
59
+ * live object. structuredClone is available in every supported Node release;
60
+ * on the off chance it is missing, fall back to a JSON round-trip (decision
61
+ * payloads are JSON-serializable by construction).
62
+ */
63
+ function cloneDecisionData(data) {
64
+ try {
65
+ return structuredClone(data);
66
+ }
67
+ catch {
68
+ return JSON.parse(JSON.stringify(data));
69
+ }
70
+ }
57
71
  /** Normalize a single finding, dropping malformed entries. */
58
72
  function normalizeFinding(input) {
59
73
  if (!input || typeof input !== 'object')
@@ -81,12 +95,23 @@ function normalizeFinding(input) {
81
95
  acknowledged: typeof f.acknowledged === 'boolean' ? f.acknowledged : undefined,
82
96
  };
83
97
  }
98
+ /**
99
+ * Append one raw finding to a thread's list, bound to MAX_FINDINGS_PER_THREAD
100
+ * with NEWEST-WINS eviction (drop the oldest findings once the cap is hit).
101
+ * A single oversized fixer agent could otherwise OOM the client with an
102
+ * unbounded finding dump inside one thread.
103
+ */
104
+ function pushThreadFinding(thread, raw) {
105
+ thread.findings.push(raw);
106
+ if (thread.findings.length > MAX_FINDINGS_PER_THREAD) {
107
+ thread.findings.splice(0, thread.findings.length - MAX_FINDINGS_PER_THREAD);
108
+ }
109
+ }
84
110
  /** Merge a report snapshot's findings/readFiles into a thread by dimension. */
85
111
  function mergeReportIntoThread(thread, findings, readFiles) {
86
112
  for (const raw of findings) {
87
- const f = normalizeFinding(raw);
88
- if (f)
89
- thread.findings.push(raw);
113
+ if (normalizeFinding(raw))
114
+ pushThreadFinding(thread, raw);
90
115
  }
91
116
  for (const r of readFiles ?? []) {
92
117
  if (typeof r === 'string')
@@ -107,9 +132,11 @@ export class ReviewTranscriptBuilder {
107
132
  round = 0;
108
133
  maxRounds = 0;
109
134
  active = true;
135
+ stoppedReason = null;
110
136
  rounds = [];
111
137
  convergence = [];
112
138
  globalFindings = [];
139
+ globalSeenKeys = new Set();
113
140
  fixes = [];
114
141
  checkpoint = null;
115
142
  timeline = [];
@@ -158,9 +185,15 @@ export class ReviewTranscriptBuilder {
158
185
  this.phases.push(n);
159
186
  this.touch();
160
187
  }
161
- /** End the run (stops the "active" pulsing in the UI). */
162
- finish() {
188
+ /**
189
+ * End the run (stops the "active" pulsing in the UI). `reason` records WHY
190
+ * it ended, so a run stopped by max-rounds or validation is never mistaken
191
+ * for a live run NOR for a clean convergence.
192
+ */
193
+ finish(reason) {
163
194
  this.active = false;
195
+ if (typeof reason === 'string' && reason.trim())
196
+ this.stoppedReason = reason.trim();
164
197
  this.touch();
165
198
  }
166
199
  /** Open a review round, capturing the current round index. */
@@ -230,12 +263,11 @@ export class ReviewTranscriptBuilder {
230
263
  for (const raw of findings) {
231
264
  const f = normalizeFinding(raw);
232
265
  if (f) {
233
- thread.findings.push(raw);
234
- this.globalFindings.push(f);
266
+ pushThreadFinding(thread, raw);
267
+ this.addGlobal(f);
235
268
  }
236
269
  }
237
270
  }
238
- this.reevaluateGlobal();
239
271
  this.touch();
240
272
  }
241
273
  /** Merge a round-level report snapshot (findings + readFiles) into a thread. */
@@ -248,9 +280,8 @@ export class ReviewTranscriptBuilder {
248
280
  for (const raw of findings) {
249
281
  const f = normalizeFinding(raw);
250
282
  if (f)
251
- this.globalFindings.push(f);
283
+ this.addGlobal(f);
252
284
  }
253
- this.reevaluateGlobal();
254
285
  this.touch();
255
286
  }
256
287
  // ─── Convergence (F2) ───────────────────────────────────────────────────
@@ -330,8 +361,12 @@ export class ReviewTranscriptBuilder {
330
361
  ? Math.floor(entry.round)
331
362
  : this.round,
332
363
  type,
333
- data: entry.data && typeof entry.data === 'object'
334
- ? entry.data
364
+ data: entry.data && typeof entry.data === 'object' && !Array.isArray(entry.data)
365
+ // Deep clone so a caller mutating its own object later can never
366
+ // alias into the timeline (serialize would then emit the mutated
367
+ // values instead of what was actually decided). Decision-log payloads
368
+ // are small JSON, so the clone cost is negligible.
369
+ ? cloneDecisionData(entry.data)
335
370
  : {},
336
371
  });
337
372
  if (this.timeline.length > MAX_TIMELINE) {
@@ -378,13 +413,14 @@ export class ReviewTranscriptBuilder {
378
413
  maxRounds: this.maxRounds,
379
414
  rounds,
380
415
  convergence: this.convergence,
381
- findings: this.globalFindings.length > MAX_FINDINGS_TOTAL
382
- ? this.globalFindings.slice(0, MAX_FINDINGS_TOTAL)
383
- : this.globalFindings,
416
+ // addGlobal maintains the newest-wins cap incrementally, so the array is
417
+ // already ≤ MAX_FINDINGS_TOTAL — no post-hoc slice needed.
418
+ findings: this.globalFindings,
384
419
  fixes: this.fixes,
385
420
  checkpoint: this.checkpoint,
386
421
  timeline: this.timeline,
387
422
  nudge: this.nudge,
423
+ stoppedReason: this.stoppedReason,
388
424
  approval: {
389
425
  active: this.approval !== 'allow',
390
426
  policy: this.approval,
@@ -408,25 +444,23 @@ export class ReviewTranscriptBuilder {
408
444
  threadCount(live) {
409
445
  return live.threads.length;
410
446
  }
411
- /** Recompute the global finding list from per-thread findings (dedup). */
412
- reevaluateGlobal() {
413
- // Rebuild from threads to derive a deterministic global list.
414
- this.globalFindings.length = 0;
415
- const seen = new Set();
416
- for (const r of this.rounds) {
417
- for (const t of r.threads) {
418
- for (const raw of t.findings) {
419
- const f = normalizeFinding(raw);
420
- if (!f)
421
- continue;
422
- const key = `${f.file}\u0000${f.line ?? 0}\u0000${f.dimension}\u0000${f.summary}`;
423
- if (seen.has(key))
424
- continue;
425
- seen.add(key);
426
- this.globalFindings.push(f);
427
- if (this.globalFindings.length >= MAX_FINDINGS_TOTAL)
428
- return;
429
- }
447
+ /**
448
+ * Insert one deduplicated finding into the compact global list.
449
+ * NEWEST-WINS: when the global cap (MAX_FINDINGS_TOTAL) is hit, the oldest
450
+ * kept finding is evicted so fresh, still-relevant findings survive — this
451
+ * is the documented contract. Amortized O(1): dedupe via a seen-key set and
452
+ * eviction is one `shift` per overflow insert.
453
+ */
454
+ addGlobal(f) {
455
+ const key = `${f.file}\u0000${f.line ?? 0}\u0000${f.dimension}\u0000${f.summary}`;
456
+ if (this.globalSeenKeys.has(key))
457
+ return;
458
+ this.globalSeenKeys.add(key);
459
+ this.globalFindings.push(f);
460
+ if (this.globalFindings.length > MAX_FINDINGS_TOTAL) {
461
+ const evicted = this.globalFindings.shift();
462
+ if (evicted) {
463
+ this.globalSeenKeys.delete(`${evicted.file}\u0000${evicted.line ?? 0}\u0000${evicted.dimension}\u0000${evicted.summary}`);
430
464
  }
431
465
  }
432
466
  }