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/README.md +24 -9
- package/README.zh-CN.md +19 -9
- package/dist/config-write.js +27 -0
- package/dist/git-scope.js +3 -2
- package/dist/review-scope.js +11 -3
- package/dist/review.js +13 -3
- package/dist/skill-prompt.js +16 -5
- package/dist/tools/checkpoint.js +63 -5
- package/dist/tools/defense-events.js +3 -0
- package/dist/tools/defense-store.js +17 -3
- package/dist/tools/history.js +1 -1
- package/dist/tools/quality-store.js +44 -2
- package/dist/transcript.js +6 -0
- package/lib/client.js +81 -11
- package/lib/parse.js +53 -6
- package/package.json +4 -2
- package/src/client/index.ts +49 -7
- package/src/config-write.ts +27 -0
- package/src/git-scope.ts +3 -2
- package/src/review-scope.ts +10 -3
- package/src/review.ts +18 -10
- package/src/skill-prompt.ts +16 -5
- package/src/tools/checkpoint.ts +71 -6
- package/src/tools/defense-events.ts +4 -0
- package/src/tools/defense-store.ts +21 -4
- package/src/tools/history.ts +1 -1
- package/src/tools/quality-store.ts +48 -3
- package/src/transcript.ts +7 -0
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
|
-
|
|
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
|
|
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
|
|
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 `
|
|
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 \
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
"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": {
|
package/src/client/index.ts
CHANGED
|
@@ -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' }, '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 || '?')),
|
package/src/config-write.ts
CHANGED
|
@@ -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(
|
|
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 {
|
package/src/review-scope.ts
CHANGED
|
@@ -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
|
-
|
|
139
|
-
|
|
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
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
275
|
-
|
|
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
|