iterate-plugin 3.3.1 → 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/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;
@@ -1525,6 +1561,7 @@ var ITERATE_CSS = `
1525
1561
  /* Dashboard empty/onboarding state */
1526
1562
  .iterate-dashboard-empty { opacity: 0.75; }
1527
1563
  .iterate-empty-hint { font-size: 12px; color: var(--dsw-alias-label-secondary); }
1564
+ .iterate-dashboard-launch { display: inline-flex; align-items: center; gap: 6px; }
1528
1565
 
1529
1566
  /* Convergence-completed progress fill */
1530
1567
  .iterate-progress-fill-done { background: var(--dsw-alias-state-success-primary); }
@@ -1748,6 +1785,31 @@ function TrendChart({ points }) {
1748
1785
  "aria-label": `\u5404\u8F6E\u53D1\u73B0\u6570\u91CF\u8D8B\u52BF\uFF1A${summary}`
1749
1786
  }, ...bars);
1750
1787
  }
1788
+ function StartIterationButton() {
1789
+ const [copied, setCopied] = React.useState(null);
1790
+ const copy = (key, text) => {
1791
+ copyText(text).then((ok) => {
1792
+ if (ok) {
1793
+ setCopied(key);
1794
+ setTimeout(() => setCopied((cur) => cur === key ? null : cur), 1600);
1795
+ }
1796
+ });
1797
+ };
1798
+ const btn = (key, label, command, primary, title) => React.createElement("button", {
1799
+ key,
1800
+ className: "iterate-cmd",
1801
+ "data-primary": primary ? "" : void 0,
1802
+ "data-copied": copied === key ? "" : void 0,
1803
+ onClick: () => copy(key, command),
1804
+ title
1805
+ }, copied === key ? "\u5DF2\u590D\u5236" : label);
1806
+ return React.createElement(
1807
+ "span",
1808
+ { className: "iterate-dashboard-launch" },
1809
+ btn("start-full", "\u5B8C\u6574\u8FED\u4EE3", "/iterate", true, "\u590D\u5236\u542F\u52A8\u547D\u4EE4\uFF1A\u5B8C\u6574\u300C\u5BA1\u67E5 \u2192 \u4FEE\u590D \u2192 \u9A8C\u8BC1\u300D\u95ED\u73AF"),
1810
+ btn("start-review", "\u4EC5\u8BC4\u5BA1", "/iterate review-only", false, "\u590D\u5236\u542F\u52A8\u547D\u4EE4\uFF1A\u53EA\u5BA1\u67E5\u4E0D\u4FEE\u6539\uFF08dry-run\uFF09")
1811
+ );
1812
+ }
1751
1813
  function ConvergenceDashboard(props) {
1752
1814
  const [pulseKey, setPulseKey] = React.useState(0);
1753
1815
  const session = props && props.session ? props.session : null;
@@ -1764,7 +1826,8 @@ function ConvergenceDashboard(props) {
1764
1826
  "div",
1765
1827
  { "data-iterate-root": "", "data-iterate": "dashboard", className: "iterate-dashboard iterate-dashboard-empty" },
1766
1828
  React.createElement("span", { className: "iterate-round-badge" }, "iterate"),
1767
- React.createElement("span", { className: "iterate-empty-hint" }, "\u8FD0\u884C\u4E00\u6B21\u8BC4\u5BA1\u540E\uFF0C\u8FD9\u91CC\u4F1A\u663E\u793A\u6536\u655B\u8FDB\u5EA6\u4E0E\u53D1\u73B0\u7EDF\u8BA1\u3002\u8BD5\u8BD5\u300Creview this project\u300D\u6216\u300C/iterate review-only\u300D")
1829
+ React.createElement("span", { className: "iterate-empty-hint" }, "\u8FD0\u884C\u4E00\u6B21\u8BC4\u5BA1\u540E\uFF0C\u8FD9\u91CC\u4F1A\u663E\u793A\u6536\u655B\u8FDB\u5EA6\u4E0E\u53D1\u73B0\u7EDF\u8BA1\u3002"),
1830
+ React.createElement(StartIterationButton, null)
1768
1831
  );
1769
1832
  }
1770
1833
  const round = getCurrentRound(report);
@@ -2051,7 +2114,7 @@ function TriagePanel(props) {
2051
2114
  const ignoredCount = ignored.length;
2052
2115
  const counts = countVerdicts(verdicts);
2053
2116
  const doApproveArchitecturalFix = () => {
2054
- 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";
2055
2118
  copyText(cmd).then((ok) => {
2056
2119
  if (ok) {
2057
2120
  setCmdCopied("approve-arch");
@@ -2069,7 +2132,7 @@ function TriagePanel(props) {
2069
2132
  });
2070
2133
  };
2071
2134
  const doRollbackToCheckpoint = () => {
2072
- 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";
2073
2136
  copyText(cmd).then((ok) => {
2074
2137
  if (ok) {
2075
2138
  setCmdCopied("rollback");
@@ -3060,7 +3123,7 @@ ${JSON.stringify({
3060
3123
  "data-primary": "",
3061
3124
  "data-copied": copiedKey === "cp-resume" ? "" : void 0,
3062
3125
  onClick: () => copyInstruction("cp-resume", resumeText),
3063
- title: "\u590D\u5236 iterate_checkpoint \u7EFC/\u6062\u590D\u6307\u4EE4\u6587\u672C"
3126
+ title: "\u590D\u5236 iterate_checkpoint resume \u6307\u4EE4\u6587\u672C\uFF08\u52A0\u8F7D\u65AD\u70B9\u5E76\u8BA1\u6570\u4E00\u6B21\u6062\u590D\uFF09"
3064
3127
  }, copiedKey === "cp-resume" ? "\u5DF2\u590D\u5236" : "\u590D\u5236\u6062\u590D\u6307\u4EE4")
3065
3128
  )
3066
3129
  );
@@ -3153,7 +3216,14 @@ ${JSON.stringify({ operation: "nudge", text: null }, null, 2)}
3153
3216
  const sorted = filtered.slice();
3154
3217
  const rows = sorted.map((t, i) => {
3155
3218
  const k = `f7-${i}`;
3156
- 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
+ }
3157
3227
  return React.createElement(
3158
3228
  "div",
3159
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.3.1",
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,
@@ -567,6 +566,7 @@ const ITERATE_CSS = `
567
566
  /* Dashboard empty/onboarding state */
