iterate-plugin 3.4.0 → 3.4.1

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.
package/README.md CHANGED
@@ -292,9 +292,20 @@ All runtime state lives under `.iterate/` at the project root (can be excluded v
292
292
 
293
293
  - **Node.js**: `>=20` (`package.json` `engines.node`).
294
294
  - **DSH**: declares `dsh.compatibility.dshReleases` — `compatible` for the official
295
- `0.1.2-alpha.4`, `0.1.2-alpha.5`, and `0.1.2-rc.1` releases. The plugin is built
296
- against `@deepseek-ai/dsh-tools` / `@deepseek-ai/dsh-util-values` `0.1.2-rc.1`
297
- and uses only public contracts (tool registration, client slots + theme, bundle patch).
295
+ `0.1.1-rc.1`, `0.1.2-alpha.4`, `0.1.2-alpha.5`, `0.1.2-rc.1`, and `0.1.3-alpha.1`
296
+ releases. The plugin is built against `@deepseek-ai/dsh-tools` /
297
+ `@deepseek-ai/dsh-util-values` `0.1.2-rc.1` and uses only public contracts
298
+ (tool registration, client slots + theme, bundle patch). `0.1.3-alpha.1` is a
299
+ source declaration matching the current DSH release window; it does not use any
300
+ API changed by that release.
301
+ - **Disposable-Profile evidence (real, `dsh` CLI `0.1.1-rc.1`)**: on a temp
302
+ `$DSH_HOME`, `dsh plugin --profile <p> add <this-repo>` installed the bundle in
303
+ ~449 ms; `dsh --profile <p> --dump-config` composed the `iterate-plugin` bundle
304
+ patch into the profile; `dsh plugin --profile <p> remove iterate-plugin`
305
+ uninstalled in ~602 ms and left zero references in the composed config. A full
306
+ runtime boot was **not** exercised here (no model provider in the disposable
307
+ profile), so `dshOperations` start/rollback stay `unknown`/`partial` — see
308
+ `CHANGELOG.md` for the exact commands and timings.
298
309
 
299
310
  ### Runtime permissions (conservative disclosure)
300
311
 
@@ -308,10 +319,14 @@ following capabilities; elevated capability means a DSH Profile install stays
308
319
  in-place edits to the user's source files (path-traversal protected to the
309
320
  resolved project root, backups taken before every fix, rollback on failure).
310
321
  The client half persists triage verdicts to `localStorage`.
311
- - **Commands** — the plugin registers model-facing tools; any shell execution that
312
- the model performs (build / test / `git`) is run by the **host**, not by this
313
- plugin. The client "command buttons" only copy paste-able instruction text; they
314
- do not spawn processes.
322
+ - **Commands** — the plugin's own tools execute a small, strictly bounded command
323
+ surface inside the dsh host process, only when the model calls them: `iterate_validate`
324
+ runs one of the **exact-match whitelisted** commands configured in
325
+ `iterate.config.yaml` `validation.commands` (yes, it shells out via
326
+ `node:child_process`, timeout-capped to ≤600 s); `iterate_review` (changed-only
327
+ scope) runs `git diff --name-only -z <branch>` in the project root. Nothing else
328
+ spawns a process. The client "command buttons" only copy paste-able instruction
329
+ text; they do not spawn processes.
315
330
  - **Credentials** — this plugin does **not** read or transmit credentials at
316
331
  runtime. The sibling **iterate-skill / iterate-harness** components may read git
317
332
  credentials / GitHub tokens when the user runs their own Git/API operations; those
package/README.zh-CN.md CHANGED
@@ -290,10 +290,17 @@ validation:
290
290
  ### Node.js / DSH 兼容范围
291
291
 
292
292
  - **Node.js**:`>=20`(`package.json` 的 `engines.node`)。
