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 +22 -7
- package/README.zh-CN.md +17 -7
- package/dist/git-scope.js +3 -2
- package/dist/skill-prompt.js +16 -5
- package/lib/client.js +52 -9
- package/lib/parse.js +53 -6
- package/package.json +4 -2
- package/src/client/index.ts +12 -4
- package/src/git-scope.ts +3 -2
- package/src/skill-prompt.ts +16 -5
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`,
|
|
296
|
-
against `@deepseek-ai/dsh-tools` /
|
|
297
|
-
and uses only public contracts
|
|
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
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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.
|
|
294
|
-
`0.1.2-alpha.5`、`0.1.2-rc.1`
|
|
295
|
-
`@deepseek-ai/dsh-tools` / `@deepseek-ai/dsh-util-values`
|
|
296
|
-
|
|
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)** —
|
|
308
|
-
|
|
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(
|
|
120
|
+
if (!candidate.startsWith(rootPrefix) && candidate !== root)
|
|
120
121
|
continue;
|
|
121
122
|
try {
|
|
122
123
|
if (existsSync(candidate) && statSync(candidate).isFile())
|
package/dist/skill-prompt.js
CHANGED
|
@@ -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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
|
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 `
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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,
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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/skill-prompt.ts
CHANGED
|
@@ -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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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({
|
|
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
|