iterate-plugin 3.5.2 → 3.5.4

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 (43) hide show
  1. package/README.md +3 -3
  2. package/README.zh-CN.md +3 -3
  3. package/dist/session-hooks.js +48 -4
  4. package/dist/skill-prompt.js +63 -29
  5. package/dist/tools/checkpoint.js +39 -5
  6. package/dist/tools/config.js +11 -2
  7. package/dist/tools/context.js +5 -1
  8. package/dist/tools/defense-events.js +48 -9
  9. package/dist/tools/defense-store.js +63 -5
  10. package/dist/tools/experience-bank.js +14 -11
  11. package/dist/tools/experience-store.js +79 -14
  12. package/dist/tools/fix.js +30 -10
  13. package/dist/tools/history.js +5 -3
  14. package/dist/tools/prune.js +197 -29
  15. package/dist/tools/quality-gate.js +3 -0
  16. package/dist/tools/quality-store.js +8 -2
  17. package/dist/tools/review.js +17 -3
  18. package/dist/tools/transcript.js +72 -15
  19. package/dist/tools/triage.js +35 -2
  20. package/dist/transcript.js +30 -7
  21. package/lib/client.js +89 -21
  22. package/lib/parse.js +27 -2
  23. package/package.json +9 -7
  24. package/src/client/index.ts +53 -8
  25. package/src/session-hooks.ts +50 -4
  26. package/src/skill-prompt.ts +63 -29
  27. package/src/tools/checkpoint.ts +45 -6
  28. package/src/tools/config.ts +11 -2
  29. package/src/tools/context.ts +5 -1
  30. package/src/tools/defense-events.ts +50 -9
  31. package/src/tools/defense-store.ts +64 -8
  32. package/src/tools/experience-bank.ts +14 -11
  33. package/src/tools/experience-store.ts +88 -23
  34. package/src/tools/fix.ts +32 -12
  35. package/src/tools/history.ts +5 -3
  36. package/src/tools/prune.ts +204 -28
  37. package/src/tools/quality-gate.ts +4 -0
  38. package/src/tools/quality-store.ts +7 -2
  39. package/src/tools/review.ts +16 -3
  40. package/src/tools/transcript.ts +71 -14
  41. package/src/tools/triage.ts +36 -2
  42. package/src/transcript.ts +32 -7
  43. 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.
@@ -26,11 +30,22 @@ import { resolveProjectRootForExec } from "../config-loader.js";
26
30
  import { writeTextAtomic, writeJsonAtomic } from "../atomic-fs.js";
27
31
  import { readDecisionEntries, appendDecisionEntry } 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.
@@ -87,17 +155,25 @@ export function inspectPrune(projectRoot, retainDays) {
87
155
  }
88
156
  const staleBackups = [];
89
157
  const fixDir = fixesDir(projectRoot);