293
- - **DSH**:在 `dsh.compatibility.dshReleases` 中声明官方 `0.1.2-alpha.4`、
294
- `0.1.2-alpha.5`、`0.1.2-rc.1` 三个版本为 `compatible`。插件基于
295
- `@deepseek-ai/dsh-tools` / `@deepseek-ai/dsh-util-values` `0.1.2-rc.1` 构建,
296
- 只使用公开契约(工具注册、客户端 slots + theme、bundle patch)。
293
+ - **DSH**:在 `dsh.compatibility.dshReleases` 中声明官方 `0.1.1-rc.1`、
294
+ `0.1.2-alpha.4`、`0.1.2-alpha.5`、`0.1.2-rc.1`、`0.1.3-alpha.1` 五个版本为
295
+ `compatible`。插件基于 `@deepseek-ai/dsh-tools` / `@deepseek-ai/dsh-util-values`
296
+ `0.1.2-rc.1` 构建,只使用公开契约(工具注册、客户端 slots + theme、bundle patch);
297
+ `0.1.3-alpha.1` 为匹配当前 DSH 发布窗口的源码声明,插件不使用该发布变更的任何 API。
298
+ - **一次性 Profile 证据(真实,`dsh` CLI `0.1.1-rc.1`)**:在临时 `$DSH_HOME` 下
299
+ `dsh plugin --profile <p> add <本仓库>` 约 449ms 完成安装;`dsh --profile <p>
300
+ --dump-config` 正确合成 `iterate-plugin` bundle patch;`dsh plugin --profile <p>
301
+ remove iterate-plugin` 约 602ms 卸载完成、合成配置中不存在残留引用。完整运行时
302
+ 启动未在此环境演练(一次性 Profile 无模型提供方),故 `dshOperations` 的
303
+ start/rollback 证据为 `unknown`/`partial`——确切命令与耗时见 `CHANGELOG.md`。
297
304
 
298
305
  ### 运行时权限(保守披露)
299
306
 
@@ -304,9 +311,12 @@ validation:
304
311
  检查点、transcript、修复备份/注册表、质量门禁、经验银行、防御事件)。
305
312
  `iterate_fix` / `iterate_triage` 还会就地修改用户源码(路径遍历防护限定在解析后
306
313
  的项目根目录内,每次修复前备份、失败回滚)。客户端侧把分诊判定持久化到 `localStorage`。
307
- - **命令(commands)** — 插件只注册模型可调用工具;模型要执行的任何 shell 操作
308
- (build / test / `git`)都由**宿主**执行,不是本插件。客户端"指挥按钮"只复制可粘贴
309
- 的指令文本,不启动任何进程。
314
+ - **命令(commands)** — 插件的工具本身只执行一个严格受限的命令面、且仅在模型调用时
315
+ 于 dsh 宿主进程内运行:`iterate_validate` 运行 `iterate.config.yaml`
316
+ `validation.commands` 中**逐条精确匹配白名单**的命令(是的,经由
317
+ `node:child_process` 执行,超时上限 600 秒);`iterate_review`(changed-only 范围)
318
+ 在项目根目录运行 `git diff --name-only -z <branch>`。除此之外不启动任何进程。
319
+ 客户端"指挥按钮"只复制可粘贴的指令文本,不启动任何进程。
310
320
  - **凭据(credentials)** — 本插件运行时**不**读取或传输凭据。同生态的
311
321
  **iterate-skill / iterate-harness** 组件在用户自行执行 Git/API 操作时可能读取
312
322
  git 凭据 / GitHub token;它们是独立包,本插件从不加载它们。
package/dist/git-scope.js CHANGED
@@ -22,7 +22,7 @@
22
22
  */
23
23
  import { execFile } from 'node:child_process';
24
24
  import { existsSync, statSync } from 'node:fs';
25
- import { join } from 'node:path';
25
+ import { join, sep } from 'node:path';
26
26
  /**
27
27
  * Parse `git diff --name-only -z` stdout into a list of relative paths.
28
28
  * Pure. NUL-delimited mode is machine-safe (handles any filename); when no
@@ -112,11 +112,12 @@ export function parseChangedFiles(stdout) {
112
112
  */
