iterate-plugin 3.5.5 → 3.5.7

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) {
@@ -82,7 +82,9 @@ function normalizeFinding(input) {
82
82
  const severity = sev === 'critical' || sev === 'high' || sev === 'medium' || sev === 'low'
83
83
  ? sev
84
84
  : 'low';
85
- 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;
86
88
  return {
87
89
  dimension,
88
90
  file,
@@ -219,7 +221,9 @@ export class ReviewTranscriptBuilder {
219
221
  const att = typeof attempt === 'number' && Number.isFinite(attempt) ? Math.floor(attempt) : 1;
220
222
  this.roundStart(this.round);
221
223
  const live = this.rounds[this.round - 1];
222
- if (live && this.threadCount(live) < MAX_THREADS_PER_ROUND) {
224
+ if (!live)
225
+ return;
226
+ if (this.threadCount(live) < MAX_THREADS_PER_ROUND) {
223
227
  live.threads.push({
224
228
  dimension: dim || 'review',
225
229
  attempt: att > 0 ? att : 1,
@@ -228,6 +232,78 @@ export class ReviewTranscriptBuilder {
228
232
  findings: [],
229
233
  });
230
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);
231
307
  this.touch();
232
308
  }
233
309
  /** Append narration (assistant text) to the current reviewer thread. */
package/lib/client.js CHANGED
@@ -1211,6 +1211,26 @@ function trendMax(points) {
1211
1211
  }
1212
1212
  return max;
1213
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
+ }
1214
1234
  function buildCompletionSummary(report) {
1215
1235
  const conv = (
1216
1236
  /** @type {Record<string, unknown>} */
@@ -1229,6 +1249,7 @@ var CONFIG_EDIT_FIELDS = [
1229
1249
  { key: "dimensions", label: "\u5BA1\u67E5\u7EF4\u5EA6", hint: '\u6570\u7EC4\uFF0C\u5982 ["correctness","security"]' },
1230
1250
  { key: "max_rounds", label: "\u6700\u5927\u8F6E\u6570", hint: "\u6B63\u6574\u6570" },
1231
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' },
1232
1253
  { key: "atomic.max_lines", label: "\u539F\u5B50\u4FEE\u590D\u4E0A\u9650\u884C\u6570", hint: "\u6B63\u6574\u6570" },
1233
1254
  { key: "git.push_per_round", label: "\u6BCF\u8F6E\u63A8\u9001", hint: "true / false" }
1234
1255
  ];
@@ -1724,20 +1745,6 @@ function severityColor(severity) {
1724
1745
  function severityLabel(severity) {
1725
1746
  return SEVERITY_LABEL[coerceSeverity(severity)];
1726
1747
  }
1727
- function stoppedReasonLabel(reason) {
1728
- switch (reason) {
1729
- case "converged":
1730
- return "\u5DF2\u7ED3\u675F \xB7 \u5DF2\u6536\u655B\uFF08\u65E0\u65B0\u53D1\u73B0\uFF09";
1731
- case "max_rounds_reached":
1732
- return "\u5DF2\u7ED3\u675F \xB7 \u8FBE\u5230\u8F6E\u6570\u4E0A\u9650";
1733
- case "aborted_by_validation":
1734
- return "\u5DF2\u7ED3\u675F \xB7 \u9A8C\u8BC1\u5931\u8D25\u540E\u56DE\u6EDA\u505C\u6B62";
1735
- case "aborted_by_config":
1736
- return "\u5DF2\u7ED3\u675F \xB7 \u9A8C\u8BC1\u547D\u4EE4\u4E0D\u5728\u767D\u540D\u5355\uFF08\u914D\u7F6E\u9700\u4FEE\u590D\uFF09";
1737
- default:
1738
- return reason ? `\u5DF2\u7ED3\u675F \xB7 ${reason}` : "\u5DF2\u7ED3\u675F";
1739
- }
1740
- }
1741
1748
  function runStatusText(live, manifest) {
1742
1749
  if (live) return "\u8FD0\u884C\u4E2D";
1743
1750
  const reason = manifest && typeof manifest.stoppedReason === "string" ? manifest.stoppedReason : "";
@@ -1767,6 +1774,7 @@ var slotsSvc = void 0;
1767
1774
  var themeSvc = void 0;
1768
1775
  var storage = null;
1769
1776
  var themeDisposer = null;
1777
+ var themeListener = null;
1770
1778
  var themeEnabled = true;
1771
1779
  var roundPulseListeners = [];
1772
1780
  function emitRoundPulse(round, converged) {
@@ -1788,6 +1796,31 @@ function clearThemeSkin() {
1788
1796
  themeDisposer = null;
1789
1797
  }
1790
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
+ }
1791
1824
  function setThemeEnabled(enabled) {
1792
1825
  themeEnabled = enabled;
1793
1826
  if (storage) storage.set(THEME_STORAGE_KEY, enabled ? "1" : "0");
@@ -3874,18 +3907,21 @@ function apply(ctx) {
3874
3907
  themeEnabled = savedTheme === null ? true : savedTheme === "1";
3875
3908
  if (themeEnabled) applyThemeSkin();
3876
3909
  if (typeof document !== "undefined" && document.createElement && document.head) {
3877
- const style = document.createElement("style");
3878
- style.dataset.plugin = PLUGIN_TAG;
3879
- style.dataset.pluginCss = "iterate-main";
3880
- style.textContent = ITERATE_CSS;
3881
- document.head.appendChild(style);
3882
- if (typeof ctx.effect === "function") {
3883
- ctx.effect(() => {
3884
- try {
3885
- style.remove();
3886
- } catch {
3887
- }
3888
- });
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
+ }
3889
3925
  }
3890
3926
  }
3891
3927
  if (slotsSvc === void 0) {
@@ -3929,11 +3965,7 @@ function apply(ctx) {
3929
3965
  )
3930
3966
  );
3931
3967
  }
3932
- if (typeof ctx.on === "function") {
3933
- ctx.on("theme/change", () => {
3934
- if (themeEnabled) applyThemeSkin();
3935
- });
3936
- }
3968
+ registerThemeListener(ctx);
3937
3969
  }
3938
3970
  return module.exports; } });
3939
3971
 
package/lib/parse.js CHANGED
@@ -1687,6 +1687,33 @@ export function trendMax(points) {
1687
1687
  return max
1688
1688
  }
1689
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
+
1690
1717
  // ─── Completion notification ────────────────────────────────────────────────
1691
1718
 
1692
1719
  /**
@@ -1717,6 +1744,7 @@ export const CONFIG_EDIT_FIELDS = [
1717
1744
  { key: 'dimensions', label: '审查维度', hint: '数组,如 ["correctness","security"]' },
1718
1745
  { key: 'max_rounds', label: '最大轮数', hint: '正整数' },
1719
1746
  { key: 'review.scope', label: '审查范围', hint: '"full" 或 "changed-only"' },
1747
+ { key: 'reasoning_effort', label: '审查推理强度', hint: '"low" / "medium" / "high",缺省跟随模型默认' },
1720
1748
  { key: 'atomic.max_lines', label: '原子修复上限行数', hint: '正整数' },
1721
1749
  { key: 'git.push_per_round', label: '每轮推送', hint: 'true / false' },
1722
1750
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iterate-plugin",
3
- "version": "3.5.5",
3
+ "version": "3.5.7",
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",
@@ -39,7 +39,11 @@
39
39
  "0.1.5-rc.1": "compatible",
40
40
  "0.1.5-rc.2": "compatible",
41
41
  "0.1.6-alpha.1": "compatible",
42
- "0.1.6-alpha.2": "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",
46
+ "0.1.7-rc.2": "compatible"
43
47
  }
44
48
  },
45
49
  "client": {
@@ -76,15 +80,15 @@
76
80
  "test:validate": "tsx --test test/validate.test.ts"
77
81
  },
78
82
  "dependencies": {
79
- "@deepseek-ai/cordis": "4.0.2",
80
- "@deepseek-ai/dsh-tools": "0.1.6-alpha.2",
81
- "@deepseek-ai/dsh-util-values": "0.1.6-alpha.2",
83
+ "@deepseek-ai/cordis": "4.0.4",
84
+ "@deepseek-ai/dsh-tools": "0.1.7-rc.2",
85
+ "@deepseek-ai/dsh-util-values": "0.1.7-rc.2",
82
86
  "js-yaml": "4.3.2"
83
87
  },
84
88
  "devDependencies": {
85
- "@deepseek-ai/dsh-agent": "0.1.6-alpha.2",
86
- "@deepseek-ai/dsh-jobs": "^0.1.6-alpha.2",
87
- "@deepseek-ai/dsh-session": "0.1.6-alpha.2",
89
+ "@deepseek-ai/dsh-agent": "0.1.7-rc.2",
90
+ "@deepseek-ai/dsh-jobs": "0.1.7-rc.2",
91
+ "@deepseek-ai/dsh-session": "0.1.7-rc.2",
88
92
  "@types/js-yaml": "4.0.9",
89
93
  "@types/node": "22.15.0",
90
94
  "@types/react": "19.2.2",
@@ -81,6 +81,7 @@ import {
81
81
  latestPhase,
82
82
  SEVERITY_LABEL,
83
83
  SEVERITY_COLOR,
84
+ stoppedReasonLabel,
84
85
  } from '../../lib/parse.js'
85
86
 
86
87
  // ─── Module contract ─────────────────────────────────────────────────────────
@@ -737,17 +738,6 @@ function severityLabel(severity: string | undefined): string {
737
738
  return SEVERITY_LABEL[coerceSeverity(severity)]
738
739
  }
739
740
 
740
- /** Human label for WHY a finished run stopped (transcript manifest). */
741
- function stoppedReasonLabel(reason: string | null | undefined): string {
742
- switch (reason) {
743
- case 'converged': return '已结束 · 已收敛(无新发现)'
744
- case 'max_rounds_reached': return '已结束 · 达到轮数上限'
745
- case 'aborted_by_validation': return '已结束 · 验证失败后回滚停止'
746
- case 'aborted_by_config': return '已结束 · 验证命令不在白名单(配置需修复)'
747
- default: return reason ? `已结束 · ${reason}` : '已结束'
748
- }
749
- }
750
-
751
741
  /** Badge text for the run's live/finished state, annotated with the stop reason. */
752
742
  function runStatusText(live: boolean, manifest: ObsManifest | null | undefined): string {
753
743
  if (live) return '运行中'
@@ -785,6 +775,7 @@ let slotsSvc: SlotsService | undefined = undefined
785
775
  let themeSvc: ThemeService | undefined = undefined
786
776
  let storage: ReturnType<typeof createStorage> | null = null
787
777
  let themeDisposer: (() => void) | null = null
778
+ let themeListener: (() => void) | null = null
788
779
  let themeEnabled = true
789
780
  const roundPulseListeners: Array<(payload: { round: number; converged: boolean }) => void> = []
790
781
 
@@ -814,6 +805,33 @@ function clearThemeSkin(): void {
814
805
  }
815
806
  }
816
807
 
808
+ /**
809
+ * Register the theme/change hook exactly once per client lifecycle.
810
+ * The listener is tracked so a re-`apply()` (hot reload / re-mount) never
811
+ * stacks a second copy — every new registration first drops the previous
812
+ * one, and `ctx.effect` pairs it off when the client unmounts.
813
+ */
814
+ function registerThemeListener(ctx: ClientContext): void {
815
+ if (typeof ctx.on !== 'function') return
816
+ if (themeListener) {
817
+ try { themeListener() } catch { /* noop */ }
818
+ themeListener = null
819
+ }
820
+ const off = ctx.on('theme/change', () => {
821
+ if (themeEnabled) applyThemeSkin()
822
+ })
823
+ // `ctx.on` may return void (no unsubscribe support) — only track a callback.
824
+ themeListener = typeof off === 'function' ? off : null
825
+ if (typeof ctx.effect === 'function') {
826
+ ctx.effect(() => {
827
+ if (themeListener) {
828
+ try { themeListener() } catch { /* noop */ }
829
+ themeListener = null
830
+ }
831
+ })
832
+ }
833
+ }
834
+
817
835
  /** Persist + apply the theme preference. */
818
836
  function setThemeEnabled(enabled: boolean): void {
819
837
  themeEnabled = enabled
@@ -2826,15 +2844,20 @@ export function apply(ctx: ClientContext): void {
2826
2844
  themeEnabled = savedTheme === null ? true : savedTheme === '1'
2827
2845
  if (themeEnabled) applyThemeSkin()
2828
2846
 
2829
- // 3. Inject styles (independent of slots / React availability).
2847
+ // 3. Inject styles (independent of slots / React availability). Guarded so a
2848
+ // re-`apply()` never appends a duplicate <style>: the tag is keyed by a
2849
+ // `dataset.iterateLock` marker instead of relying on the CSS content.
2830
2850
  if (typeof document !== 'undefined' && document.createElement && document.head) {
2831
- const style = document.createElement('style')
2832
- style.dataset.plugin = PLUGIN_TAG
2833
- style.dataset.pluginCss = 'iterate-main'
2834
- style.textContent = ITERATE_CSS
2835
- document.head.appendChild(style)
2836
- if (typeof ctx.effect === 'function') {
2837
- ctx.effect(() => { try { style.remove() } catch { /* noop */ } })
2851
+ if (!document.head.querySelector('style[data-iterate-lock="1"]')) {
2852
+ const style = document.createElement('style')
2853
+ style.dataset.iterateLock = '1'
2854
+ style.dataset.plugin = PLUGIN_TAG
2855
+ style.dataset.pluginCss = 'iterate-main'
2856
+ style.textContent = ITERATE_CSS
2857
+ document.head.appendChild(style)
2858
+ if (typeof ctx.effect === 'function') {
2859
+ ctx.effect(() => { try { style.remove() } catch { /* noop */ } })
2860
+ }
2838
2861
  }
2839
2862
  }
2840
2863
 
@@ -2889,9 +2912,7 @@ export function apply(ctx: ClientContext): void {
2889
2912
  }
2890
2913
 
2891
2914
  // 6: keep theme toggle state consistent across theme/change events.
2892
- if (typeof ctx.on === 'function') {
2893
- ctx.on('theme/change', () => {
2894
- if (themeEnabled) applyThemeSkin()
2895
- })
2896
- }
2915
+ // Registered through registerThemeListener so repeated apply() and client
2916
+ // unmounts never leak a second listener.
2917
+ registerThemeListener(ctx)
2897
2918
  }
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from 'node:fs'
1
+ import { existsSync, readFileSync, realpathSync } from 'node:fs'
2
2
  import { homedir } from 'node:os'
3
3
  import { join, resolve, sep } from 'node:path'
4
4
  import yaml from 'js-yaml'
@@ -217,7 +217,25 @@ export function resolveProjectRoot(input?: string, sessionCwd?: string): Project
217
217
  if (raw.includes('\0')) {
218
218
  return { ok: false, reason: 'Refusing project root containing NUL bytes.' }
219
219
  }
220
- const root = raw ? resolve(raw) : resolve(effectiveCwd(sessionCwd))
220
+ const rootLexical = raw ? resolve(raw) : resolve(effectiveCwd(sessionCwd))
221
+ // Collapse symlinks (documented contract: "collapsing `..` and symlinks") —
222
+ // a path through a symlinked directory must resolve to its REAL location so
223
+ // two aliases of the same project share one `.iterate/` state, and tools can
224
+ // never read outside the resolved root via a link. Only when the path
225
+ // exists: a not-yet-created target (dirs created through the tools) keeps
226
+ // the lexical path so the caller can still write into it. The filesystem
227
+ // root check runs against the NORMALIZED path, so a symlink to `/` is
228
+ // refused too.
229
+ let root = rootLexical
230
+ try {
231
+ if (existsSync(rootLexical)) {
232
+ const real = realpathSync(rootLexical)
233
+ if (real) root = real
234
+ }
235
+ } catch {
236
+ // realpath can throw on a broken link or a permission fence — keep the
237
+ // lexical path (existing behavior) rather than failing the resolution.
238
+ }
221
239
  if (!root || root === sep) {
222
240
  return { ok: false, reason: 'Refusing filesystem root as project root.' }
223
241
  }
@@ -32,6 +32,9 @@ export const MAX_ATOMIC_MAX_LINES = 10_000
32
32
  /** Upper bound on `atomic.max_adjacent_methods`. */
33
33
  export const MAX_MAX_ADJACENT_METHODS = 200
34
34
 
35
+ /** Upper bound on `reviewer.scope_chunk_size` (files per reviewer task batch). */
36
+ export const MAX_SCOPE_CHUNK_SIZE = 1000
37
+
35
38
  /** Keep at most this many timestamped config backups (older ones are removed). */
36
39
  export const MAX_CONFIG_BACKUPS = 5
37
40
 
@@ -92,6 +95,35 @@ export function validateConfigUpdates(updates: Record<string, unknown>): string[
92
95
  errors.push(`updates.max_rounds must be an integer between 1 and ${MAX_MAX_ROUNDS}`)
93
96
  }
94
97
  }
98
+ if ('reasoning_effort' in updates) {
99
+ if (
100
+ updates.reasoning_effort !== undefined &&
101
+ updates.reasoning_effort !== 'low' &&
102
+ updates.reasoning_effort !== 'medium' &&
103
+ updates.reasoning_effort !== 'high'
104
+ ) {
105
+ errors.push('updates.reasoning_effort must be "low", "medium", or "high"')
106
+ }
107
+ }
108
+ if ('reviewer' in updates) {
109
+ const rv = updates.reviewer as Record<string, unknown> | undefined
110
+ if (!rv || typeof rv !== 'object') {
111
+ errors.push('updates.reviewer must be an object')
112
+ } else {
113
+ for (const boolKey of ['output_schema_validation', 'evidence_validation', 'coverage_validation'] as const) {
114
+ if (rv[boolKey] !== undefined && typeof rv[boolKey] !== 'boolean') {
115
+ errors.push(`updates.reviewer.${boolKey} must be a boolean`)
116
+ }
117
+ }
118
+ if (
119
+ rv.scope_chunk_size !== undefined &&
120
+ (typeof rv.scope_chunk_size !== 'number' || !Number.isInteger(rv.scope_chunk_size) ||
121
+ rv.scope_chunk_size < 1 || rv.scope_chunk_size > MAX_SCOPE_CHUNK_SIZE)
122
+ ) {
123
+ errors.push(`updates.reviewer.scope_chunk_size must be an integer between 1 and ${MAX_SCOPE_CHUNK_SIZE}`)
124
+ }
125
+ }
126
+ }
95
127
  if ('review' in updates) {
96
128
  const r = updates.review as Record<string, unknown> | undefined
97
129
  if (!r || typeof r !== 'object') {
package/src/evidence.ts CHANGED
@@ -115,7 +115,11 @@ export function verifyLineBounds(
115
115
  if (line === undefined || line === null || line === WHOLE_FILE_LINE) {
116
116
  return { inBounds: true, lineTotal }
117
117
  }
118
- if (line < 1) return { inBounds: false, lineTotal }
118
+ // Only a positive INTEGER is a valid line anchor. A fractional line (e.g.
119
+ // 3.7 from a bypassed schema) or a negative value is out of bounds — a
120
+ // fractional line "within" the file would otherwise be accepted and poison
121
+ // the evidence trail with a location nothing can jump to.
122
+ if (line < 1 || !Number.isInteger(line)) return { inBounds: false, lineTotal }
119
123
  return { inBounds: line <= lineTotal, lineTotal }
120
124
  }
121
125
 
@@ -233,7 +233,11 @@ export function metaReviewReport(report: ReviewReport): MetaReviewResult {
233
233
  : null
234
234
  const lastRoundNew =
235
235
  lastRecordedRound !== null && lastRecordedRound > 0
236
- ? Number(findingsByRound[lastRecordedRound - 1] ?? 0)
236
+ // Over-cap rounds are FOLDED by aggregateRounds into the final slot, so a
237
+ // reported round number may exceed the array length — read the SAME
238
+ // clamped index buildReviewReport/computeConvergence use, or a real
239
+ // over-cap round would be mis-read as 0 new (flag mismatch).
240
+ ? Number(findingsByRound[Math.min(lastRecordedRound, findingsByRound.length) - 1] ?? 0)
237
241
  : null
238
242
  const expectedConverged = lastRoundNew === 0
239
243
  if (report.convergence?.converged !== expectedConverged) {