90
- if (existsSync(fixDir)) {
91
- for (const entry of readdirSync(fixDir)) {
92
- if (!entry.endsWith('.bak'))
93
- continue;
94
- // Extract the fix-id prefix (up to the first underscore after the id).
95
- // e.g. "fix-abc123_2026-08-17T00-00-00-000Z.bak" → "fix-abc123"
96
- const match = entry.match(/^(fix-[a-z0-9]+)_/);
97
- const id = match?.[1];
98
- if (id && !activeIds.has(id)) {
99
- staleBackups.push(entry);
100
- }
158
+ // existsSync does not prove readability — a permissions failure must degrade
159
+ // to "nothing listable" (report skipped) instead of throwing out of execute.
160
+ let fixEntries = [];
161
+ try {
162
+ if (existsSync(fixDir))
163
+ fixEntries = readdirSync(fixDir);
164
+ }
165
+ catch {
166
+ fixEntries = [];
167
+ }
168
+ for (const entry of fixEntries) {
169
+ if (!entry.endsWith('.bak'))
170
+ continue;
171
+ // Extract the fix-id prefix (up to the first underscore after the id).
172
+ // e.g. "fix-abc123_2026-08-17T00-00-00-000Z.bak" → "fix-abc123"
173
+ const match = entry.match(/^(fix-[a-z0-9]+)_/);
174
+ const id = match?.[1];
175
+ if (id && !activeIds.has(id)) {
176
+ staleBackups.push(entry);
101
177
  }
102
178
  }
103
179
  // 4. Empty rounds (rounds with 0 records).
@@ -106,10 +182,25 @@ export function inspectPrune(projectRoot, retainDays) {
106
182
  .map((r) => r.round);
107
183
  // 5. Stray atomic-write temp files left behind by a crashed writer. The
108
184
  // unique prefix makes them recognizable and safe to delete — no live writer
109
- // ever reads another writer's temp file.
110
- const staleTemps = existsSync(iterateDir(projectRoot))
111
- ? readdirSync(iterateDir(projectRoot)).filter((f) => isPrunableTemp(f)).sort()
112
- : [];
185
+ // ever reads another writer's temp file. A permissions failure listing the
186
+ // dir degrades to "none found" (report-only) rather than throwing.
187
+ let iterateEntries = [];
188
+ try {
189
+ iterateEntries = existsSync(iterateDir(projectRoot)) ? readdirSync(iterateDir(projectRoot)) : [];
190
+ }
191
+ catch {
192
+ iterateEntries = [];
193
+ }
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);
113
204
  return {
114
205
  oldLogEntries,
115
206
  hasCheckpoint,
@@ -118,12 +209,57 @@ export function inspectPrune(projectRoot, retainDays) {
118
209
  emptyRounds,
119
210
  totalLogEntries: entries.length,
120
211
  registryRounds: registry.rounds.length,
212
+ totalExperiences: bank.entries.length,
213
+ experienceOversize: sweptBank.removed,
214
+ totalDefenseEvents: defenseStream.events.length,
215
+ staleDefenseEvents: sweptDefense.removed,
121
216
  };
122
217
  }
123
218
  /**
124
219
  * Actually prune the runtime artifacts (only called when dryRun=false).
125
220
  * Returns a detailed report of what was deleted.
126
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
+ for (let attempt = 0; attempt < MAX_LOG_REWRITE_RETRIES; attempt++) {
238
+ try {
239
+ const entries = readDecisionEntries(projectRoot);
240
+ const kept = entries.filter((e) => e.timestamp >= cutoff);
241
+ const deleted = entries.length - kept.length;
242
+ if (deleted === 0)
243
+ return { deleted: 0 };
244
+ writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n');
245
+ // A concurrent appender may have landed new lines after our read. If the
246
+ // log grew during the write window, re-read and prune again instead of
247
+ // accepting a lost audit trail.
248
+ const after = readDecisionEntries(projectRoot);
249
+ if (after.length <= kept.length)
250
+ return { deleted };
251
+ }
252
+ catch (err) {
253
+ return { deleted: 0, error: `failed to rewrite decision log: ${String(err)}` };
254
+ }
255
+ }
256
+ return { deleted: 0, error: `failed to rewrite decision log after ${MAX_LOG_REWRITE_RETRIES} attempts` };
257
+ }
258
+ /**
259
+ * Execute the prune: apply every cleanable item reported by `inspectPrune`.
260
+ * Every step is individually hedged so a single disk failure degrades to a
261
+ * reported error rather than aborting the whole cleanup.
262
+ */
127
263
  export function executePrune(projectRoot, retainDays, report) {
128
264
  const cutoff = cutoffTimestamp(retainDays);
129
265
  const result = {
@@ -132,22 +268,19 @@ export function executePrune(projectRoot, retainDays, report) {
132
268
  deletedBackups: [],
133
269
  deletedTemps: [],
134
270
  trimmedEmptyRounds: 0,
271
+ deletedExperiences: 0,
272
+ deletedDefenseEvents: 0,
135
273
  errors: [],
136
274
  };
137
275
  // 1. Rewrite the decision log, keeping only recent entries. Atomic
138
- // (temp + rename) so a crash mid-write can never truncate the log.
139
- try {
140
- const entries = readDecisionEntries(projectRoot);
141
- const kept = entries.filter((e) => e.timestamp >= cutoff);
142
- result.deletedLogEntries = entries.length - kept.length;
143
- if (result.deletedLogEntries > 0) {
144
- const logPath = join(iterateDir(projectRoot), 'decision-log.jsonl');
145
- writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n');
146
- }
147
- }
148
- catch (err) {
149
- result.errors.push(`failed to rewrite decision log: ${String(err)}`);
150
- result.deletedLogEntries = 0;
276
+ // (temp + rename) so a crash mid-write can never truncate the log; the
277
+ // bounded compare-and-append loop guards against a concurrent appender
278
+ // slipping a fresh audit line into the read-before-rewrite window.
279
+ {
280
+ const { deleted, error } = rewriteDecisionLogKeepingRecent(projectRoot, cutoff);
281
+ result.deletedLogEntries = deleted;
282
+ if (error)
283
+ result.errors.push(error);
151
284
  }
152
285
  // 2. Remove checkpoint.
153
286
  if (report.hasCheckpoint) {
@@ -199,6 +332,34 @@ export function executePrune(projectRoot, retainDays, report) {
199
332
  result.errors.push(`failed to delete temp file ${tmp}: ${String(err)}`);
200
333
  }
201
334
  }
335
+ // 6. Trim the experience bank to its entry cap (newest wins).
336
+ if (report.experienceOversize > 0) {
337
+ try {
338
+ const bank = readExperienceBank(projectRoot);
339
+ const { bank: swept, removed } = sweepExperienceBank(bank, MAX_EXPERIENCE_ENTRIES);
340
+ if (removed > 0) {
341
+ writeExperienceBank(projectRoot, swept);
342
+ result.deletedExperiences = removed;
343
+ }
344
+ }
345
+ catch (err) {
346
+ result.errors.push(`failed to trim experience bank: ${String(err)}`);
347
+ }
348
+ }
349
+ // 7. Drop defense events older than retainDays (recounted on write).
350
+ if (report.staleDefenseEvents > 0) {
351
+ try {
352
+ const stream = readDefenseEvents(projectRoot);
353
+ const { stream: swept, removed } = sweepDefenseEvents(stream, cutoff);
354
+ if (removed > 0) {
355
+ writeDefenseEvents(projectRoot, swept);
356
+ result.deletedDefenseEvents = removed;
357
+ }
358
+ }
359
+ catch (err) {
360
+ result.errors.push(`failed to sweep defense events: ${String(err)}`);
361
+ }
362
+ }
202
363
  return result;
203
364
  }
204
365
  /**
@@ -212,7 +373,8 @@ export function registerPruneTool(ctx) {
212
373
  description: 'Inspect or clean up old iterate runtime artifacts (.iterate/). ' +
213
374
  'Defaults to dry-run (report-only, no deletion). Pass `dryRun: false` to actually prune. ' +
214
375
  'Manages: old decision-log entries, stale checkpoints, orphaned fix backups, empty fix rounds, ' +
215
- 'and stray temp files left by crashed atomic writes. ' +
376
+ 'stray temp files left by crashed atomic writes, experience-bank entries beyond the entry cap, ' +
377
+ 'and stale defense events. ' +
216
378
  'Each deletion is logged to the decision log.',
217
379
  parameters: {
218
380
  dryRun: {
@@ -254,6 +416,8 @@ export function registerPruneTool(ctx) {
254
416
  ` Stale backups to delete: ${report?.staleBackups?.length ?? 0}`,
255
417
  ` Stray temp files to delete: ${report?.staleTemps?.length ?? 0}`,
256
418
  ` Empty rounds to trim: ${report?.emptyRounds?.length ?? 0}`,
419
+ ` Experience entries to drop (over ${report?.totalExperiences ?? '?'} cap): ${report?.experienceOversize ?? 0} of ${report?.totalExperiences ?? '?'}`,
420
+ ` Stale defense events to remove: ${report?.staleDefenseEvents ?? 0} (of ${report?.totalDefenseEvents ?? '?'})`,
257
421
  '',
258
422
  'Pass dryRun:false to execute the prune.',
259
423
  ];
@@ -266,6 +430,8 @@ export function registerPruneTool(ctx) {
266
430
  ` Deleted ${result?.deletedBackups?.length ?? 0} stale backups.`,
267
431
  ` Deleted ${result?.deletedTemps?.length ?? 0} stray temp files.`,
268
432
  ` Trimmed ${result?.trimmedEmptyRounds ?? 0} empty rounds.`,
433
+ ` Trimmed ${result?.deletedExperiences ?? 0} experience entries (over the entry cap).`,
434
+ ` Removed ${result?.deletedDefenseEvents ?? 0} stale defense events.`,
269
435
  ];
270
436
  const errs = result?.errors ?? [];
271
437
  if (errs.length > 0) {
@@ -305,6 +471,8 @@ export function registerPruneTool(ctx) {
305
471
  deletedCheckpoint: result.deletedCheckpoint,
306
472
  deletedBackups: result.deletedBackups.length,
307
473
  trimmedEmptyRounds: result.trimmedEmptyRounds,
474
+ deletedExperiences: result.deletedExperiences,
475
+ deletedDefenseEvents: result.deletedDefenseEvents,
308
476
  },
309
477
  });
310
478
  return {
@@ -9,6 +9,8 @@
9
9
  import { defineTool } from '@deepseek-ai/dsh-tools';
10
10
  import { resolveProjectRootForExec } from "../config-loader.js";
11
11
  import { readQualityGate, writeQualityGate, computeQualityGate, clearQualityGate } from "./quality-store.js";
12
+ /** Valid severity values (kept in sync with QualityGateSnapshot counting). */
13
+ const VALID_SEVERITIES = new Set(['critical', 'high', 'medium', 'low']);
12
14
  /** Validate a single finding object; returns true when well-formed. */
13
15
  function isValidFinding(raw) {
14
16
  if (!raw || typeof raw !== 'object')
@@ -16,6 +18,7 @@ function isValidFinding(raw) {
16
18
  const f = raw;
17
19
  return (typeof f.dimension === 'string' &&
18
20
  typeof f.severity === 'string' &&
21
+ VALID_SEVERITIES.has(f.severity) &&
19
22
  typeof f.file === 'string' &&
20
23
  (f.line === undefined || typeof f.line === 'number'));
21
24
  }
@@ -200,13 +200,19 @@ export function computeQualityGate(opts) {
200
200
  const overallScore = dimensionGates.length > 0
201
201
  ? Math.round(dimensionGates.reduce((sum, d) => sum + d.score, 0) / dimensionGates.length)
202
202
  : 0;
203
- // Determine overall status
203
+ // Determine overall status. An empty review (no dimensions, no findings, no
204
+ // validation results) is NOT a failure — there is simply nothing to gate —
205
+ // so it reports `pending` (not-applicable) instead of a fabricated FAIL.
204
206
  const hasCritical = criticalCount > 0;
205
207
  const hasHighFail = dimensionGates.some((d) => d.status === 'fail');
206
208
  const verificationFails = totalChecks > 0 && failedChecks > 0;
209
+ const noData = dimensionGates.length === 0 && totalFindings === 0 && totalChecks === 0;
207
210
  let overallStatus = 'pass';
208
211
  let failReason;
209
- if (hasCritical) {
212
+ if (noData) {
213
+ overallStatus = 'pending';
214
+ }
215
+ else if (hasCritical) {
210
216
  overallStatus = 'fail';
211
217
  failReason = `${criticalCount} critical findings present`;
212
218
  }
@@ -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;
@@ -31,7 +31,10 @@ function captureRound(builder, round) {
31
31
  if (!round || typeof round !== 'object')
32
32
  return;
33
33
  const r = round;
34
- const roundNo = typeof r.round === 'number' ? Math.floor(r.round) : 0;
34
+ const roundNo = typeof r.round === 'number' && Number.isFinite(r.round) ? Math.floor(r.round) : 0;
35
+ // NaN round values (`Math.floor(NaN) === NaN`, and `NaN <= 0` is false) would
36
+ // fall through the guard below; a finite check rejects them outright so a
37
+ // malformed round can never collapse into round 1 via the builder's coercion.
35
38
  if (roundNo <= 0)
36
39
  return;
37
40
  builder.roundStart(roundNo);
@@ -61,15 +64,17 @@ function normalizeCheckpoint(input) {
61
64
  if (!input || typeof input !== 'object')
62
65
  return null;
63
66
  const c = input;
64
- const round = typeof c.round === 'number' ? c.round : 0;
67
+ // Number.isFinite: a NaN round must not pass `round <= 0` (which is false for
68
+ // NaN) and later coerce into a phantom round 1 row.
69
+ const round = typeof c.round === 'number' && Number.isFinite(c.round) ? c.round : 0;
65
70
  if (round <= 0)
66
71
  return null;
67
72
  return {
68
73
  mode: c.mode === 'dry-run' || c.mode === 'normal' ? c.mode : 'normal',
69
74
  round,
70
- maxRounds: typeof c.maxRounds === 'number' ? c.maxRounds : 0,
71
- fixedCount: typeof c.fixedCount === 'number' ? c.fixedCount : 0,
72
- resumeCount: typeof c.resumeCount === 'number' ? c.resumeCount : 0,
75
+ maxRounds: typeof c.maxRounds === 'number' && Number.isFinite(c.maxRounds) ? c.maxRounds : 0,
76
+ fixedCount: typeof c.fixedCount === 'number' && Number.isFinite(c.fixedCount) ? c.fixedCount : 0,
77
+ resumeCount: typeof c.resumeCount === 'number' && Number.isFinite(c.resumeCount) ? c.resumeCount : 0,
73
78
  updatedAt: typeof c.updatedAt === 'string' ? c.updatedAt : new Date().toISOString(),
74
79
  };
75
80
  }
@@ -85,7 +90,7 @@ function normalizeFix(input) {
85
90
  return {
86
91
  id,
87
92
  timestamp: typeof f.timestamp === 'string' ? f.timestamp : new Date().toISOString(),
88
- round: typeof f.round === 'number' ? f.round : 0,
93
+ round: typeof f.round === 'number' && Number.isFinite(f.round) ? f.round : 0,
89
94
  file,
90
95
  summary: typeof f.summary === 'string' ? f.summary : '',
91
96
  linesAdded: typeof f.linesAdded === 'number' ? f.linesAdded : 0,
@@ -143,6 +148,7 @@ export function registerTranscriptTool(ctx) {
143
148
  description: 'For `capture`: array of applied fixes [{id, file, round, summary, linesAdded, linesRemoved, success}].',
144
149
  },
145
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).' },
146
152
  text: { type: 'string', description: 'For `nudge`: steering text to set (or null to clear).' },
147
153
  path: { type: 'string', description: 'Project root directory (default: current working directory).' },
148
154
  },
@@ -211,11 +217,37 @@ export function registerTranscriptTool(ctx) {
211
217
  manifest = null;
212
218
  }
213
219
  }
214
- const builder = manifest
215
- ? rehydrateBuilder(manifest, approval)
216
- : new ReviewTranscriptBuilder({ project: projectRoot, mode: 'normal', approval });
220
+ // Rehydrating a parsed-but-malformed manifest (e.g. `rounds: [null]`
221
+ // or a non-object) must never crash the nudge — degrade to a fresh
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
+ };
234
+ let builder;
235
+ if (manifest) {
236
+ try {
237
+ builder = rehydrateBuilder(manifest, approval);
238
+ }
239
+ catch {
240
+ builder = new ReviewTranscriptBuilder(fallbackOpts);
241
+ }
242
+ }
243
+ else {
244
+ builder = new ReviewTranscriptBuilder({ project: projectRoot, mode: 'normal', approval });
245
+ }
217
246
  builder.setNudge(typeof args.text === 'string' && args.text.trim() ? args.text : null);
218
- await persist(file, builder.serialize());
247
+ const persisted = await persistChecked(file, builder.serialize());
248
+ if (!persisted.ok) {
249
+ return { operation: 'nudge', updated: false, error: persisted.error };
250
+ }
219
251
  return {
220
252
  operation: 'nudge',
221
253
  updated: true,
@@ -272,11 +304,24 @@ export function registerTranscriptTool(ctx) {
272
304
  builder.fix(record);
273
305
  }
274
306
  }
275
- // 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
+ : '';
276
314
  const last = convergence[convergence.length - 1];
277
- if (convergence.length > 0 && last === 0)
278
- builder.finish();
279
- await persist(file, builder.serialize());
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');
321
+ const persisted = await persistChecked(file, builder.serialize());
322
+ if (!persisted.ok) {
323
+ return { operation: 'capture', found: true, updated: false, error: persisted.error };
324
+ }
280
325
  const live = await readLive(projectRoot);
281
326
  return {
282
327
  operation: 'capture',
@@ -323,7 +368,7 @@ function rehydrateBuilder(manifest, approval) {
323
368
  builder.decision(e);
324
369
  builder.setNudge(manifest.nudge?.text ?? null);
325
370
  if (!manifest.active)
326
- builder.finish();
371
+ builder.finish(manifest.stoppedReason ?? undefined);
327
372
  return builder;
328
373
  }
329
374
  /** Atomically persist a manifest (unique temp + rename) under `.iterate/`. */
@@ -331,6 +376,18 @@ async function persist(file, manifest) {
331
376
  await mkdir(dirname(file), { recursive: true });
332
377
  await writeTextAtomicAsync(file, JSON.stringify(manifest, null, 2));
333
378
  }
379
+ /** persist wrapped with a structured error channel so disk failures surface
380
+ * as a structured tool result instead of rejecting the whole execute (matches
381
+ * every other write path in this codebase). */
382
+ async function persistChecked(file, manifest) {
383
+ try {
384
+ await persist(file, manifest);
385
+ return { ok: true };
386
+ }
387
+ catch (err) {
388
+ return { ok: false, error: `failed to persist transcript: ${err instanceof Error ? err.message : String(err)}` };
389
+ }
390
+ }
334
391
  /**
335
392
  * Mark one applied fix as rolled back in the PERSISTED transcript, so the
336
393
  * client observatory (F4 fix/rollback tab) reflects the reversal instead of
@@ -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) {
@@ -213,6 +242,10 @@ function applyEntries(projectRoot, incoming) {
213
242
  error: `Failed to write config: ${String(err)}${rollbackError}`,
214
243
  };
215
244
  }
245
+ // Success: bound the accumulation of timestamped backups so a long-lived
246
+ // project never collects an unbounded pile of config snapshots.
247
+ if (backupPath)
248
+ pruneOldConfigBackups(configPath);
216
249
  return { ok: true, added, skipped, count: merged.length, configPath, backupPath };
217
250
  }
218
251
  /**