568
567
  .iterate-dashboard-empty { opacity: 0.75; }
569
568
  .iterate-empty-hint { font-size: 12px; color: var(--dsw-alias-label-secondary); }
569
+ .iterate-dashboard-launch { display: inline-flex; align-items: center; gap: 6px; }
570
570
 
571
571
  /* Convergence-completed progress fill */
572
572
  .iterate-progress-fill-done { background: var(--dsw-alias-state-success-primary); }
@@ -834,6 +834,36 @@ function TrendChart({ points }: { points: Array<{ round: number; count: number }
834
834
  }, ...bars)
835
835
  }
836
836
 
837
+ /** Start-iteration launcher shown on the empty dashboard: copies a runnable
838
+ * command (the browser cannot invoke harness tools directly, so every action
839
+ * follows the panel's copy-to-command pattern). */
840
+ function StartIterationButton() {
841
+ const [copied, setCopied] = React.useState<string | null>(null)
842
+ const copy = (key: string, text: string) => {
843
+ copyText(text).then((ok) => {
844
+ if (ok) {
845
+ setCopied(key)
846
+ setTimeout(() => setCopied((cur) => (cur === key ? null : cur)), 1600)
847
+ }
848
+ })
849
+ }
850
+ const btn = (key: string, label: string, command: string, primary: boolean, title: string) =>
851
+ React.createElement('button', {
852
+ key,
853
+ className: 'iterate-cmd',
854
+ 'data-primary': primary ? '' : undefined,
855
+ 'data-copied': copied === key ? '' : undefined,
856
+ onClick: () => copy(key, command),
857
+ title,
858
+ }, copied === key ? '已复制' : label)
859
+ return React.createElement(
860
+ 'span',
861
+ { className: 'iterate-dashboard-launch' },
862
+ btn('start-full', '完整迭代', '/iterate', true, '复制启动命令:完整「审查 → 修复 → 验证」闭环'),
863
+ btn('start-review', '仅评审', '/iterate review-only', false, '复制启动命令:只审查不修改(dry-run)'),
864
+ )
865
+ }
866
+
837
867
  /** Dashboard: live convergence strip above the composer.
838
868
  *
839
869
  * The `conversation.input.dock` slot's owner share is `InputZone`, which
@@ -859,12 +889,15 @@ function ConvergenceDashboard(props: SlotProps) {
859
889
 
860
890
  if (!report) {
861
891
  // Empty/onboarding state: first-time users otherwise see nothing and have
862
- // no idea the plugin exists or how to start.
892
+ // no idea the plugin exists or how to start. Provide a one-click command
893
+ // that launches the loop (copied for paste — the browser cannot call
894
+ // harness tools directly).
863
895
  return React.createElement(
864
896
  'div',
865
897
  { 'data-iterate-root': '', 'data-iterate': 'dashboard', className: 'iterate-dashboard iterate-dashboard-empty' },
866
898
  React.createElement('span', { className: 'iterate-round-badge' }, 'iterate'),
867
- React.createElement('span', { className: 'iterate-empty-hint' }, '运行一次评审后,这里会显示收敛进度与发现统计。试试「review this project」或「/iterate review-only」'),
899
+ React.createElement('span', { className: 'iterate-empty-hint' }, '运行一次评审后,这里会显示收敛进度与发现统计。'),
900
+ React.createElement(StartIterationButton, null),
868
901
  )
869
902
  }
870
903
 
@@ -1192,7 +1225,10 @@ function TriagePanel(props: SlotProps) {
1192
1225
 
1193
1226
  // v3.0: Command handlers for native action buttons
1194
1227
  const doApproveArchitecturalFix = () => {
1195
- 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 阈值)'
1196
1232
  copyText(cmd).then((ok) => {
1197
1233
  if (ok) {
1198
1234
  setCmdCopied('approve-arch')
@@ -1212,7 +1248,10 @@ function TriagePanel(props: SlotProps) {
1212
1248
  }
1213
1249
 
1214
1250
  const doRollbackToCheckpoint = () => {
1215
- 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` 逐个撤销这些修复'
1216
1255
  copyText(cmd).then((ok) => {
1217
1256
  if (ok) {
1218
1257
  setCmdCopied('rollback')
@@ -2151,7 +2190,7 @@ function ObservatoryPanel(props: SlotProps) {
2151
2190
  React.createElement('button', {
2152
2191
  className: 'iterate-btn', 'data-primary': '', 'data-copied': copiedKey === 'cp-resume' ? '' : undefined,
2153
2192
  onClick: () => copyInstruction('cp-resume', resumeText),
2154
- title: '复制 iterate_checkpoint 综/恢复指令文本',
2193
+ title: '复制 iterate_checkpoint resume 指令文本(加载断点并计数一次恢复)',
2155
2194
  }, copiedKey === 'cp-resume' ? '已复制' : '复制恢复指令'),
2156
2195
  ),
2157
2196
  )
@@ -2233,7 +2272,10 @@ function ObservatoryPanel(props: SlotProps) {
2233
2272
  const sorted = filtered.slice()
2234
2273
  const rows = sorted.map((t, i) => {
2235
2274
  const k = `f7-${i}`
2236
- 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
+ }
2237
2279
  return React.createElement('div', { key: k, className: 'iterate-obs-row', style: { flexDirection: 'column', alignItems: 'flex-start' } },
2238
2280
  React.createElement('div', { className: 'iterate-obs-bar', style: { width: '100%' } },
2239
2281
  React.createElement('span', { className: 'iterate-obs-chip' }, String(t.type || '?')),
@@ -92,6 +92,28 @@ export function validateConfigUpdates(updates: Record<string, unknown>): string[
92
92
  errors.push('updates.validation.commands must be an object of command arrays')
93
93
  }
94
94
  }
95
+ if ('observatory' in updates) {
96
+ const o = updates.observatory as Record<string, unknown> | undefined
97
+ if (!o || typeof o !== 'object') {
98
+ errors.push('updates.observatory must be an object')
99
+ } else {
100
+ if (o.capture !== undefined && typeof o.capture !== 'boolean') {
101
+ errors.push('updates.observatory.capture must be a boolean')
102
+ }
103
+ // The approval policy is the AUTHORITATIVE human-consent seam for
104
+ // destructive iterate tools (session-hooks.ts reads it at call time).
105
+ // Letting the model flip it to `allow` via a config write would bypass
106
+ // the gate entirely, so model-driven approval changes are refused
107
+ // fail-closed — the value can only be set by editing the config file
108
+ // directly (a human action the approval seam can trust).
109
+ if ('approval' in o) {
110
+ errors.push(
111
+ 'updates.observatory.approval cannot be changed through iterate_config — ' +
112
+ 'edit iterate.config.yaml directly (the approval gate is human-controlled)',
113
+ )
114
+ }
115
+ }
116
+ }
95
117
  if ('personalization' in updates && (!updates.personalization || typeof updates.personalization !== 'object')) {
96
118
  errors.push('updates.personalization must be an object')
97
119
  }
@@ -109,6 +131,11 @@ export function applyConfigUpdates(
109
131
  const out: Record<string, unknown> = { ...base }
110
132
  for (const [key, value] of Object.entries(updates)) {
111
133
  if (value === undefined) continue
134
+ // Prototype-pollution guard (mirrors config-loader.mergeConfig): a
135
+ // caller-supplied `__proto__`/`constructor`/`prototype` key must never be
136
+ // plain-assigned — on a plain object `out['__proto__'] = value` would set
137
+ // the object's prototype instead of an own property.
138
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue
112
139
  const baseValue = out[key]
113
140
  if (
114
141
  baseValue &&
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 {
@@ -22,7 +22,7 @@
22
22
  */
