iterate-plugin 3.5.3 → 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.
@@ -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.
@@ -28,14 +32,27 @@ import { resolveProjectRootForExec } from '../config-loader.ts'
28
32
  import { writeTextAtomic, writeJsonAtomic } from '../atomic-fs.ts'
29
33
  import { readDecisionEntries, appendDecisionEntry } 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,45 @@ 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
+ for (let attempt = 0; attempt < MAX_LOG_REWRITE_RETRIES; attempt++) {
268
+ try {
269
+ const entries = readDecisionEntries(projectRoot)
270
+ const kept = entries.filter((e) => e.timestamp >= cutoff)
271
+ const deleted = entries.length - kept.length
272
+ if (deleted === 0) return { deleted: 0 }
273
+ writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n')
274
+ // A concurrent appender may have landed new lines after our read. If the
275
+ // log grew during the write window, re-read and prune again instead of
276
+ // accepting a lost audit trail.
277
+ const after = readDecisionEntries(projectRoot)
278
+ if (after.length <= kept.length) return { deleted }
279
+ } catch (err) {
280
+ return { deleted: 0, error: `failed to rewrite decision log: ${String(err)}` }
281
+ }
282
+ }
283
+ return { deleted: 0, error: `failed to rewrite decision log after ${MAX_LOG_REWRITE_RETRIES} attempts` }
284
+ }
285
+
286
+ /**
287
+ * Execute the prune: apply every cleanable item reported by `inspectPrune`.
288
+ * Every step is individually hedged so a single disk failure degrades to a
289
+ * reported error rather than aborting the whole cleanup.
290
+ */
160
291
  export function executePrune(
161
292
  projectRoot: string,
162
293
  retainDays: number,
@@ -167,6 +298,8 @@ export function executePrune(
167
298
  deletedBackups: string[]
168
299
  deletedTemps: string[]
169
300
  trimmedEmptyRounds: number
301
+ deletedExperiences: number
302
+ deletedDefenseEvents: number
170
303
  errors: string[]
171
304
  } {
172
305
  const cutoff = cutoffTimestamp(retainDays)
@@ -176,22 +309,19 @@ export function executePrune(
176
309
  deletedBackups: [] as string[],
177
310
  deletedTemps: [] as string[],
178
311
  trimmedEmptyRounds: 0,
312
+ deletedExperiences: 0,
313
+ deletedDefenseEvents: 0,
179
314
  errors: [] as string[],
180
315
  }
181
316
 
182
317
  // 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
318
+ // (temp + rename) so a crash mid-write can never truncate the log; the
319
+ // bounded compare-and-append loop guards against a concurrent appender
320
+ // slipping a fresh audit line into the read-before-rewrite window.
321
+ {
322
+ const { deleted, error } = rewriteDecisionLogKeepingRecent(projectRoot, cutoff)
323
+ result.deletedLogEntries = deleted
324
+ if (error) result.errors.push(error)
195
325
  }
196
326
 
197
327
  // 2. Remove checkpoint.
@@ -244,6 +374,34 @@ export function executePrune(
244
374
  }
245
375
  }
246
376
 
377
+ // 6. Trim the experience bank to its entry cap (newest wins).
378
+ if (report.experienceOversize > 0) {
379
+ try {
380
+ const bank = readExperienceBank(projectRoot)
381
+ const { bank: swept, removed } = sweepExperienceBank(bank, MAX_EXPERIENCE_ENTRIES)
382
+ if (removed > 0) {
383
+ writeExperienceBank(projectRoot, swept)
384
+ result.deletedExperiences = removed
385
+ }
386
+ } catch (err) {
387
+ result.errors.push(`failed to trim experience bank: ${String(err)}`)
388
+ }
389
+ }
390
+
391
+ // 7. Drop defense events older than retainDays (recounted on write).
392
+ if (report.staleDefenseEvents > 0) {
393
+ try {
394
+ const stream = readDefenseEvents(projectRoot)
395
+ const { stream: swept, removed } = sweepDefenseEvents(stream, cutoff)
396
+ if (removed > 0) {
397
+ writeDefenseEvents(projectRoot, swept)
398
+ result.deletedDefenseEvents = removed
399
+ }
400
+ } catch (err) {
401
+ result.errors.push(`failed to sweep defense events: ${String(err)}`)
402
+ }
403
+ }
404
+
247
405
  return result
248
406
  }
249
407
 
@@ -260,7 +418,8 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
260
418
  'Inspect or clean up old iterate runtime artifacts (.iterate/). ' +
261
419
  'Defaults to dry-run (report-only, no deletion). Pass `dryRun: false` to actually prune. ' +
262
420
  'Manages: old decision-log entries, stale checkpoints, orphaned fix backups, empty fix rounds, ' +
