iterate-plugin 3.5.4 → 3.5.6

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.
@@ -148,7 +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
+ stoppedReason: { type: 'string', description: 'For `capture`: why the run ended — "converged" | "max_rounds_reached" | "aborted_by_validation" | "aborted_by_config" | "schema_invalid" | "no_usable_reviewer_output" | "inconclusive". When omitted, derived from the convergence trend (trailing 0 = converged, otherwise = max_rounds_reached once a round ran).' },
152
152
  text: { type: 'string', description: 'For `nudge`: steering text to set (or null to clear).' },
153
153
  path: { type: 'string', description: 'Project root directory (default: current working directory).' },
154
154
  },
@@ -344,13 +344,24 @@ function rehydrateBuilder(manifest, approval) {
344
344
  maxRounds: manifest.maxRounds,
345
345
  });
346
346
  for (const r of Array.isArray(manifest.rounds) ? manifest.rounds : []) {
347
+ // A malformed/null round row must be skipped, never a throw (the caller
348
+ // wraps rehydrate in a fallback, but preserving sibling rows is better).
349
+ if (!r || typeof r !== 'object')
350
+ continue;
347
351
  builder.roundStart(r.round, manifest.maxRounds);
348
352
  for (const t of Array.isArray(r.threads) ? r.threads : []) {
349
- builder.reviewerStart(t.dimension || 'review', t.attempt || 1);
350
- builder.reviewerMessage((t.messages ?? []).join('\n'));
351
- builder.reviewerRead(t.readFiles ?? []);
352
- for (const f of t.findings ?? [])
353
- builder.reviewerFindings([f]);
353
+ // restoreThread (not reviewerStart + reviewerMessage(join)) preserves the
354
+ // message ARRAY boundaries and the thread's own dimension: rehydration
355
+ // previously collapsed each thread's messages into one string and, once a
356
+ // round hit the per-round thread cap, silently DROPPED the extra threads
357
+ // / mis-merged their findings into the previous dimension's thread.
358
+ builder.restoreThread(r.round, {
359
+ dimension: t.dimension,
360
+ attempt: t.attempt,
361
+ messages: t.messages,
362
+ readFiles: t.readFiles,
363
+ findings: t.findings,
364
+ });
354
365
  }
355
366
  }