23
23
 
24
24
  import { readdirSync } from 'node:fs'
25
- import { join } from 'node:path'
25
+ import { join, relative } from 'node:path'
26
26
 
27
27
  export interface CoverageResult {
28
28
  assigned: string[]
@@ -135,8 +135,15 @@ function collectFull(root: string): string[] {
135
135
  }
136
136
  if (!entry.isFile()) continue
137
137
  if (!sourceExt(entry.name)) continue
138
- const rel = abs.startsWith(root + SEP) ? abs.slice(root.length + 1) : abs
139
- out.push(rel.split(SEP).join(SEP))
138
+ // Derive the inventory path relative to the root via path.relative (then
139
+ // canonicalize separators). The old `abs.slice(root.length + 1)` string
140
+ // prefix was only correct on POSIX: on Windows path.join emits '\\' while
141
+ // this module's SEP is '/', so the prefix never matched and the ABSOLUTE
142
+ // path leaked into the inventory. path.relative is separator-agnostic and
143
+ // always yields a path strictly inside `root`.
144
+ const rel = normalizePath(relative(root, abs))
145
+ if (rel === '' || rel === '.' || rel.startsWith('../') || rel === '..') continue
146
+ out.push(rel)
140
147
  }
141
148
  }
142
149
  return out.sort()
