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.
@@ -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.
@@ -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,57 @@ 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
+ 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
+ */
141
263
  export function executePrune(projectRoot, retainDays, report) {
142
264
  const cutoff = cutoffTimestamp(retainDays);
143
265
  const result = {
@@ -146,22 +268,19 @@ export function executePrune(projectRoot, retainDays, report) {
146
268
  deletedBackups: [],
147
269
  deletedTemps: [],
148
270
  trimmedEmptyRounds: 0,
271
+ deletedExperiences: 0,
272
+ deletedDefenseEvents: 0,
149
273
  errors: [],
150
274
  };
151
275
  // 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;
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);
165
284
  }
166
285
  // 2. Remove checkpoint.
167
286
  if (report.hasCheckpoint) {
@@ -213,6 +332,34 @@ export function executePrune(projectRoot, retainDays, report) {
213
332
  result.errors.push(`failed to delete temp file ${tmp}: ${String(err)}`);
214
333
  }
215
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
+ }
216
363
  return result;
217
364
  }
218
365
  /**
@@ -226,7 +373,8 @@ export function registerPruneTool(ctx) {
226
373
  description: 'Inspect or clean up old iterate runtime artifacts (.iterate/). ' +
227
374
  'Defaults to dry-run (report-only, no deletion). Pass `dryRun: false` to actually prune. ' +
228
375
  'Manages: old decision-log entries, stale checkpoints, orphaned fix backups, empty fix rounds, ' +
229
- '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. ' +
230
378
  'Each deletion is logged to the decision log.',
231
379
  parameters: {
232
380
  dryRun: {
@@ -268,6 +416,8 @@ export function registerPruneTool(ctx) {
268
416
  ` Stale backups to delete: ${report?.staleBackups?.length ?? 0}`,
269
417
  ` Stray temp files to delete: ${report?.staleTemps?.length ?? 0}`,
270
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 ?? '?'})`,
271
421
  '',
272
422
  'Pass dryRun:false to execute the prune.',
273
423
  ];
@@ -280,6 +430,8 @@ export function registerPruneTool(ctx) {
280
430
  ` Deleted ${result?.deletedBackups?.length ?? 0} stale backups.`,
281
431
  ` Deleted ${result?.deletedTemps?.length ?? 0} stray temp files.`,
282
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.`,
283
435
  ];
284
436
  const errs = result?.errors ?? [];
285
437
  if (errs.length > 0) {
@@ -319,6 +471,8 @@ export function registerPruneTool(ctx) {
319
471
  deletedCheckpoint: result.deletedCheckpoint,
320
472
  deletedBackups: result.deletedBackups.length,
321
473
  trimmedEmptyRounds: result.trimmedEmptyRounds,
474
+ deletedExperiences: result.deletedExperiences,
475
+ deletedDefenseEvents: result.deletedDefenseEvents,
322
476
  },
323
477
  });
324
478
  return {
@@ -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) {
@@ -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
  /**
@@ -107,6 +107,7 @@ export class ReviewTranscriptBuilder {
107
107
  round = 0;
108
108
  maxRounds = 0;
109
109
  active = true;
110
+ stoppedReason = null;
110
111
  rounds = [];
111
112
  convergence = [];
112
113
  globalFindings = [];
@@ -158,9 +159,15 @@ export class ReviewTranscriptBuilder {
158
159
  this.phases.push(n);
159
160
  this.touch();
160
161
  }
161
- /** End the run (stops the "active" pulsing in the UI). */
162
- finish() {
162
+ /**
163
+ * End the run (stops the "active" pulsing in the UI). `reason` records WHY
164
+ * it ended, so a run stopped by max-rounds or validation is never mistaken
165
+ * for a live run NOR for a clean convergence.
166
+ */
167
+ finish(reason) {
163
168
  this.active = false;
169
+ if (typeof reason === 'string' && reason.trim())
170
+ this.stoppedReason = reason.trim();
164
171
  this.touch();
165
172
  }
166
173
  /** Open a review round, capturing the current round index. */
@@ -385,6 +392,7 @@ export class ReviewTranscriptBuilder {
385
392
  checkpoint: this.checkpoint,
386
393
  timeline: this.timeline,
387
394
  nudge: this.nudge,
395
+ stoppedReason: this.stoppedReason,
388
396
  approval: {
389
397
  active: this.approval !== 'allow',
390
398
  policy: this.approval,
package/lib/client.js CHANGED
@@ -941,18 +941,25 @@ function computeConvergenceProgress(report) {
941
941
  report.convergence ?? {}
942
942
  );
943
943
  const totalRounds = typeof convergence.totalRounds === "number" ? convergence.totalRounds : 1;
944
- const currentRounds = (
945
- /** @type {Array<unknown>} */
946
- (report.rounds ?? []).length
947
- );
944
+ const currentRounds = currentRoundNumber(report);
948
945
  if (!(totalRounds > 0)) return 0;
949
946
  return Math.min(100, Math.round(currentRounds / totalRounds * 100));
950
947
  }
951
- function getCurrentRound(report) {
952
- return (
953
- /** @type {Array<unknown>} */
954
- (report.rounds ?? []).length
948
+ function currentRoundNumber(report) {
949
+ const rounds = (
950
+ /** @type {Array<Record<string, unknown>>} */
951
+ report.rounds ?? []
955
952
  );
953
+ let max = 0;
954
+ for (const r of rounds) {
955
+ if (!r || typeof r !== "object") continue;
956
+ const n = Number(r.round);
957
+ if (Number.isFinite(n) && n > max) max = n;
958
+ }
959
+ return rounds.length > 0 && max === 0 ? rounds.length : max;
960
+ }
961
+ function getCurrentRound(report) {
962
+ return currentRoundNumber(report);
956
963
  }
957
964
  function getTotalRounds(report) {
958
965
  const convergence = (
@@ -1713,6 +1720,25 @@ function severityColor(severity) {
1713
1720
  function severityLabel(severity) {
1714
1721
  return SEVERITY_LABEL[coerceSeverity(severity)];
1715
1722
  }
1723
+ function stoppedReasonLabel(reason) {
1724
+ switch (reason) {
1725
+ case "converged":
1726
+ return "\u5DF2\u7ED3\u675F \xB7 \u5DF2\u6536\u655B\uFF08\u65E0\u65B0\u53D1\u73B0\uFF09";
1727
+ case "max_rounds_reached":
1728
+ return "\u5DF2\u7ED3\u675F \xB7 \u8FBE\u5230\u8F6E\u6570\u4E0A\u9650";
1729
+ case "aborted_by_validation":
1730
+ return "\u5DF2\u7ED3\u675F \xB7 \u9A8C\u8BC1\u5931\u8D25\u540E\u56DE\u6EDA\u505C\u6B62";
1731
+ case "aborted_by_config":
1732
+ return "\u5DF2\u7ED3\u675F \xB7 \u9A8C\u8BC1\u547D\u4EE4\u4E0D\u5728\u767D\u540D\u5355\uFF08\u914D\u7F6E\u9700\u4FEE\u590D\uFF09";
1733
+ default:
1734
+ return reason ? `\u5DF2\u7ED3\u675F \xB7 ${reason}` : "\u5DF2\u7ED3\u675F";
1735
+ }
1736
+ }
1737
+ function runStatusText(live, manifest) {
1738
+ if (live) return "\u8FD0\u884C\u4E2D";
1739
+ const reason = manifest && typeof manifest.stoppedReason === "string" ? manifest.stoppedReason : "";
1740
+ return reason ? stoppedReasonLabel(reason) : "\u5DF2\u7ED3\u675F";
1741
+ }
1716
1742
  function readSlots(ctx) {
1717
1743
  if (ctx && ctx.slots && typeof ctx.slots.inject === "function" && typeof ctx.slots.register === "function") {
1718
1744
  return ctx.slots;
@@ -1858,7 +1884,7 @@ function ConvergenceDashboard(props) {
1858
1884
  key: "phase",
1859
1885
  "data-live": transcript.active === true ? "" : void 0,
1860
1886
  title: transcript.active === true ? `\u5F53\u524D\u5904\u4E8E\u300C${phase}\u300D\u9636\u6BB5\uFF08\u8FD0\u884C\u4E2D\uFF09` : `\u6700\u8FD1\u4E00\u6B21\u6267\u884C\u505C\u7559\u5728\u300C${phase}\u300D\u9636\u6BB5\uFF08\u5DF2\u7ED3\u675F\uFF09`
1861
- }, `${phase} \xB7 ${transcript.active === true ? "\u8FD0\u884C\u4E2D" : "\u5DF2\u7ED3\u675F"}`) : null;
1887
+ }, `${phase} \xB7 ${runStatusText(transcript.active === true, transcript)}`) : null;
1862
1888
  const taskModeTranscript = latestTranscript(session);
1863
1889
  const taskMode = taskModeTranscript?.taskMode;
1864
1890
  const taskModeChip = taskMode ? React.createElement("span", {
@@ -3171,7 +3197,7 @@ ${JSON.stringify({ operation: "nudge", text: null }, null, 2)}
3171
3197
  "div",
3172
3198
  { className: "iterate-obs-block-head" },
3173
3199
  React.createElement("span", {}, "\u8FD0\u884C\u63A7\u5236\u53F0"),
3174
- React.createElement("span", { className: "iterate-obs-badge", "data-live": live ? "" : void 0 }, live ? "\u8FD0\u884C\u4E2D" : "\u5DF2\u7ED3\u675F"),
3200
+ React.createElement("span", { className: "iterate-obs-badge", "data-live": live ? "" : void 0 }, runStatusText(live, manifest)),
3175
3201
  React.createElement("span", { className: "iterate-obs-head-meta" }, `\u6279\u51C6\u7B56\u7565\uFF1A${policyLabel}`)
3176
3202
  ),
3177
3203
  React.createElement(
@@ -3767,7 +3793,7 @@ ${JSON.stringify({ operation: "clear" }, null, 2)}
3767
3793
  "div",
3768
3794
  { className: "iterate-obs-head", "data-closed": open ? void 0 : "", onClick: () => setOpen((v) => !v) },
3769
3795
  React.createElement("span", { className: "iterate-obs-title" }, "iterate \u89C2\u6D4B\u53F0"),
3770
- React.createElement("span", { className: "iterate-obs-badge", "data-live": live ? "" : void 0 }, live ? "\u8FD0\u884C\u4E2D" : "\u5DF2\u7ED3\u675F"),
3796
+ React.createElement("span", { className: "iterate-obs-badge", "data-live": live ? "" : void 0 }, runStatusText(live, manifest)),
3771
3797
  React.createElement("span", { className: "iterate-obs-head-meta" }, headMeta || "runtime"),
3772
3798
  React.createElement("button", {
3773
3799
  className: "iterate-btn",
package/lib/parse.js CHANGED
@@ -1217,12 +1217,34 @@ export function computeConvergenceProgress(report) {
1217
1217
  const totalRounds = typeof convergence.totalRounds === 'number'
1218
1218
  ? convergence.totalRounds
1219
1219
  : 1
1220
- const currentRounds = /** @type {Array<unknown>} */ (report.rounds ?? []).length
1220
+ const currentRounds = currentRoundNumber(report)
1221
1221
  // Guard against an empty report (totalRounds <= 0) producing NaN.
1222
1222
  if (!(totalRounds > 0)) return 0
1223
1223
  return Math.min(100, Math.round((currentRounds / totalRounds) * 100))
1224
1224
  }
1225
1225
 
1226
+ /**
1227
+ * The actual current round (1-indexed) carried by a report. Uses the highest
1228
+ * per-round `round` field: a normal-mode aggregate ships ONLY the live round
1229
+ * (`rounds: [{round: r, ...}]`), so `rounds.length` would be stuck at 1 while
1230
+ * the run is on round 3. Dry-run aggregates ship the cumulative list, where
1231
+ * the round numbers and the array length agree anyway.
1232
+ *
1233
+ * @param {Record<string, unknown>} report
1234
+ * @returns {number}
1235
+ */
1236
+ function currentRoundNumber(report) {
1237
+ const rounds = /** @type {Array<Record<string, unknown>>} */ (report.rounds ?? [])
1238
+ let max = 0
1239
+ for (const r of rounds) {
1240
+ if (!r || typeof r !== 'object') continue
1241
+ const n = Number(r.round)
1242
+ if (Number.isFinite(n) && n > max) max = n
1243
+ }
1244
+ // Fallback: no usable round numbers → array length (covers all-0 rounds).
1245
+ return rounds.length > 0 && max === 0 ? rounds.length : max
1246
+ }
1247
+
1226
1248
  /**
1227
1249
  * Get the current round number (1-indexed) from a report.
1228
1250
  *
@@ -1230,7 +1252,7 @@ export function computeConvergenceProgress(report) {
1230
1252
  * @returns {number}
1231
1253
  */
1232
1254
  export function getCurrentRound(report) {
1233
- return (/** @type {Array<unknown>} */ (report.rounds ?? [])).length
1255
+ return currentRoundNumber(report)
1234
1256
  }
1235
1257
 
1236
1258
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iterate-plugin",
3
- "version": "3.5.3",
3
+ "version": "3.5.4",
4
4
  "description": "dsh plugin that turns the iterate skill into an autonomous closed-loop harness with quality command center and experience bank (v3.3). Features: plan -> parallel review xN -> atomic fixes -> validate -> loop -> auto-stop, plus dry-run pure-review mode, quality gate compute/persist, writable experience bank, defense events stream (record + bilingual labels), and native command buttons — with a live quality command center (F8/F9/F10 render real session data), §8 assign-fix instruction, and task_mode indicator wired end to end.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -37,7 +37,8 @@
37
37
  "0.1.5-alpha.1": "compatible",
38
38
  "0.1.5-alpha.2": "compatible",
39
39
  "0.1.5-rc.1": "compatible",
40
- "0.1.5-rc.2": "compatible"
40
+ "0.1.5-rc.2": "compatible",
41
+ "0.1.6-alpha.1": "compatible"
41
42
  }
42
43
  },
43
44
  "client": {
@@ -75,13 +76,14 @@
75
76
  },
76
77
  "dependencies": {
77
78
  "@deepseek-ai/cordis": "4.0.2",
78
- "@deepseek-ai/dsh-tools": "0.1.5-rc.2",
79
- "@deepseek-ai/dsh-util-values": "0.1.5-rc.2",
80
- "js-yaml": "4.3.1"
79
+ "@deepseek-ai/dsh-tools": "0.1.6-alpha.1",
80
+ "@deepseek-ai/dsh-util-values": "0.1.6-alpha.1",
81
+ "js-yaml": "4.3.2"
81
82
  },
82
83
  "devDependencies": {
83
- "@deepseek-ai/dsh-jobs": "^0.1.5-rc.2",
84
- "@deepseek-ai/dsh-session": "0.1.5-rc.2",
84
+ "@deepseek-ai/dsh-agent": "0.1.6-alpha.1",
85
+ "@deepseek-ai/dsh-jobs": "^0.1.6-alpha.1",
86
+ "@deepseek-ai/dsh-session": "0.1.6-alpha.1",
85
87
  "@types/js-yaml": "4.0.9",
86
88
  "@types/node": "22.15.0",
87
89
  "@types/react": "19.2.2",