356
367
  for (let idx = 0; idx < (manifest.convergence ?? []).length; idx += 1) {
@@ -184,8 +184,9 @@ function readConfigFile(configPath) {
184
184
  return {};
185
185
  const content = readFileSync(configPath, 'utf-8');
186
186
  const parsed = yaml.load(content);
187
- if (!parsed || typeof parsed !== 'object') {
188
- // 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
189
190
  // treated as empty: writing over it would destroy user data. Callers
190
191
  // surface this as an error and refuse to write.
191
192
  throw new Error('existing iterate.config.yaml is not a valid YAML mapping');
@@ -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')
@@ -68,7 +82,9 @@ function normalizeFinding(input) {
68
82
  const severity = sev === 'critical' || sev === 'high' || sev === 'medium' || sev === 'low'
69
83
  ? sev
70
84
  : 'low';
71
- const line = typeof f.line === 'number' && Number.isFinite(f.line) ? f.line : 0;
85
+ // Only a non-negative integer is a real line anchor; a fractional/malformed
86
+ // value (e.g. 3.7 from a bypassed schema) must not leak into the manifest.
87
+ const line = typeof f.line === 'number' && Number.isInteger(f.line) && f.line >= 0 ? f.line : 0;
72
88
  return {
73
89
  dimension,
74
90
  file,
@@ -81,12 +97,23 @@ function normalizeFinding(input) {
81
97
  acknowledged: typeof f.acknowledged === 'boolean' ? f.acknowledged : undefined,
82
98
  };
83
99
  }
100
+ /**
101
+ * Append one raw finding to a thread's list, bound to MAX_FINDINGS_PER_THREAD
102
+ * with NEWEST-WINS eviction (drop the oldest findings once the cap is hit).
103
+ * A single oversized fixer agent could otherwise OOM the client with an
104
+ * unbounded finding dump inside one thread.
105
+ */
106
+ function pushThreadFinding(thread, raw) {
107
+ thread.findings.push(raw);
108
+ if (thread.findings.length > MAX_FINDINGS_PER_THREAD) {
109
+ thread.findings.splice(0, thread.findings.length - MAX_FINDINGS_PER_THREAD);
110
+ }
111
+ }
84
112
  /** Merge a report snapshot's findings/readFiles into a thread by dimension. */
85
113
  function mergeReportIntoThread(thread, findings, readFiles) {
86
114
  for (const raw of findings) {
87
- const f = normalizeFinding(raw);
88
- if (f)
89
- thread.findings.push(raw);
115
+ if (normalizeFinding(raw))
116
+ pushThreadFinding(thread, raw);
90
117
  }
91
118
  for (const r of readFiles ?? []) {
92
119
  if (typeof r === 'string')
@@ -111,6 +138,7 @@ export class ReviewTranscriptBuilder {
111
138
  rounds = [];
112
139
  convergence = [];
113
140
  globalFindings = [];
141
+ globalSeenKeys = new Set();
114
142
  fixes = [];
115
143
  checkpoint = null;
116
144
  timeline = [];
@@ -193,7 +221,9 @@ export class ReviewTranscriptBuilder {
193
221
  const att = typeof attempt === 'number' && Number.isFinite(attempt) ? Math.floor(attempt) : 1;
194
222
  this.roundStart(this.round);
195
223
  const live = this.rounds[this.round - 1];
196
- if (live && this.threadCount(live) < MAX_THREADS_PER_ROUND) {
224
+ if (!live)
225
+ return;
226
+ if (this.threadCount(live) < MAX_THREADS_PER_ROUND) {
197
227
  live.threads.push({
198
228
  dimension: dim || 'review',
199
229
  attempt: att > 0 ? att : 1,
@@ -202,6 +232,78 @@ export class ReviewTranscriptBuilder {
202
232
  findings: [],
203
233
  });
204
234
  }
235
+ else {
236
+ // Thread cap reached: route the extra dimension/retry into ONE shared
237
+ // "other" overflow thread instead of silently dropping it. A dropped
238
+ // reviewerStart used to leave `currentThread()` pointing at the PREVIOUS
239
+ // dimension's thread, so its findings were mis-attributed to that
240
+ // dimension. The overflow thread stays bounded (at most ONE extra thread
241
+ // per round) while keeping over-cap findings attributable to "other".
242
+ let overflow = live.threads.find((t) => t.dimension === 'other');
243
+ if (!overflow) {
244
+ overflow = {
245
+ dimension: 'other',
246
+ attempt: att > 0 ? att : 1,
247
+ messages: [],
248
+ readFiles: [],
249
+ findings: [],
250
+ };
251
+ live.threads.push(overflow);
252
+ }
253
+ }
254
+ this.touch();
255
+ }
256
+ /**
257
+ * Restore ONE persisted thread during rehydration, preserving the message
258
+ * ARRAY boundaries (a rehydrated manifest is re-serialized on the next nudge
259
+ * edit, so collapsed messages would silently rewrite the evidence) and the
260
+ * thread's own dimension (never merging it into a previous dimension's
261
+ * thread). Bypasses the live per-round cap — the manifest already enforced
262
+ * it at capture time — but every restored field is hard-bounded so a hostile
263
+ * hand-edited manifest can never blow up memory.
264
+ */
265
+ restoreThread(round, thread) {
266
+ const r = typeof round === 'number' && Number.isFinite(round)
267
+ ? Math.min(Math.floor(round), MAX_ROUNDS)
268
+ : 1;
269
+ this.roundStart(r);
270
+ const live = this.rounds[this.round - 1];
271
+ if (!live)
272
+ return;
273
+ if (!thread || typeof thread !== 'object')
274
+ return;
275
+ // Hostile-manifest guard: keep the live hard bound (with headroom for the
276
+ // single overflow thread) and newest-first truncate beyond it.
277
+ if (live.threads.length >= MAX_THREADS_PER_ROUND * 2 + 1)
278
+ return;
279
+ const dim = typeof thread.dimension === 'string' && thread.dimension.trim()
280
+ ? thread.dimension.trim()
281
+ : 'review';
282
+ const att = typeof thread.attempt === 'number' && Number.isFinite(thread.attempt)
283
+ ? Math.floor(thread.attempt)
284
+ : 1;
285
+ const t = {
286
+ dimension: dim,
287
+ attempt: att > 0 ? att : 1,
288
+ messages: [],
289
+ readFiles: [],
290
+ findings: [],
291
+ };
292
+ const messages = Array.isArray(thread.messages) ? thread.messages : [];
293
+ for (const m of messages.slice(0, MAX_MESSAGES_PER_THREAD)) {
294
+ if (typeof m === 'string' && m.trim())
295
+ t.messages.push(m);
296
+ }
297
+ if (Array.isArray(thread.readFiles)) {
298
+ t.readFiles.push(...dedupePaths(thread.readFiles));
299
+ }
300
+ if (Array.isArray(thread.findings)) {
301
+ for (const f of thread.findings) {
302
+ if (normalizeFinding(f))
303
+ pushThreadFinding(t, f);
304
+ }
305
+ }
306
+ live.threads.push(t);
205
307
  this.touch();
206
308
  }
207
309
  /** Append narration (assistant text) to the current reviewer thread. */
@@ -237,12 +339,11 @@ export class ReviewTranscriptBuilder {
237
339
  for (const raw of findings) {
238
340
  const f = normalizeFinding(raw);
239
341
  if (f) {
240
- thread.findings.push(raw);
241
- this.globalFindings.push(f);
342
+ pushThreadFinding(thread, raw);
343
+ this.addGlobal(f);
242
344
  }
243
345
  }
244
346
  }
245
- this.reevaluateGlobal();
246
347
  this.touch();
247
348
  }
248
349
  /** Merge a round-level report snapshot (findings + readFiles) into a thread. */
@@ -255,9 +356,8 @@ export class ReviewTranscriptBuilder {
255
356
  for (const raw of findings) {
256
357
  const f = normalizeFinding(raw);
257
358
  if (f)
258
- this.globalFindings.push(f);
359
+ this.addGlobal(f);
259
360
  }
260
- this.reevaluateGlobal();
261
361
  this.touch();
262
362
  }
263
363
  // ─── Convergence (F2) ───────────────────────────────────────────────────
@@ -337,8 +437,12 @@ export class ReviewTranscriptBuilder {
337
437
  ? Math.floor(entry.round)
338
438
  : this.round,
339
439
  type,
340
- data: entry.data && typeof entry.data === 'object'
341
- ? entry.data
440
+ data: entry.data && typeof entry.data === 'object' && !Array.isArray(entry.data)
441
+ // Deep clone so a caller mutating its own object later can never
442
+ // alias into the timeline (serialize would then emit the mutated
443
+ // values instead of what was actually decided). Decision-log payloads
444
+ // are small JSON, so the clone cost is negligible.
445
+ ? cloneDecisionData(entry.data)
342
446
  : {},
343
447
  });
344
448
  if (this.timeline.length > MAX_TIMELINE) {
@@ -385,9 +489,9 @@ export class ReviewTranscriptBuilder {
385
489
  maxRounds: this.maxRounds,
386
490
  rounds,
387
491
  convergence: this.convergence,
388
- findings: this.globalFindings.length > MAX_FINDINGS_TOTAL
389
- ? this.globalFindings.slice(0, MAX_FINDINGS_TOTAL)
390
- : this.globalFindings,
492
+ // addGlobal maintains the newest-wins cap incrementally, so the array is
493
+ // already ≤ MAX_FINDINGS_TOTAL — no post-hoc slice needed.
494
+ findings: this.globalFindings,
391
495
  fixes: this.fixes,
392
496
  checkpoint: this.checkpoint,
393
497
  timeline: this.timeline,
@@ -416,25 +520,23 @@ export class ReviewTranscriptBuilder {
416
520
  threadCount(live) {
417
521
  return live.threads.length;
418
522
  }
419
- /** Recompute the global finding list from per-thread findings (dedup). */
420
- reevaluateGlobal() {
421
- // Rebuild from threads to derive a deterministic global list.
422
- this.globalFindings.length = 0;
423
- const seen = new Set();
424
- for (const r of this.rounds) {
425
- for (const t of r.threads) {
426
- for (const raw of t.findings) {
427
- const f = normalizeFinding(raw);
428
- if (!f)
429
- continue;
430
- const key = `${f.file}\u0000${f.line ?? 0}\u0000${f.dimension}\u0000${f.summary}`;
431
- if (seen.has(key))
432
- continue;
433
- seen.add(key);
434
- this.globalFindings.push(f);
435
- if (this.globalFindings.length >= MAX_FINDINGS_TOTAL)
436
- return;
437
- }
523
+ /**
524
+ * Insert one deduplicated finding into the compact global list.
525
+ * NEWEST-WINS: when the global cap (MAX_FINDINGS_TOTAL) is hit, the oldest
526
+ * kept finding is evicted so fresh, still-relevant findings survive — this
527
+ * is the documented contract. Amortized O(1): dedupe via a seen-key set and
528
+ * eviction is one `shift` per overflow insert.
529
+ */
530
+ addGlobal(f) {
531
+ const key = `${f.file}\u0000${f.line ?? 0}\u0000${f.dimension}\u0000${f.summary}`;
532
+ if (this.globalSeenKeys.has(key))
533
+ return;
534
+ this.globalSeenKeys.add(key);
535
+ this.globalFindings.push(f);
536
+ if (this.globalFindings.length > MAX_FINDINGS_TOTAL) {
537
+ const evicted = this.globalFindings.shift();
538
+ if (evicted) {
539
+ this.globalSeenKeys.delete(`${evicted.file}\u0000${evicted.line ?? 0}\u0000${evicted.dimension}\u0000${evicted.summary}`);
438
540
  }
439
541
  }
440
542
  }
package/lib/client.js CHANGED
@@ -465,6 +465,10 @@ function normalizeTranscript(manifest) {
465
465
  phases: asArray(safeGet(src, "phases")).map((p) => typeof p === "string" ? p : ""),
466
466
  round: asNum(safeGet(src, "round")),
467
467
  maxRounds: asNum(safeGet(src, "maxRounds")),
468
+ // v3.5: why a FINISHED run stopped (converged / max_rounds_reached /
469
+ // aborted_by_validation / aborted_by_config). Required for the
470
+ // observatory badge to render the stop reason instead of a bare "已结束".
471
+ stoppedReason: src.stoppedReason === null || src.stoppedReason === void 0 ? null : asStr(safeGet(src, "stoppedReason")) || null,
468
472
  rounds,
469
473
  convergence: asArray(safeGet(src, "convergence")).map((n) => asCount(n)),
470
474
  findings: asArray(safeGet(src, "findings")).map((f) => ({ ...f })),
@@ -1207,6 +1211,26 @@ function trendMax(points) {
1207
1211
  }
1208
1212
  return max;
1209
1213
  }
1214
+ function stoppedReasonLabel(reason) {
1215
+ switch (reason) {
1216
+ case "converged":
1217
+ return "\u5DF2\u7ED3\u675F \xB7 \u5DF2\u6536\u655B\uFF08\u65E0\u65B0\u53D1\u73B0\uFF09";
1218
+ case "max_rounds_reached":
1219
+ return "\u5DF2\u7ED3\u675F \xB7 \u8FBE\u5230\u8F6E\u6570\u4E0A\u9650";
1220
+ case "aborted_by_validation":
1221
+ return "\u5DF2\u7ED3\u675F \xB7 \u9A8C\u8BC1\u5931\u8D25\u540E\u56DE\u6EDA\u505C\u6B62";
1222
+ case "aborted_by_config":
1223
+ return "\u5DF2\u7ED3\u675F \xB7 \u9A8C\u8BC1\u547D\u4EE4\u4E0D\u5728\u767D\u540D\u5355\uFF08\u914D\u7F6E\u9700\u4FEE\u590D\uFF09";
1224
+ case "inconclusive":
1225
+ return "\u5DF2\u7ED3\u675F \xB7 \u8F6E\u6B21\u7ED3\u8BBA\u4E0D\u660E\uFF08\u5BA1\u67E5\u8F93\u51FA\u65E0\u6548\uFF09";
1226
+ case "schema_invalid":
1227
+ return "\u5DF2\u7ED3\u675F \xB7 \u5BA1\u67E5\u8F93\u51FA\u8FDE\u7EED schema \u6821\u9A8C\u5931\u8D25";
1228
+ case "no_usable_reviewer_output":
1229
+ return "\u5DF2\u7ED3\u675F \xB7 \u65E0\u53EF\u7528\u5BA1\u67E5\u8F93\u51FA";
1230
+ default:
1231
+ return reason ? `\u5DF2\u7ED3\u675F \xB7 ${reason}` : "\u5DF2\u7ED3\u675F";
1232
+ }
1233
+ }
1210
1234
  function buildCompletionSummary(report) {
1211
1235
  const conv = (
1212
1236
  /** @type {Record<string, unknown>} */
@@ -1225,6 +1249,7 @@ var CONFIG_EDIT_FIELDS = [
1225
1249
  { key: "dimensions", label: "\u5BA1\u67E5\u7EF4\u5EA6", hint: '\u6570\u7EC4\uFF0C\u5982 ["correctness","security"]' },
1226
1250
  { key: "max_rounds", label: "\u6700\u5927\u8F6E\u6570", hint: "\u6B63\u6574\u6570" },
1227
1251
  { key: "review.scope", label: "\u5BA1\u67E5\u8303\u56F4", hint: '"full" \u6216 "changed-only"' },
1252
+ { key: "reasoning_effort", label: "\u5BA1\u67E5\u63A8\u7406\u5F3A\u5EA6", hint: '"low" / "medium" / "high"\uFF0C\u7F3A\u7701\u8DDF\u968F\u6A21\u578B\u9ED8\u8BA4' },
1228
1253
  { key: "atomic.max_lines", label: "\u539F\u5B50\u4FEE\u590D\u4E0A\u9650\u884C\u6570", hint: "\u6B63\u6574\u6570" },
1229
1254
  { key: "git.push_per_round", label: "\u6BCF\u8F6E\u63A8\u9001", hint: "true / false" }
1230
1255
  ];
@@ -1720,20 +1745,6 @@ function severityColor(severity) {
1720
1745
  function severityLabel(severity) {
1721
1746
  return SEVERITY_LABEL[coerceSeverity(severity)];
1722
1747
  }
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
1748
  function runStatusText(live, manifest) {
1738
1749
  if (live) return "\u8FD0\u884C\u4E2D";
1739
1750
  const reason = manifest && typeof manifest.stoppedReason === "string" ? manifest.stoppedReason : "";
@@ -1763,6 +1774,7 @@ var slotsSvc = void 0;
1763
1774
  var themeSvc = void 0;
1764
1775
  var storage = null;
1765
1776
  var themeDisposer = null;
1777
+ var themeListener = null;
1766
1778
  var themeEnabled = true;
1767
1779
  var roundPulseListeners = [];
1768
1780
  function emitRoundPulse(round, converged) {
@@ -1784,6 +1796,31 @@ function clearThemeSkin() {
1784
1796
  themeDisposer = null;
1785
1797
  }
1786
1798
  }
1799
+ function registerThemeListener(ctx) {
1800
+ if (typeof ctx.on !== "function") return;
1801
+ if (themeListener) {
1802
+ try {
1803
+ themeListener();
1804
+ } catch {
1805
+ }
1806
+ themeListener = null;
1807
+ }
1808
+ const off = ctx.on("theme/change", () => {
1809
+ if (themeEnabled) applyThemeSkin();
1810
+ });
1811
+ themeListener = typeof off === "function" ? off : null;
1812
+ if (typeof ctx.effect === "function") {
1813
+ ctx.effect(() => {
1814
+ if (themeListener) {
1815
+ try {
1816
+ themeListener();
1817
+ } catch {
1818
+ }
1819
+ themeListener = null;
1820
+ }
1821
+ });
1822
+ }
1823
+ }
1787
1824
  function setThemeEnabled(enabled) {
1788
1825
  themeEnabled = enabled;
1789
1826
  if (storage) storage.set(THEME_STORAGE_KEY, enabled ? "1" : "0");
@@ -3870,18 +3907,21 @@ function apply(ctx) {
3870
3907
  themeEnabled = savedTheme === null ? true : savedTheme === "1";
3871
3908
  if (themeEnabled) applyThemeSkin();
3872
3909
  if (typeof document !== "undefined" && document.createElement && document.head) {
3873
- const style = document.createElement("style");
3874
- style.dataset.plugin = PLUGIN_TAG;
3875
- style.dataset.pluginCss = "iterate-main";
3876
- style.textContent = ITERATE_CSS;
3877
- document.head.appendChild(style);
3878
- if (typeof ctx.effect === "function") {
3879
- ctx.effect(() => {
3880
- try {
3881
- style.remove();
3882
- } catch {
3883
- }
3884
- });
3910
+ if (!document.head.querySelector('style[data-iterate-lock="1"]')) {
3911
+ const style = document.createElement("style");
3912
+ style.dataset.iterateLock = "1";
3913
+ style.dataset.plugin = PLUGIN_TAG;
3914
+ style.dataset.pluginCss = "iterate-main";
3915
+ style.textContent = ITERATE_CSS;
3916
+ document.head.appendChild(style);
3917
+ if (typeof ctx.effect === "function") {
3918
+ ctx.effect(() => {
3919
+ try {
3920
+ style.remove();
3921
+ } catch {
3922
+ }
3923
+ });
3924
+ }
3885
3925
  }
3886
3926
  }
3887
3927
  if (slotsSvc === void 0) {
@@ -3925,11 +3965,7 @@ function apply(ctx) {
3925
3965
  )
3926
3966
  );
3927
3967
  }
3928
- if (typeof ctx.on === "function") {
3929
- ctx.on("theme/change", () => {
3930
- if (themeEnabled) applyThemeSkin();
3931
- });
3932
- }
3968
+ registerThemeListener(ctx);
3933
3969
  }
3934
3970
  return module.exports; } });
3935
3971
 
package/lib/parse.js CHANGED
@@ -581,6 +581,12 @@ export function normalizeTranscript(manifest) {
581
581
  phases: asArray(safeGet(src, 'phases')).map((p) => (typeof p === 'string' ? p : '')),
582
582
  round: asNum(safeGet(src, 'round')),
583
583
  maxRounds: asNum(safeGet(src, 'maxRounds')),
584
+ // v3.5: why a FINISHED run stopped (converged / max_rounds_reached /
585
+ // aborted_by_validation / aborted_by_config). Required for the
586
+ // observatory badge to render the stop reason instead of a bare "已结束".
587
+ stoppedReason: src.stoppedReason === null || src.stoppedReason === undefined
588
+ ? null
589
+ : asStr(safeGet(src, 'stoppedReason')) || null,
584
590
  rounds,
585
591
  convergence: asArray(safeGet(src, 'convergence')).map((n) => asCount(n)),
586
592
  findings: asArray(safeGet(src, 'findings')).map((f) => ({ ...f })),
@@ -1681,6 +1687,33 @@ export function trendMax(points) {
1681
1687
  return max
1682
1688
  }
1683
1689
 
1690
+ // ─── Stop reason badges (v3.5.5) ─────────────────────────────────────────────
1691
+ //
1692
+ // Every value the iterate loop can report as `stoppedReason` — including the
1693
+ // schema-retry batch (schema_invalid / no_usable_reviewer_output / inconclusive)
1694
+ // — maps to a concrete Chinese label. Unknown values still get a readable
1695
+ // default instead of a bare "已结束". Kept in parse.js so both the client
1696
+ // observatory badge and Node unit tests share one source of truth.
1697
+
1698
+ /**
1699
+ * Chinese badge label for a run's `stoppedReason`, or null/undefined-safe.
1700
+ *
1701
+ * @param {string | null | undefined} reason
1702
+ * @returns {string}
1703
+ */
1704
+ export function stoppedReasonLabel(reason) {
1705
+ switch (reason) {
1706
+ case 'converged': return '已结束 · 已收敛(无新发现)'
1707
+ case 'max_rounds_reached': return '已结束 · 达到轮数上限'
1708
+ case 'aborted_by_validation': return '已结束 · 验证失败后回滚停止'
1709
+ case 'aborted_by_config': return '已结束 · 验证命令不在白名单(配置需修复)'
1710
+ case 'inconclusive': return '已结束 · 轮次结论不明(审查输出无效)'
1711
+ case 'schema_invalid': return '已结束 · 审查输出连续 schema 校验失败'
1712
+ case 'no_usable_reviewer_output': return '已结束 · 无可用审查输出'
1713
+ default: return reason ? `已结束 · ${reason}` : '已结束'
1714
+ }
1715
+ }
1716
+
1684
1717
  // ─── Completion notification ────────────────────────────────────────────────
1685
1718
 
1686
1719
  /**
@@ -1711,6 +1744,7 @@ export const CONFIG_EDIT_FIELDS = [
1711
1744
  { key: 'dimensions', label: '审查维度', hint: '数组,如 ["correctness","security"]' },
1712
1745
  { key: 'max_rounds', label: '最大轮数', hint: '正整数' },
1713
1746
  { key: 'review.scope', label: '审查范围', hint: '"full" 或 "changed-only"' },
1747
+ { key: 'reasoning_effort', label: '审查推理强度', hint: '"low" / "medium" / "high",缺省跟随模型默认' },
1714
1748
  { key: 'atomic.max_lines', label: '原子修复上限行数', hint: '正整数' },
1715
1749
  { key: 'git.push_per_round', label: '每轮推送', hint: 'true / false' },
1716
1750
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iterate-plugin",
3
- "version": "3.5.4",
3
+ "version": "3.5.6",
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",
@@ -38,7 +38,11 @@
38
38
  "0.1.5-alpha.2": "compatible",
39
39
  "0.1.5-rc.1": "compatible",
40
40
  "0.1.5-rc.2": "compatible",
41
- "0.1.6-alpha.1": "compatible"
41
+ "0.1.6-alpha.1": "compatible",
42
+ "0.1.6-alpha.2": "compatible",
43
+ "0.1.7-alpha.1": "compatible",
44
+ "0.1.7-alpha.2": "compatible",
45
+ "0.1.7-rc.1": "compatible"
42
46
  }
43
47
  },
44
48
  "client": {
@@ -75,15 +79,15 @@
75
79
  "test:validate": "tsx --test test/validate.test.ts"
76
80
  },
77
81
  "dependencies": {
78
- "@deepseek-ai/cordis": "4.0.2",
79
- "@deepseek-ai/dsh-tools": "0.1.6-alpha.1",
80
- "@deepseek-ai/dsh-util-values": "0.1.6-alpha.1",
82
+ "@deepseek-ai/cordis": "4.0.4",
83
+ "@deepseek-ai/dsh-tools": "0.1.7-rc.1",
84
+ "@deepseek-ai/dsh-util-values": "0.1.7-rc.1",
81
85
  "js-yaml": "4.3.2"
82
86
  },
83
87
  "devDependencies": {
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",
88
+ "@deepseek-ai/dsh-agent": "0.1.7-rc.1",
89
+ "@deepseek-ai/dsh-jobs": "0.1.7-rc.1",
90
+ "@deepseek-ai/dsh-session": "0.1.7-rc.1",
87
91
  "@types/js-yaml": "4.0.9",
88
92
  "@types/node": "22.15.0",
89
93
  "@types/react": "19.2.2",
package/src/atomic-fs.ts CHANGED
@@ -54,11 +54,19 @@ function tempPathFor(filePath: string): string {
54
54
  */
55
55
  export function writeTextAtomic(filePath: string, data: string): void {
56
56
  const tmp = tempPathFor(filePath)
57
- writeFileSync(tmp, data, 'utf-8')
57
+ try {
58
+ writeFileSync(tmp, data, 'utf-8')
59
+ } catch (err) {
60
+ // The write itself failed (disk full, permission, …): the temp file may
61
+ // already be partially written, so remove it before propagating — a
62
+ // failed write must never litter the directory either.
63
+ cleanupTemp(tmp)
64
+ throw err
65
+ }
58
66
  try {
59
67
  renameSync(tmp, filePath)
60
68
  } catch (err) {
61
- // Best-effort temp cleanup so a failed write never litters the directory.
69
+ // Best-effort temp cleanup so a failed rename never litters the directory.
62
70
  cleanupTemp(tmp)
63
71
  throw err
64
72
  }
@@ -67,11 +75,19 @@ export function writeTextAtomic(filePath: string, data: string): void {
67
75
  /** Async variant of {@link writeTextAtomic} (same temp naming convention). */
68
76
  export async function writeTextAtomicAsync(filePath: string, data: string): Promise<void> {
69
77
  const tmp = tempPathFor(filePath)
70
- await writeFile(tmp, data, 'utf-8')
78
+ try {
79
+ await writeFile(tmp, data, 'utf-8')
80
+ } catch (err) {
81
+ // The write itself failed (disk full, permission, …): the temp file may
82
+ // already be partially written, so remove it before propagating — a
83
+ // failed write must never litter the directory either.
84
+ await cleanupTempAsync(tmp)
85
+ throw err
86
+ }
71
87
  try {
72
88
  await rename(tmp, filePath)
73
89
  } catch (err) {
74
- // Best-effort temp cleanup so a failed write never litters the directory.
90
+ // Best-effort temp cleanup so a failed rename never litters the directory.
75
91
  await cleanupTempAsync(tmp)
76
92
  throw err
77
93
  }