263
- 'and stray temp files left by crashed atomic writes. ' +
421
+ 'stray temp files left by crashed atomic writes, experience-bank entries beyond the entry cap, ' +
422
+ 'and stale defense events. ' +
264
423
  'Each deletion is logged to the decision log.',
265
424
  parameters: {
266
425
  dryRun: {
@@ -302,6 +461,8 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
302
461
  ` Stale backups to delete: ${(report?.staleBackups as string[] | undefined)?.length ?? 0}`,
303
462
  ` Stray temp files to delete: ${(report?.staleTemps as string[] | undefined)?.length ?? 0}`,
304
463
  ` Empty rounds to trim: ${(report?.emptyRounds as number[] | undefined)?.length ?? 0}`,
464
+ ` Experience entries to drop (over ${report?.totalExperiences ?? '?'} cap): ${report?.experienceOversize ?? 0} of ${report?.totalExperiences ?? '?'}`,
465
+ ` Stale defense events to remove: ${report?.staleDefenseEvents ?? 0} (of ${report?.totalDefenseEvents ?? '?'})`,
305
466
  '',
306
467
  'Pass dryRun:false to execute the prune.',
307
468
  ]
@@ -314,6 +475,8 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
314
475
  ` Deleted ${(result?.deletedBackups as string[] | undefined)?.length ?? 0} stale backups.`,
315
476
  ` Deleted ${(result?.deletedTemps as string[] | undefined)?.length ?? 0} stray temp files.`,
316
477
  ` Trimmed ${result?.trimmedEmptyRounds ?? 0} empty rounds.`,
478
+ ` Trimmed ${result?.deletedExperiences ?? 0} experience entries (over the entry cap).`,
479
+ ` Removed ${result?.deletedDefenseEvents ?? 0} stale defense events.`,
317
480
  ]
318
481
  const errs = (result?.errors as string[] | undefined) ?? []
319
482
  if (errs.length > 0) {
@@ -356,6 +519,8 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
356
519
  deletedCheckpoint: result.deletedCheckpoint,
357
520
  deletedBackups: result.deletedBackups.length,
358
521
  trimmedEmptyRounds: result.trimmedEmptyRounds,
522
+ deletedExperiences: result.deletedExperiences,
523
+ deletedDefenseEvents: result.deletedDefenseEvents,
359
524
  },
360
525
  })
361
526
 
@@ -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). */
@@ -248,6 +278,10 @@ function applyEntries(
248
278
  }
249
279
  }
250
280
 
281
+ // Success: bound the accumulation of timestamped backups so a long-lived
282
+ // project never collects an unbounded pile of config snapshots.
283
+ if (backupPath) pruneOldConfigBackups(configPath)
284
+
251
285
  return { ok: true, added, skipped, count: merged.length, configPath, backupPath }
252
286
  }
253
287
 
package/src/transcript.ts CHANGED
@@ -142,6 +142,7 @@ export class ReviewTranscriptBuilder {
142
142
  private round = 0
143
143
  private maxRounds = 0
144
144
  private active = true
145
+ private stoppedReason: string | null = null
145
146
  private readonly rounds: LiveRound[] = []
146
147
  private readonly convergence: number[] = []
147
148
  private readonly globalFindings: TranscriptFinding[] = []
@@ -203,9 +204,14 @@ export class ReviewTranscriptBuilder {
203
204
  this.touch()
204
205
  }
205
206
 
206
- /** End the run (stops the "active" pulsing in the UI). */
207
- finish(): void {
207
+ /**
208
+ * End the run (stops the "active" pulsing in the UI). `reason` records WHY
209
+ * it ended, so a run stopped by max-rounds or validation is never mistaken
210
+ * for a live run NOR for a clean convergence.
211
+ */
212
+ finish(reason?: string): void {
208
213
  this.active = false
214
+ if (typeof reason === 'string' && reason.trim()) this.stoppedReason = reason.trim()
209
215
  this.touch()
210
216
  }
211
217
 
@@ -443,6 +449,7 @@ export class ReviewTranscriptBuilder {
443
449
  checkpoint: this.checkpoint,
444
450
  timeline: this.timeline,
445
451
  nudge: this.nudge,
452
+ stoppedReason: this.stoppedReason,
446
453
  approval: {
447
454
  active: this.approval !== 'allow',
448
455
  policy: this.approval,
package/src/types.ts CHANGED
@@ -349,6 +349,8 @@ export interface TranscriptManifest {
349
349
  }
350
350
  /** v3.0: task_mode indicator from harness status */
351
351
  taskMode?: 'code' | 'iterate' | null
352
+ /** Why the run ended: converged / max_rounds_reached / aborted_by_validation. */
353
+ stoppedReason?: string | null
352
354
  }
353
355
 
354
356
  // ─── v3.0: Quality Gate ──────────────────────────────────────────────────────