113
113
  export function filterExistingFiles(root, files) {
114
114
  const out = [];
115
+ const rootPrefix = root.endsWith(sep) ? root : root + sep;
115
116
  for (const rel of files) {
116
117
  if (rel.startsWith('/') || rel.includes('\0'))
117
118
  continue;
118
119
  const candidate = join(root, rel);
119
- if (!candidate.startsWith(root + '/') && candidate !== root)
120
+ if (!candidate.startsWith(rootPrefix) && candidate !== root)
120
121
  continue;
121
122
  try {
122
123
  if (existsSync(candidate) && statSync(candidate).isFile())
@@ -142,9 +142,20 @@ for (let r = 1; r <= maxRounds; r++) {
142
142
  // Feed the DEDUPED + already-filtered set back (not raw findings) so the known
143
143
  // list stays bounded and reviewers never see the same issue twice.
144
144
  if (agg && agg.report && Array.isArray(agg.report.findings)) known = agg.report.findings
145
- if (agg && agg.report && agg.report.convergence && agg.report.convergence.findingsByRound[r-1] === 0) {
146
- log('round ' + r + ' found 0 new findings — converged')
147
- break
145
+ // A round counts as "converged" ONLY when the aggregate accepted it
146
+ // (schema-valid) AND it genuinely found zero NEW findings. If reviewers all
147
+ // failed or their output stayed schema-invalid after retries, the round is
148
+ // INCONCLUSIVE — never report an empty-but-broken round as converged (an
149
+ // invalid/failed round must fail the run, not masquerade as a clean pass).
150
+ if (!schemaInvalid) {
151
+ const newCount =
152
+ agg && agg.report && agg.report.convergence
153
+ ? agg.report.convergence.findingsByRound[r - 1]
154
+ : thisRound.findings.length
155
+ if (newCount === 0) {
156
+ log('round ' + r + ' found 0 new findings — converged')
157
+ break
158
+ }
148
159
  }
149
160
  }
150
161
 
@@ -237,7 +248,7 @@ if (checkpoint) {
237
248
 
238
249
  phase('plan')
239
250
  const configRes = await agent(
240
- 'Call iterate_config({ validate: true }) and return the config JSON.',
251
+ 'Call iterate_config({}) and return the config JSON.',
241
252
  Object.assign({ label: 'config:read' }, backend)
242
253
  )
243
254
  const cfg = (configRes && configRes.config) ? configRes.config : null
@@ -420,7 +431,7 @@ for (let r = startRound; r <= maxRounds; r++) {
420
431
  Object.assign({ label: 'checkpoint:save:r' + r }, backend)
421
432
  )
422
433
 
423
- if (atomic.length === 0 && remaining.length === 0) {
434
+ if (atomic.length === 0 && remaining.length === 0 && !schemaInvalid) {
424
435
  log('round ' + r + ' found nothing to fix — converged')
425
436
  converged = true
426
437
  break
package/lib/client.js CHANGED
@@ -115,10 +115,11 @@ function scanSessionForResume(obj, seen, maxDepth = 20) {
115
115
  function countSessionImages(session) {
116
116
  if (!session || typeof session !== "object") return 0;
117
117
  const ids = /* @__PURE__ */ new Set();
118
+ const consumed = /* @__PURE__ */ new Set();
118
119
  let count = 0;
119
120
  const walk = (obj, depth) => {
120
121
  if (depth <= 0 || !obj || typeof obj !== "object") return;
121
- if (seen.has(obj)) return;
122
+ if (seen.has(obj) || consumed.has(obj)) return;
122
123
  seen.add(obj);
123
124
  const o = (
124
125
  /** @type {Record<string, unknown>} */
@@ -142,6 +143,7 @@ function countSessionImages(session) {
142
143
  } else {
143
144
  count += 1;
144
145
  }
146
+ consumed.add(ref);
145
147
  }
146
148
  if (Array.isArray(obj)) {
147
149
  for (const item of obj) walk(item, depth - 1);
@@ -289,16 +291,21 @@ function attachLive(manifest, source) {
289
291
  copy.live = live;
290
292
  return copy;
291
293
  }
292
- function extractTranscript(obj) {
294
+ function extractTranscript(obj, seen, depth) {
295
+ if (depth === void 0) depth = 0;
296
+ if (depth > 20) return null;
293
297
  if (typeof obj === "string") {
294
298
  try {
295
299
  const parsed = JSON.parse(obj);
296
- return extractTranscript(parsed);
300
+ return extractTranscript(parsed, seen, depth + 1);
297
301
  } catch {
298
302
  return null;
299
303
  }
300
304
  }
301
305
  if (!obj || typeof obj !== "object") return null;
306
+ if (!seen) seen = /* @__PURE__ */ new Set();
307
+ if (seen.has(obj)) return null;
308
+ seen.add(obj);
302
309
  if (isTranscriptManifest(obj)) return (
303
310
  /** @type {Record<string, unknown>} */
304
311
  obj
@@ -322,11 +329,11 @@ function extractTranscript(obj) {
322
329
  if (val !== void 0) {
323
330
  if (Array.isArray(val)) {
324
331
  for (const item of val) {
325
- const found = extractTranscript(item);
332
+ const found = extractTranscript(item, seen, depth + 1);
326
333
  if (found) return attachLive(found, o);
327
334
  }
328
335
  } else {
329
- const found = extractTranscript(val);
336
+ const found = extractTranscript(val, seen, depth + 1);
330
337
  if (found) return attachLive(found, o);
331
338
  }
332
339
  }
@@ -569,6 +576,24 @@ function latestToolResultNode(session, toolName) {
569
576
  /** @type {Array<Record<string, unknown>>} */
570
577
  messages
571
578
  );
579
+ for (let i = msgs.length - 1; i >= 0; i--) {
580
+ const msg = msgs[i];
581
+ if (!msg) continue;
582
+ const calls = safeGet(msg, "tool_calls");
583
+ if (!Array.isArray(calls)) continue;
584
+ const callList = (
585
+ /** @type {Array<Record<string, unknown>>} */
586
+ calls
587
+ );
588
+ for (let j = callList.length - 1; j >= 0; j--) {
589
+ const call = callList[j];
590
+ if (!call) continue;
591
+ const name2 = String(safeGet(call, "name") ?? safeGet(call, "tool") ?? "");
592
+ if (name2 !== toolName && !name2.endsWith(toolName)) continue;
593
+ const result = safeGet(call, "result") ?? safeGet(call, "response") ?? safeGet(call, "message");
594
+ if (result !== void 0 && result !== null) return result;
595
+ }
596
+ }
572
597
  for (let i = msgs.length - 1; i >= 0; i--) {
573
598
  const msg = msgs[i];
574
599
  if (!msg) continue;
@@ -895,6 +920,7 @@ function computeSummaryFromFindings(findings) {
895
920
  const counts = { critical: 0, high: 0, medium: 0, low: 0 };
896
921
  const byDimension = {};
897
922
  for (const f of findings) {
923
+ if (!f || typeof f !== "object") continue;
898
924
  const sev = String(f.severity ?? "low");
899
925
  if (sev in counts) counts[sev]++;
900
926
  const dim = String(f.dimension ?? "unknown");
@@ -942,6 +968,7 @@ function severityStats(report) {
942
968
  );
943
969
  const counts = { critical: 0, high: 0, medium: 0, low: 0 };
944
970
  for (const f of findings) {
971
+ if (!f || typeof f !== "object") continue;
945
972
  const sev = String(f.severity ?? "low");
946
973
  if (sev in counts) counts[sev]++;
947
974
  }
@@ -954,6 +981,7 @@ function groupByDimension(report) {
954
981
  );
955
982
  const groups = {};
956
983
  for (const f of findings) {
984
+ if (!f || typeof f !== "object") continue;
957
985
  const dim = String(f.dimension ?? "unknown");
958
986
  if (!groups[dim]) groups[dim] = [];
959
987
  groups[dim].push(f);
@@ -1048,6 +1076,7 @@ function normalizeFindingFilter(filter) {
1048
1076
  return { severities, dimensions, search };
1049
1077
  }
1050
1078
  function findingMatches(finding, filter) {
1079
+ if (!finding || typeof finding !== "object") return false;
1051
1080
  const f = normalizeFindingFilter(filter);
1052
1081
  const sev = String(finding.severity ?? "low");
1053
1082
  if (f.severities.length > 0 && !f.severities.includes(sev)) return false;
@@ -1118,6 +1147,7 @@ function setAllVerdicts(triageState, verdict, indices) {
1118
1147
  function buildRoundHistory(report) {
1119
1148
  const rounds = Array.isArray(report.rounds) ? report.rounds : [];
1120
1149
  return rounds.map((r) => {
1150
+ if (!r || typeof r !== "object") return { round: 0, count: 0, critical: 0, high: 0, medium: 0, low: 0 };
1121
1151
  const rr = (
1122
1152
  /** @type {Record<string, unknown>} */
1123
1153
  r
@@ -1274,7 +1304,13 @@ function filterTimelineEntries(entries, opts) {
1274
1304
  if (type && String(t.type ?? "") !== type) return false;
1275
1305
  if (round && String(t.round ?? "") !== round) return false;
1276
1306
  if (q) {
1277
- const hay = [String(t.type ?? ""), String(t.round ?? ""), JSON.stringify(t.data ?? {})].join(" ").toLowerCase();
1307
+ let dataText = "";
1308
+ try {
1309
+ dataText = JSON.stringify(t.data ?? {});
1310
+ } catch {
1311
+ dataText = t.data === void 0 ? "{}" : String(t.data);
1312
+ }
1313
+ const hay = [String(t.type ?? ""), String(t.round ?? ""), dataText].join(" ").toLowerCase();
1278
1314
  if (hay.indexOf(q) < 0) return false;
1279
1315
  }
1280
1316
  return true;
@@ -2078,7 +2114,7 @@ function TriagePanel(props) {
2078
2114
  const ignoredCount = ignored.length;
2079
2115
  const counts = countVerdicts(verdicts);
2080
2116
  const doApproveArchitecturalFix = () => {
2081
- const cmd = "\u8BF7\u8C03\u7528 `iterate_fix` \u6279\u51C6\u67B6\u6784\u4FEE\u590D\uFF08\u8BBE\u7F6E is_architectural: true\uFF09";
2117
+ const cmd = "\u8BF7\u8C03\u7528 `iterate_fix`\uFF0C\u5BF9\u672C\u9879\u76EE\u5269\u4F59\u67B6\u6784\u578B finding \u751F\u6210\u4FEE\u590D\u5185\u5BB9\u5E76\u8BBE\u7F6E force: true \u5E94\u7528\uFF08\u67B6\u6784\u4FEE\u590D\u5141\u8BB8\u8D85\u8FC7 atomic \u9608\u503C\uFF09";
2082
2118
  copyText(cmd).then((ok) => {
2083
2119
  if (ok) {
2084
2120
  setCmdCopied("approve-arch");
@@ -2096,7 +2132,7 @@ function TriagePanel(props) {
2096
2132
  });
2097
2133
  };
2098
2134
  const doRollbackToCheckpoint = () => {
2099
- const cmd = "\u8BF7\u8C03\u7528 `iterate_checkpoint` \u56DE\u6EDA\u5230\u4E0A\u4E00\u4E2A\u68C0\u67E5\u70B9";
2135
+ const cmd = "\u8BF7\u56DE\u6EDA\u4E0A\u4E00\u8F6E\u8FED\u4EE3\u7684\u4FEE\u590D\uFF1A\u8C03\u7528 `iterate_history` \u67E5\u770B\u672C\u8F6E fix id\uFF0C\u518D\u7528 `iterate_rollback` \u9010\u4E2A\u64A4\u9500\u8FD9\u4E9B\u4FEE\u590D";
2100
2136
  copyText(cmd).then((ok) => {
2101
2137
  if (ok) {
2102
2138
  setCmdCopied("rollback");
@@ -3180,7 +3216,14 @@ ${JSON.stringify({ operation: "nudge", text: null }, null, 2)}
3180
3216
  const sorted = filtered.slice();
3181
3217
  const rows = sorted.map((t, i) => {
3182
3218
  const k = `f7-${i}`;
3183
- const dataText = t.data && typeof t.data === "object" ? JSON.stringify(t.data) : "";
3219
+ let dataText = "";
3220
+ if (t.data && typeof t.data === "object") {
3221
+ try {
3222
+ dataText = JSON.stringify(t.data);
3223
+ } catch {
3224
+ dataText = String(t.data);
3225
+ }
3226
+ }
3184
3227
  return React.createElement(
3185
3228
  "div",
3186
3229
  { key: k, className: "iterate-obs-row", style: { flexDirection: "column", alignItems: "flex-start" } },
package/lib/parse.js CHANGED
@@ -133,12 +133,13 @@ export function countSessionImages(session) {
133
133
  if (!session || typeof session !== 'object') return 0
134
134
 
135
135
  const ids = new Set()
136
+ const consumed = new Set()
136
137
  let count = 0
137
138
 
138
139
  /** @param {unknown} obj */
139
140
  const walk = (obj, depth) => {
140
141
  if (depth <= 0 || !obj || typeof obj !== 'object') return
141
- if (seen.has(obj)) return
142
+ if (seen.has(obj) || consumed.has(obj)) return
142
143
  seen.add(obj)
143
144
  const o = /** @type {Record<string, unknown>} */ (obj)
144
145
 
@@ -158,6 +159,9 @@ export function countSessionImages(session) {
158
159
  } else {
159
160
  count += 1
160
161
  }
162
+ // The ref node is already counted; mark it consumed so descending into it
163
+ // (its own mediaType/attachmentId keys) does not double-count the image.
164
+ consumed.add(ref)
161
165
  }
162
166
 
163
167
  if (Array.isArray(obj)) {
@@ -364,18 +368,25 @@ function attachLive(manifest, source) {
364
368
  * manifest buried inside an arbitrary result still surfaces. Never throws.
365
369
  *
366
370
  * @param {unknown} obj
371
+ * @param {Set<unknown>} [seen]
372
+ * @param {number} [depth]
367
373
  * @returns {Record<string, unknown> | null}
368
374
  */
369
- function extractTranscript(obj) {
375
+ function extractTranscript(obj, seen, depth) {
376
+ if (depth === undefined) depth = 0
377
+ if (depth > 20) return null
370
378
  if (typeof obj === 'string') {
371
379
  try {
372
380
  const parsed = JSON.parse(obj)
373
- return extractTranscript(parsed)
381
+ return extractTranscript(parsed, seen, depth + 1)
374
382
  } catch {
375
383
  return null
376
384
  }
377
385
  }
378
386
  if (!obj || typeof obj !== 'object') return null
387
+ if (!seen) seen = new Set()
388
+ if (seen.has(obj)) return null
389
+ seen.add(obj)
379
390
 
380
391
  // Direct manifest.
381
392
  if (isTranscriptManifest(obj)) return /** @type {Record<string, unknown>} */ (obj)
@@ -397,11 +408,11 @@ function extractTranscript(obj) {
397
408
  if (val !== undefined) {
398
409
  if (Array.isArray(val)) {
399
410
  for (const item of val) {
400
- const found = extractTranscript(item)
411
+ const found = extractTranscript(item, seen, depth + 1)
401
412
  if (found) return attachLive(found, o)
402
413
  }
403
414
  } else {
404
- const found = extractTranscript(val)
415
+ const found = extractTranscript(val, seen, depth + 1)
405
416
  if (found) return attachLive(found, o)
406
417
  }
407
418
  }
@@ -745,6 +756,29 @@ function latestToolResultNode(session, toolName) {
745
756
  const messages = safeGet(s, 'messages')
746
757
  if (Array.isArray(messages)) {
747
758
  const msgs = /** @type {Array<Record<string, unknown>>} */ (messages)
759
+ // Assistant tool-call variant: the tool result may live on the call object
760
+ // inside message.tool_calls rather than on session.toolCalls. Scan every
761
+ // message for a matching call (newest-first) before falling back to the
762
+ // last message's raw content — a conversational closing message must not
763
+ // shadow an earlier message's real tool result.
764
+ for (let i = msgs.length - 1; i >= 0; i--) {
765
+ const msg = msgs[i]
766
+ if (!msg) continue
767
+ const calls = safeGet(msg, 'tool_calls')
768
+ if (!Array.isArray(calls)) continue
769
+ const callList = /** @type {Array<Record<string, unknown>>} */ (calls)
770
+ for (let j = callList.length - 1; j >= 0; j--) {
771
+ const call = callList[j]
772
+ if (!call) continue
773
+ const name = String(safeGet(call, 'name') ?? safeGet(call, 'tool') ?? '')
774
+ if (name !== toolName && !name.endsWith(toolName)) continue
775
+ const result = safeGet(call, 'result') ?? safeGet(call, 'response') ?? safeGet(call, 'message')
776
+ if (result !== undefined && result !== null) return result
777
+ }
778
+ }
779
+ // Fallback: the last message's raw content (pre-existing behavior — a
780
+ // plain conversational string yields null upstream, but an embedded JSON
781
+ // body is still worth surfacing for the deep-scan finders).
748
782
  for (let i = msgs.length - 1; i >= 0; i--) {
749
783
  const msg = msgs[i]
750
784
  if (!msg) continue
@@ -1151,6 +1185,7 @@ function computeSummaryFromFindings(findings) {
1151
1185
  const byDimension = {}
1152
1186
 
1153
1187
  for (const f of findings) {
1188
+ if (!f || typeof f !== 'object') continue
1154
1189
  const sev = String(f.severity ?? 'low')
1155
1190
  if (sev in counts) counts[sev]++
1156
1191
  const dim = String(f.dimension ?? 'unknown')
@@ -1224,6 +1259,7 @@ export function severityStats(report) {
1224
1259
  const findings = /** @type {Array<Record<string, unknown>>} */ (report.findings ?? [])
1225
1260
  const counts = { critical: 0, high: 0, medium: 0, low: 0 }
1226
1261
  for (const f of findings) {
1262
+ if (!f || typeof f !== 'object') continue
1227
1263
  const sev = String(f.severity ?? 'low')
1228
1264
  if (sev in counts) counts[sev]++
1229
1265
  }
@@ -1243,6 +1279,7 @@ export function groupByDimension(report) {
1243
1279
  /** @type {Record<string, Array<Record<string, unknown>>>} */
1244
1280
  const groups = {}
1245
1281
  for (const f of findings) {
1282
+ if (!f || typeof f !== 'object') continue
1246
1283
  const dim = String(f.dimension ?? 'unknown')
1247
1284
  if (!groups[dim]) groups[dim] = []
1248
1285
  groups[dim].push(f)
@@ -1412,6 +1449,7 @@ export function normalizeFindingFilter(filter) {
1412
1449
  * @returns {boolean}
1413
1450
  */
1414
1451
  export function findingMatches(finding, filter) {
1452
+ if (!finding || typeof finding !== 'object') return false
1415
1453
  const f = normalizeFindingFilter(filter)
1416
1454
  const sev = String(finding.severity ?? 'low')
1417
1455
  if (f.severities.length > 0 && !f.severities.includes(sev)) return false
@@ -1553,6 +1591,7 @@ export function setAllVerdicts(triageState, verdict, indices) {
1553
1591
  export function buildRoundHistory(report) {
1554
1592
  const rounds = Array.isArray(report.rounds) ? report.rounds : []
1555
1593
  return rounds.map((r) => {
1594
+ if (!r || typeof r !== 'object') return { round: 0, count: 0, critical: 0, high: 0, medium: 0, low: 0 }
1556
1595
  const rr = /** @type {Record<string, unknown>} */ (r)
1557
1596
  const findings = Array.isArray(rr.findings) ? rr.findings : []
1558
1597
  const sev = severityStats({ findings })
@@ -1823,7 +1862,15 @@ export function filterTimelineEntries(entries, opts) {
1823
1862
  if (type && String(t.type ?? '') !== type) return false
1824
1863
  if (round && String(t.round ?? '') !== round) return false
1825
1864
  if (q) {
1826
- const hay = [String(t.type ?? ''), String(t.round ?? ''), JSON.stringify(t.data ?? {})].join(' ').toLowerCase()
1865
+ let dataText = ''
1866
+ try {
1867
+ dataText = JSON.stringify(t.data ?? {})
1868
+ } catch {
1869
+ // A self-referential `data` reference would throw on stringify — degrade
1870
+ // to a string representation rather than crashing the search.
1871
+ dataText = t.data === undefined ? '{}' : String(t.data)
1872
+ }
1873
+ const hay = [String(t.type ?? ''), String(t.round ?? ''), dataText].join(' ').toLowerCase()
1827
1874
  if (hay.indexOf(q) < 0) return false
1828
1875
  }
1829
1876
  return true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iterate-plugin",
3
- "version": "3.4.0",
3
+ "version": "3.4.1",
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",
@@ -29,9 +29,11 @@
29
29
  },
30
30
  "compatibility": {
31
31
  "dshReleases": {
32
+ "0.1.1-rc.1": "compatible",
32
33
  "0.1.2-alpha.4": "compatible",
33
34
  "0.1.2-alpha.5": "compatible",
34
- "0.1.2-rc.1": "compatible"
35
+ "0.1.2-rc.1": "compatible",
36
+ "0.1.3-alpha.1": "compatible"
35
37
  }
36
38
  },
37
39
  "client": {
@@ -61,7 +61,6 @@ import {
61
61
  batchSetVerdict,
62
62
  setAllVerdicts,
63
63
  buildRoundHistory,
64
- buildFindingTrend,
65
64
  computeTrendMetrics,
66
65
  trendMax,
67
66
  buildCompletionSummary,
@@ -1226,7 +1225,10 @@ function TriagePanel(props: SlotProps) {
1226
1225
 
1227
1226
  // v3.0: Command handlers for native action buttons
1228
1227
  const doApproveArchitecturalFix = () => {
1229
- const cmd = '请调用 `iterate_fix` 批准架构修复(设置 is_architectural: true)'
1228
+ // Architectural findings exceed the atomic threshold, so "approving" one
1229
+ // means telling the model to apply it as a deliberate forced change via
1230
+ // iterate_fix with force:true (is_architectural is not a real parameter).
1231
+ const cmd = '请调用 `iterate_fix`,对本项目剩余架构型 finding 生成修复内容并设置 force: true 应用(架构修复允许超过 atomic 阈值)'
1230
1232
  copyText(cmd).then((ok) => {
1231
1233
  if (ok) {
1232
1234
  setCmdCopied('approve-arch')
@@ -1246,7 +1248,10 @@ function TriagePanel(props: SlotProps) {
1246
1248
  }
1247
1249
 
1248
1250
  const doRollbackToCheckpoint = () => {
1249
- const cmd = '请调用 `iterate_checkpoint` 回滚到上一个检查点'
1251
+ // iterate_checkpoint has NO rollback operation (save/load/resume/clear
1252
+ // only); reverting applied fixes is done via iterate_rollback by fix id,
1253
+ // so the paste-able instruction must point there instead.
1254
+ const cmd = '请回滚上一轮迭代的修复:调用 `iterate_history` 查看本轮 fix id,再用 `iterate_rollback` 逐个撤销这些修复'
1250
1255
  copyText(cmd).then((ok) => {
1251
1256
  if (ok) {
1252
1257
  setCmdCopied('rollback')
@@ -2267,7 +2272,10 @@ function ObservatoryPanel(props: SlotProps) {
2267
2272
  const sorted = filtered.slice()
2268
2273
  const rows = sorted.map((t, i) => {
2269
2274
  const k = `f7-${i}`
2270
- const dataText = t.data && typeof t.data === 'object' ? JSON.stringify(t.data) : ''
2275
+ let dataText = ''
2276
+ if (t.data && typeof t.data === 'object') {
2277
+ try { dataText = JSON.stringify(t.data) } catch { dataText = String(t.data) }
2278
+ }
2271
2279
  return React.createElement('div', { key: k, className: 'iterate-obs-row', style: { flexDirection: 'column', alignItems: 'flex-start' } },
2272
2280
  React.createElement('div', { className: 'iterate-obs-bar', style: { width: '100%' } },
2273
2281
  React.createElement('span', { className: 'iterate-obs-chip' }, String(t.type || '?')),
package/src/git-scope.ts CHANGED
@@ -23,7 +23,7 @@
23
23
 
24
24
  import { execFile } from 'node:child_process'
25
25
  import { existsSync, statSync } from 'node:fs'
26
- import { join } from 'node:path'
26
+ import { join, sep } from 'node:path'
27
27
 
28
28
  /** A resolved changed-only scope result. */
29
29
  export interface GitScopeResult {
@@ -113,10 +113,11 @@ export function parseChangedFiles(stdout: string): string[] {
113
113
  */
114
114
  export function filterExistingFiles(root: string, files: string[]): string[] {
115
115
  const out: string[] = []
116
+ const rootPrefix = root.endsWith(sep) ? root : root + sep
116
117
  for (const rel of files) {
117
118
  if (rel.startsWith('/') || rel.includes('\0')) continue
118
119
  const candidate = join(root, rel)
119
- if (!candidate.startsWith(root + '/') && candidate !== root) continue
120
+ if (!candidate.startsWith(rootPrefix) && candidate !== root) continue
120
121
  try {
121
122
  if (existsSync(candidate) && statSync(candidate).isFile()) out.push(rel)
122
123
  } catch {
@@ -143,9 +143,20 @@ for (let r = 1; r <= maxRounds; r++) {
143
143
  // Feed the DEDUPED + already-filtered set back (not raw findings) so the known
144
144
  // list stays bounded and reviewers never see the same issue twice.
145
145
  if (agg && agg.report && Array.isArray(agg.report.findings)) known = agg.report.findings
146
- if (agg && agg.report && agg.report.convergence && agg.report.convergence.findingsByRound[r-1] === 0) {
147
- log('round ' + r + ' found 0 new findings — converged')
148
- break
146
+ // A round counts as "converged" ONLY when the aggregate accepted it
147
+ // (schema-valid) AND it genuinely found zero NEW findings. If reviewers all
148
+ // failed or their output stayed schema-invalid after retries, the round is
149
+ // INCONCLUSIVE — never report an empty-but-broken round as converged (an
150
+ // invalid/failed round must fail the run, not masquerade as a clean pass).
151
+ if (!schemaInvalid) {
152
+ const newCount =
153
+ agg && agg.report && agg.report.convergence
154
+ ? agg.report.convergence.findingsByRound[r - 1]
155
+ : thisRound.findings.length
156
+ if (newCount === 0) {
157
+ log('round ' + r + ' found 0 new findings — converged')
158
+ break
159
+ }
149
160
  }
150
161
  }
151
162
 
@@ -238,7 +249,7 @@ if (checkpoint) {
238
249
 
239
250
  phase('plan')
240
251
  const configRes = await agent(
241
- 'Call iterate_config({ validate: true }) and return the config JSON.',
252
+ 'Call iterate_config({}) and return the config JSON.',
242
253
  Object.assign({ label: 'config:read' }, backend)
243
254
  )
244
255
  const cfg = (configRes && configRes.config) ? configRes.config : null
@@ -421,7 +432,7 @@ for (let r = startRound; r <= maxRounds; r++) {
421
432
  Object.assign({ label: 'checkpoint:save:r' + r }, backend)
422
433
  )
423
434
 
424
- if (atomic.length === 0 && remaining.length === 0) {
435
+ if (atomic.length === 0 && remaining.length === 0 && !schemaInvalid) {
425
436
  log('round ' + r + ' found nothing to fix — converged')
426
437
  converged = true
427
438
  break