package/src/review.ts CHANGED
@@ -247,14 +247,20 @@ export function buildReviewReport(input: {
247
247
  fixedCount?: number
248
248
  }): ReviewReport {
249
249
  // 1. Filter known-intentional per round (before cross-round dedupe).
250
- const filteredRounds = input.rounds.map((r) => ({
251
- round: typeof r?.round === 'number' ? r.round : 0,
252
- findings: filterKnownIntentional(
253
- Array.isArray(r?.findings) ? r.findings : [],
254
- input.knownIntentional,
255
- ),
256
- readFiles: Array.isArray(r?.readFiles) ? r.readFiles : [],
257
- }))
250
+ const filteredRounds = input.rounds
251
+ .map((r) => ({
252
+ round: typeof r?.round === 'number' ? r.round : 0,
253
+ findings: filterKnownIntentional(
254
+ Array.isArray(r?.findings) ? r.findings : [],
255
+ input.knownIntentional,
256
+ ),
257
+ readFiles: Array.isArray(r?.readFiles) ? r.readFiles : [],
258
+ }))
259
+ // Sort by round number so convergence math below (and the meta-review
260
+ // audit, which reads the LAST element as the highest round) never depends
261
+ // on the caller's array order. A resumed run that only passes [round 5]
262
+ // must be audited as round 5, not as "one round with no number".
263
+ .sort((a, b) => a.round - b.round)
258
264
 
259
265
  // 2. Cross-round dedupe + per-round "first seen" tracking.
260
266
  const { findings, findingsByRound } = aggregateRounds(
@@ -271,8 +277,10 @@ export function buildReviewReport(input: {
271
277
  // using its reported round number — NOT `filteredRounds.length - 1`, which
272
278
  // is only valid for contiguous 1..N round numbers (resumed iterations and
273
279
  // non-contiguous round sets would otherwise read the wrong count).
274
- const lastRound =
275
- filteredRounds.length > 0 ? filteredRounds[filteredRounds.length - 1]!.round : 0
280
+ let lastRound = 0
281
+ for (const r of filteredRounds) {
282
+ if (typeof r.round === 'number' && r.round > lastRound) lastRound = r.round
283
+ }
276
284
  const lastRoundCount =
277
285
  lastRound > 0 ? (findingsByRound[lastRound - 1] ?? 0) : 0
278
286
  const converged = filteredRounds.length > 0 && lastRoundCount === 0