iterate-plugin 3.5.3 → 3.5.5
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/dist/atomic-fs.js +22 -4
- package/dist/config-loader.js +8 -3
- package/dist/config-write.js +50 -9
- package/dist/evidence.js +28 -1
- package/dist/git-scope.js +14 -5
- package/dist/jobs.js +15 -1
- package/dist/live.js +39 -22
- package/dist/meta-review.js +2 -2
- package/dist/review-scope.js +6 -2
- package/dist/review.js +7 -6
- package/dist/session-hooks.js +48 -4
- package/dist/skill-prompt.js +66 -32
- package/dist/tools/context.js +5 -1
- package/dist/tools/decision-log.js +142 -17
- package/dist/tools/defense-events.js +7 -3
- package/dist/tools/defense-store.js +14 -7
- package/dist/tools/experience-bank.js +6 -3
- package/dist/tools/experience-store.js +24 -12
- package/dist/tools/fix.js +24 -3
- package/dist/tools/prune.js +185 -17
- package/dist/tools/review.js +17 -3
- package/dist/tools/transcript.js +28 -6
- package/dist/tools/triage.js +38 -4
- package/dist/transcript.js +68 -34
- package/lib/client.js +41 -11
- package/lib/parse.js +30 -2
- package/package.json +10 -7
- package/src/atomic-fs.ts +20 -4
- package/src/client/index.ts +23 -3
- package/src/config-loader.ts +8 -3
- package/src/config-write.ts +59 -9
- package/src/evidence.ts +29 -1
- package/src/git-scope.ts +14 -3
- package/src/jobs.ts +15 -1
- package/src/live.ts +41 -22
- package/src/meta-review.ts +2 -2
- package/src/review-scope.ts +6 -2
- package/src/review.ts +7 -6
- package/src/session-hooks.ts +50 -4
- package/src/skill-prompt.ts +66 -32
- package/src/tools/context.ts +5 -1
- package/src/tools/decision-log.ts +136 -16
- package/src/tools/defense-events.ts +7 -3
- package/src/tools/defense-store.ts +14 -7
- package/src/tools/experience-bank.ts +6 -3
- package/src/tools/experience-store.ts +24 -12
- package/src/tools/fix.ts +25 -3
- package/src/tools/prune.ts +194 -17
- package/src/tools/review.ts +16 -3
- package/src/tools/transcript.ts +26 -5
- package/src/tools/triage.ts +39 -4
- package/src/transcript.ts +68 -31
- package/src/types.ts +2 -0
package/lib/client.js
CHANGED
|
@@ -465,6 +465,10 @@ function normalizeTranscript(manifest) {
|
|
|
465
465
|
phases: asArray(safeGet(src, "phases")).map((p) => typeof p === "string" ? p : ""),
|
|
466
466
|
round: asNum(safeGet(src, "round")),
|
|
467
467
|
maxRounds: asNum(safeGet(src, "maxRounds")),
|
|
468
|
+
// v3.5: why a FINISHED run stopped (converged / max_rounds_reached /
|
|
469
|
+
// aborted_by_validation / aborted_by_config). Required for the
|
|
470
|
+
// observatory badge to render the stop reason instead of a bare "已结束".
|
|
471
|
+
stoppedReason: src.stoppedReason === null || src.stoppedReason === void 0 ? null : asStr(safeGet(src, "stoppedReason")) || null,
|
|
468
472
|
rounds,
|
|
469
473
|
convergence: asArray(safeGet(src, "convergence")).map((n) => asCount(n)),
|
|
470
474
|
findings: asArray(safeGet(src, "findings")).map((f) => ({ ...f })),
|
|
@@ -941,18 +945,25 @@ function computeConvergenceProgress(report) {
|
|
|
941
945
|
report.convergence ?? {}
|
|
942
946
|
);
|
|
943
947
|
const totalRounds = typeof convergence.totalRounds === "number" ? convergence.totalRounds : 1;
|
|
944
|
-
const currentRounds = (
|
|
945
|
-
/** @type {Array<unknown>} */
|
|
946
|
-
(report.rounds ?? []).length
|
|
947
|
-
);
|
|
948
|
+
const currentRounds = currentRoundNumber(report);
|
|
948
949
|
if (!(totalRounds > 0)) return 0;
|
|
949
950
|
return Math.min(100, Math.round(currentRounds / totalRounds * 100));
|
|
950
951
|
}
|
|
951
|
-
function
|
|
952
|
-
|
|
953
|
-
/** @type {Array<unknown
|
|
954
|
-
|
|
952
|
+
function currentRoundNumber(report) {
|
|
953
|
+
const rounds = (
|
|
954
|
+
/** @type {Array<Record<string, unknown>>} */
|
|
955
|
+
report.rounds ?? []
|
|
955
956
|
);
|
|
957
|
+
let max = 0;
|
|
958
|
+
for (const r of rounds) {
|
|
959
|
+
if (!r || typeof r !== "object") continue;
|
|
960
|
+
const n = Number(r.round);
|
|
961
|
+
if (Number.isFinite(n) && n > max) max = n;
|
|
962
|
+
}
|
|
963
|
+
return rounds.length > 0 && max === 0 ? rounds.length : max;
|
|
964
|
+
}
|
|
965
|
+
function getCurrentRound(report) {
|
|
966
|
+
return currentRoundNumber(report);
|
|
956
967
|
}
|
|
957
968
|
function getTotalRounds(report) {
|
|
958
969
|
const convergence = (
|
|
@@ -1713,6 +1724,25 @@ function severityColor(severity) {
|
|
|
1713
1724
|
function severityLabel(severity) {
|
|
1714
1725
|
return SEVERITY_LABEL[coerceSeverity(severity)];
|
|
1715
1726
|
}
|
|
1727
|
+
function stoppedReasonLabel(reason) {
|
|
1728
|
+
switch (reason) {
|
|
1729
|
+
case "converged":
|
|
1730
|
+
return "\u5DF2\u7ED3\u675F \xB7 \u5DF2\u6536\u655B\uFF08\u65E0\u65B0\u53D1\u73B0\uFF09";
|
|
1731
|
+
case "max_rounds_reached":
|
|
1732
|
+
return "\u5DF2\u7ED3\u675F \xB7 \u8FBE\u5230\u8F6E\u6570\u4E0A\u9650";
|
|
1733
|
+
case "aborted_by_validation":
|
|
1734
|
+
return "\u5DF2\u7ED3\u675F \xB7 \u9A8C\u8BC1\u5931\u8D25\u540E\u56DE\u6EDA\u505C\u6B62";
|
|
1735
|
+
case "aborted_by_config":
|
|
1736
|
+
return "\u5DF2\u7ED3\u675F \xB7 \u9A8C\u8BC1\u547D\u4EE4\u4E0D\u5728\u767D\u540D\u5355\uFF08\u914D\u7F6E\u9700\u4FEE\u590D\uFF09";
|
|
1737
|
+
default:
|
|
1738
|
+
return reason ? `\u5DF2\u7ED3\u675F \xB7 ${reason}` : "\u5DF2\u7ED3\u675F";
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
function runStatusText(live, manifest) {
|
|
1742
|
+
if (live) return "\u8FD0\u884C\u4E2D";
|
|
1743
|
+
const reason = manifest && typeof manifest.stoppedReason === "string" ? manifest.stoppedReason : "";
|
|
1744
|
+
return reason ? stoppedReasonLabel(reason) : "\u5DF2\u7ED3\u675F";
|
|
1745
|
+
}
|
|
1716
1746
|
function readSlots(ctx) {
|
|
1717
1747
|
if (ctx && ctx.slots && typeof ctx.slots.inject === "function" && typeof ctx.slots.register === "function") {
|
|
1718
1748
|
return ctx.slots;
|
|
@@ -1858,7 +1888,7 @@ function ConvergenceDashboard(props) {
|
|
|
1858
1888
|
key: "phase",
|
|
1859
1889
|
"data-live": transcript.active === true ? "" : void 0,
|
|
1860
1890
|
title: transcript.active === true ? `\u5F53\u524D\u5904\u4E8E\u300C${phase}\u300D\u9636\u6BB5\uFF08\u8FD0\u884C\u4E2D\uFF09` : `\u6700\u8FD1\u4E00\u6B21\u6267\u884C\u505C\u7559\u5728\u300C${phase}\u300D\u9636\u6BB5\uFF08\u5DF2\u7ED3\u675F\uFF09`
|
|
1861
|
-
}, `${phase} \xB7 ${transcript.active === true
|
|
1891
|
+
}, `${phase} \xB7 ${runStatusText(transcript.active === true, transcript)}`) : null;
|
|
1862
1892
|
const taskModeTranscript = latestTranscript(session);
|
|
1863
1893
|
const taskMode = taskModeTranscript?.taskMode;
|
|
1864
1894
|
const taskModeChip = taskMode ? React.createElement("span", {
|
|
@@ -3171,7 +3201,7 @@ ${JSON.stringify({ operation: "nudge", text: null }, null, 2)}
|
|
|
3171
3201
|
"div",
|
|
3172
3202
|
{ className: "iterate-obs-block-head" },
|
|
3173
3203
|
React.createElement("span", {}, "\u8FD0\u884C\u63A7\u5236\u53F0"),
|
|
3174
|
-
React.createElement("span", { className: "iterate-obs-badge", "data-live": live ? "" : void 0 }, live
|
|
3204
|
+
React.createElement("span", { className: "iterate-obs-badge", "data-live": live ? "" : void 0 }, runStatusText(live, manifest)),
|
|
3175
3205
|
React.createElement("span", { className: "iterate-obs-head-meta" }, `\u6279\u51C6\u7B56\u7565\uFF1A${policyLabel}`)
|
|
3176
3206
|
),
|
|
3177
3207
|
React.createElement(
|
|
@@ -3767,7 +3797,7 @@ ${JSON.stringify({ operation: "clear" }, null, 2)}
|
|
|
3767
3797
|
"div",
|
|
3768
3798
|
{ className: "iterate-obs-head", "data-closed": open ? void 0 : "", onClick: () => setOpen((v) => !v) },
|
|
3769
3799
|
React.createElement("span", { className: "iterate-obs-title" }, "iterate \u89C2\u6D4B\u53F0"),
|
|
3770
|
-
React.createElement("span", { className: "iterate-obs-badge", "data-live": live ? "" : void 0 }, live
|
|
3800
|
+
React.createElement("span", { className: "iterate-obs-badge", "data-live": live ? "" : void 0 }, runStatusText(live, manifest)),
|
|
3771
3801
|
React.createElement("span", { className: "iterate-obs-head-meta" }, headMeta || "runtime"),
|
|
3772
3802
|
React.createElement("button", {
|
|
3773
3803
|
className: "iterate-btn",
|
package/lib/parse.js
CHANGED
|
@@ -581,6 +581,12 @@ export function normalizeTranscript(manifest) {
|
|
|
581
581
|
phases: asArray(safeGet(src, 'phases')).map((p) => (typeof p === 'string' ? p : '')),
|
|
582
582
|
round: asNum(safeGet(src, 'round')),
|
|
583
583
|
maxRounds: asNum(safeGet(src, 'maxRounds')),
|
|
584
|
+
// v3.5: why a FINISHED run stopped (converged / max_rounds_reached /
|
|
585
|
+
// aborted_by_validation / aborted_by_config). Required for the
|
|
586
|
+
// observatory badge to render the stop reason instead of a bare "已结束".
|
|
587
|
+
stoppedReason: src.stoppedReason === null || src.stoppedReason === undefined
|
|
588
|
+
? null
|
|
589
|
+
: asStr(safeGet(src, 'stoppedReason')) || null,
|
|
584
590
|
rounds,
|
|
585
591
|
convergence: asArray(safeGet(src, 'convergence')).map((n) => asCount(n)),
|
|
586
592
|
findings: asArray(safeGet(src, 'findings')).map((f) => ({ ...f })),
|
|
@@ -1217,12 +1223,34 @@ export function computeConvergenceProgress(report) {
|
|
|
1217
1223
|
const totalRounds = typeof convergence.totalRounds === 'number'
|
|
1218
1224
|
? convergence.totalRounds
|
|
1219
1225
|
: 1
|
|
1220
|
-
const currentRounds =
|
|
1226
|
+
const currentRounds = currentRoundNumber(report)
|
|
1221
1227
|
// Guard against an empty report (totalRounds <= 0) producing NaN.
|
|
1222
1228
|
if (!(totalRounds > 0)) return 0
|
|
1223
1229
|
return Math.min(100, Math.round((currentRounds / totalRounds) * 100))
|
|
1224
1230
|
}
|
|
1225
1231
|
|
|
1232
|
+
/**
|
|
1233
|
+
* The actual current round (1-indexed) carried by a report. Uses the highest
|
|
1234
|
+
* per-round `round` field: a normal-mode aggregate ships ONLY the live round
|
|
1235
|
+
* (`rounds: [{round: r, ...}]`), so `rounds.length` would be stuck at 1 while
|
|
1236
|
+
* the run is on round 3. Dry-run aggregates ship the cumulative list, where
|
|
1237
|
+
* the round numbers and the array length agree anyway.
|
|
1238
|
+
*
|
|
1239
|
+
* @param {Record<string, unknown>} report
|
|
1240
|
+
* @returns {number}
|
|
1241
|
+
*/
|
|
1242
|
+
function currentRoundNumber(report) {
|
|
1243
|
+
const rounds = /** @type {Array<Record<string, unknown>>} */ (report.rounds ?? [])
|
|
1244
|
+
let max = 0
|
|
1245
|
+
for (const r of rounds) {
|
|
1246
|
+
if (!r || typeof r !== 'object') continue
|
|
1247
|
+
const n = Number(r.round)
|
|
1248
|
+
if (Number.isFinite(n) && n > max) max = n
|
|
1249
|
+
}
|
|
1250
|
+
// Fallback: no usable round numbers → array length (covers all-0 rounds).
|
|
1251
|
+
return rounds.length > 0 && max === 0 ? rounds.length : max
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1226
1254
|
/**
|
|
1227
1255
|
* Get the current round number (1-indexed) from a report.
|
|
1228
1256
|
*
|
|
@@ -1230,7 +1258,7 @@ export function computeConvergenceProgress(report) {
|
|
|
1230
1258
|
* @returns {number}
|
|
1231
1259
|
*/
|
|
1232
1260
|
export function getCurrentRound(report) {
|
|
1233
|
-
return (
|
|
1261
|
+
return currentRoundNumber(report)
|
|
1234
1262
|
}
|
|
1235
1263
|
|
|
1236
1264
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iterate-plugin",
|
|
3
|
-
"version": "3.5.
|
|
3
|
+
"version": "3.5.5",
|
|
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",
|
|
@@ -37,7 +37,9 @@
|
|
|
37
37
|
"0.1.5-alpha.1": "compatible",
|
|
38
38
|
"0.1.5-alpha.2": "compatible",
|
|
39
39
|
"0.1.5-rc.1": "compatible",
|
|
40
|
-
"0.1.5-rc.2": "compatible"
|
|
40
|
+
"0.1.5-rc.2": "compatible",
|
|
41
|
+
"0.1.6-alpha.1": "compatible",
|
|
42
|
+
"0.1.6-alpha.2": "compatible"
|
|
41
43
|
}
|
|
42
44
|
},
|
|
43
45
|
"client": {
|
|
@@ -75,13 +77,14 @@
|
|
|
75
77
|
},
|
|
76
78
|
"dependencies": {
|
|
77
79
|
"@deepseek-ai/cordis": "4.0.2",
|
|
78
|
-
"@deepseek-ai/dsh-tools": "0.1.
|
|
79
|
-
"@deepseek-ai/dsh-util-values": "0.1.
|
|
80
|
-
"js-yaml": "4.3.
|
|
80
|
+
"@deepseek-ai/dsh-tools": "0.1.6-alpha.2",
|
|
81
|
+
"@deepseek-ai/dsh-util-values": "0.1.6-alpha.2",
|
|
82
|
+
"js-yaml": "4.3.2"
|
|
81
83
|
},
|
|
82
84
|
"devDependencies": {
|
|
83
|
-
"@deepseek-ai/dsh-
|
|
84
|
-
"@deepseek-ai/dsh-
|
|
85
|
+
"@deepseek-ai/dsh-agent": "0.1.6-alpha.2",
|
|
86
|
+
"@deepseek-ai/dsh-jobs": "^0.1.6-alpha.2",
|
|
87
|
+
"@deepseek-ai/dsh-session": "0.1.6-alpha.2",
|
|
85
88
|
"@types/js-yaml": "4.0.9",
|
|
86
89
|
"@types/node": "22.15.0",
|
|
87
90
|
"@types/react": "19.2.2",
|
package/src/atomic-fs.ts
CHANGED
|
@@ -54,11 +54,19 @@ function tempPathFor(filePath: string): string {
|
|
|
54
54
|
*/
|
|
55
55
|
export function writeTextAtomic(filePath: string, data: string): void {
|
|
56
56
|
const tmp = tempPathFor(filePath)
|
|
57
|
-
|
|
57
|
+
try {
|
|
58
|
+
writeFileSync(tmp, data, 'utf-8')
|
|
59
|
+
} catch (err) {
|
|
60
|
+
// The write itself failed (disk full, permission, …): the temp file may
|
|
61
|
+
// already be partially written, so remove it before propagating — a
|
|
62
|
+
// failed write must never litter the directory either.
|
|
63
|
+
cleanupTemp(tmp)
|
|
64
|
+
throw err
|
|
65
|
+
}
|
|
58
66
|
try {
|
|
59
67
|
renameSync(tmp, filePath)
|
|
60
68
|
} catch (err) {
|
|
61
|
-
// Best-effort temp cleanup so a failed
|
|
69
|
+
// Best-effort temp cleanup so a failed rename never litters the directory.
|
|
62
70
|
cleanupTemp(tmp)
|
|
63
71
|
throw err
|
|
64
72
|
}
|
|
@@ -67,11 +75,19 @@ export function writeTextAtomic(filePath: string, data: string): void {
|
|
|
67
75
|
/** Async variant of {@link writeTextAtomic} (same temp naming convention). */
|
|
68
76
|
export async function writeTextAtomicAsync(filePath: string, data: string): Promise<void> {
|
|
69
77
|
const tmp = tempPathFor(filePath)
|
|
70
|
-
|
|
78
|
+
try {
|
|
79
|
+
await writeFile(tmp, data, 'utf-8')
|
|
80
|
+
} catch (err) {
|
|
81
|
+
// The write itself failed (disk full, permission, …): the temp file may
|
|
82
|
+
// already be partially written, so remove it before propagating — a
|
|
83
|
+
// failed write must never litter the directory either.
|
|
84
|
+
await cleanupTempAsync(tmp)
|
|
85
|
+
throw err
|
|
86
|
+
}
|
|
71
87
|
try {
|
|
72
88
|
await rename(tmp, filePath)
|
|
73
89
|
} catch (err) {
|
|
74
|
-
// Best-effort temp cleanup so a failed
|
|
90
|
+
// Best-effort temp cleanup so a failed rename never litters the directory.
|
|
75
91
|
await cleanupTempAsync(tmp)
|
|
76
92
|
throw err
|
|
77
93
|
}
|
package/src/client/index.ts
CHANGED
|
@@ -244,6 +244,8 @@ interface ObsManifest {
|
|
|
244
244
|
timeline?: ObsEntry[]
|
|
245
245
|
nudge?: ObsNudge | null
|
|
246
246
|
approval?: ObsApproval
|
|
247
|
+
/** Why the run ended: converged / max_rounds_reached / aborted_by_validation. */
|
|
248
|
+
stoppedReason?: string | null
|
|
247
249
|
}
|
|
248
250
|
|
|
249
251
|
// ─── Quality command center data types (v3.1+; mirrors src/types.ts) ────────
|
|
@@ -735,6 +737,24 @@ function severityLabel(severity: string | undefined): string {
|
|
|
735
737
|
return SEVERITY_LABEL[coerceSeverity(severity)]
|
|
736
738
|
}
|
|
737
739
|
|
|
740
|
+
/** Human label for WHY a finished run stopped (transcript manifest). */
|
|
741
|
+
function stoppedReasonLabel(reason: string | null | undefined): string {
|
|
742
|
+
switch (reason) {
|
|
743
|
+
case 'converged': return '已结束 · 已收敛(无新发现)'
|
|
744
|
+
case 'max_rounds_reached': return '已结束 · 达到轮数上限'
|
|
745
|
+
case 'aborted_by_validation': return '已结束 · 验证失败后回滚停止'
|
|
746
|
+
case 'aborted_by_config': return '已结束 · 验证命令不在白名单(配置需修复)'
|
|
747
|
+
default: return reason ? `已结束 · ${reason}` : '已结束'
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** Badge text for the run's live/finished state, annotated with the stop reason. */
|
|
752
|
+
function runStatusText(live: boolean, manifest: ObsManifest | null | undefined): string {
|
|
753
|
+
if (live) return '运行中'
|
|
754
|
+
const reason = manifest && typeof manifest.stoppedReason === 'string' ? manifest.stoppedReason : ''
|
|
755
|
+
return reason ? stoppedReasonLabel(reason) : '已结束'
|
|
756
|
+
}
|
|
757
|
+
|
|
738
758
|
/** Resolve the slots service: prefer the typed `ctx.slots`, fall back to `ctx.get`. */
|
|
739
759
|
function readSlots(ctx: ClientContext): SlotsService | undefined {
|
|
740
760
|
if (ctx && ctx.slots && typeof ctx.slots.inject === 'function' && typeof ctx.slots.register === 'function') {
|
|
@@ -940,7 +960,7 @@ function ConvergenceDashboard(props: SlotProps) {
|
|
|
940
960
|
title: transcript.active === true
|
|
941
961
|
? `当前处于「${phase}」阶段(运行中)`
|
|
942
962
|
: `最近一次执行停留在「${phase}」阶段(已结束)`,
|
|
943
|
-
}, `${phase} · ${transcript.active === true
|
|
963
|
+
}, `${phase} · ${runStatusText(transcript.active === true, transcript)}`)
|
|
944
964
|
: null
|
|
945
965
|
|
|
946
966
|
// v3.0: task_mode indicator (code/iterate)
|
|
@@ -2224,7 +2244,7 @@ function ObservatoryPanel(props: SlotProps) {
|
|
|
2224
2244
|
return React.createElement('div', { className: 'iterate-obs-block' },
|
|
2225
2245
|
React.createElement('div', { className: 'iterate-obs-block-head' },
|
|
2226
2246
|
React.createElement('span', {}, '运行控制台'),
|
|
2227
|
-
React.createElement('span', { className: 'iterate-obs-badge', 'data-live': live ? '' : undefined }, live
|
|
2247
|
+
React.createElement('span', { className: 'iterate-obs-badge', 'data-live': live ? '' : undefined }, runStatusText(live, manifest)),
|
|
2228
2248
|
React.createElement('span', { className: 'iterate-obs-head-meta' }, `批准策略:${policyLabel}`),
|
|
2229
2249
|
),
|
|
2230
2250
|
React.createElement('div', { className: 'iterate-obs-block-body' },
|
|
@@ -2731,7 +2751,7 @@ function ObservatoryPanel(props: SlotProps) {
|
|
|
2731
2751
|
return React.createElement('div', { 'data-iterate-root': '', 'data-iterate': 'obs', className: 'iterate-obs' },
|
|
2732
2752
|
React.createElement('div', { className: 'iterate-obs-head', 'data-closed': open ? undefined : '', onClick: () => setOpen((v) => !v) },
|
|
2733
2753
|
React.createElement('span', { className: 'iterate-obs-title' }, 'iterate 观测台'),
|
|
2734
|
-
React.createElement('span', { className: 'iterate-obs-badge', 'data-live': live ? '' : undefined }, live
|
|
2754
|
+
React.createElement('span', { className: 'iterate-obs-badge', 'data-live': live ? '' : undefined }, runStatusText(live, manifest)),
|
|
2735
2755
|
React.createElement('span', { className: 'iterate-obs-head-meta' }, headMeta || 'runtime'),
|
|
2736
2756
|
React.createElement('button', {
|
|
2737
2757
|
className: 'iterate-btn', 'data-ghost': '',
|
package/src/config-loader.ts
CHANGED
|
@@ -12,7 +12,10 @@ export function loadConfig(projectRoot: string): IterateConfig | null {
|
|
|
12
12
|
try {
|
|
13
13
|
const content = readFileSync(join(projectRoot, 'iterate.config.yaml'), 'utf-8')
|
|
14
14
|
const parsed = yaml.load(content) as Record<string, unknown>
|
|
15
|
-
|
|
15
|
+
// A YAML sequence root (e.g. a list of findings or a `- foo` file) must
|
|
16
|
+
// not masquerade as a config object — treating it as one would merge its
|
|
17
|
+
// indices into the config (config bomb). Reject it, not coerce it.
|
|
18
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
|
|
16
19
|
return parsed as unknown as IterateConfig
|
|
17
20
|
} catch {
|
|
18
21
|
return null
|
|
@@ -158,7 +161,7 @@ export function flattenCommands(
|
|
|
158
161
|
*/
|
|
159
162
|
export function validateConfig(config: unknown): string[] {
|
|
160
163
|
const errors: string[] = []
|
|
161
|
-
if (!config || typeof config !== 'object') {
|
|
164
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
162
165
|
errors.push('root')
|
|
163
166
|
return errors
|
|
164
167
|
}
|
|
@@ -206,7 +209,9 @@ export type ProjectRootResult = { ok: true; root: string } | { ok: false; reason
|
|
|
206
209
|
* the DSH runtime injects per-session env into tool sub-processes).
|
|
207
210
|
*/
|
|
208
211
|
export function resolveProjectRoot(input?: string, sessionCwd?: string): ProjectRootResult {
|
|
209
|
-
|
|
212
|
+
// A non-string input (number, array, …) from a hostile tool invocation must
|
|
213
|
+
// be treated as "no explicit path" instead of crashing on `.trim()`.
|
|
214
|
+
const raw = (typeof input === 'string' ? input : '').trim()
|
|
210
215
|
// A NUL byte can never name a real path and makes `resolve()` (and every
|
|
211
216
|
// downstream fs call) throw — treat it as unsafe input, not a throw path.
|
|
212
217
|
if (raw.includes('\0')) {
|
package/src/config-write.ts
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* config, always back up before writing, roll back on failure.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { copyFileSync, existsSync, readFileSync, rmSync } from 'node:fs'
|
|
14
|
-
import { join } from 'node:path'
|
|
13
|
+
import { copyFileSync, existsSync, readFileSync, readdirSync, rmSync } from 'node:fs'
|
|
14
|
+
import { basename, dirname, join } from 'node:path'
|
|
15
15
|
import yaml from 'js-yaml'
|
|
16
16
|
import { writeTextAtomic } from './atomic-fs.ts'
|
|
17
17
|
|
|
@@ -23,6 +23,46 @@ export function configBackupSuffix(now = new Date()): string {
|
|
|
23
23
|
return now.toISOString().replace(/[:.]/g, '-')
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/** Upper bound on configurable iteration rounds (config bomb guard). */
|
|
27
|
+
export const MAX_MAX_ROUNDS = 100
|
|
28
|
+
|
|
29
|
+
/** Upper bound on `atomic.max_lines` (a single fix never needs more). */
|
|
30
|
+
export const MAX_ATOMIC_MAX_LINES = 10_000
|
|
31
|
+
|
|
32
|
+
/** Upper bound on `atomic.max_adjacent_methods`. */
|
|
33
|
+
export const MAX_MAX_ADJACENT_METHODS = 200
|
|
34
|
+
|
|
35
|
+
/** Keep at most this many timestamped config backups (older ones are removed). */
|
|
36
|
+
export const MAX_CONFIG_BACKUPS = 5
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Bound the timestamped config backups: after a fresh one is written, delete
|
|
40
|
+
* every older `config.bak-*` file beyond the newest `keep`. Best-effort — a
|
|
41
|
+
* filesystem failure here must never fail the write that just succeeded.
|
|
42
|
+
* @returns the absolute paths of the backups that were removed.
|
|
43
|
+
*/
|
|
44
|
+
export function pruneOldConfigBackups(
|
|
45
|
+
configPath: string,
|
|
46
|
+
keep = MAX_CONFIG_BACKUPS,
|
|
47
|
+
): string[] {
|
|
48
|
+
const removed: string[] = []
|
|
49
|
+
try {
|
|
50
|
+
const dir = dirname(configPath)
|
|
51
|
+
const prefix = `${basename(configPath)}.bak-`
|
|
52
|
+
const matches = existsSync(dir)
|
|
53
|
+
? readdirSync(dir).filter((f) => f.startsWith(prefix)).sort()
|
|
54
|
+
: []
|
|
55
|
+
const doomed = matches.slice(0, Math.max(0, matches.length - keep))
|
|
56
|
+
for (const f of doomed) {
|
|
57
|
+
rmSync(join(dir, f), { force: true })
|
|
58
|
+
removed.push(join(dir, f))
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
// Best-effort cleanup — never surface a cleanup failure.
|
|
62
|
+
}
|
|
63
|
+
return removed
|
|
64
|
+
}
|
|
65
|
+
|
|
26
66
|
/**
|
|
27
67
|
* Validate a partial config update.
|
|
28
68
|
* Returns an array of error strings (empty when the update is valid).
|
|
@@ -45,8 +85,11 @@ export function validateConfigUpdates(updates: Record<string, unknown>): string[
|
|
|
45
85
|
}
|
|
46
86
|
}
|
|
47
87
|
if ('max_rounds' in updates) {
|
|
48
|
-
if (
|
|
49
|
-
|
|
88
|
+
if (
|
|
89
|
+
typeof updates.max_rounds !== 'number' || !Number.isInteger(updates.max_rounds) ||
|
|
90
|
+
updates.max_rounds < 1 || updates.max_rounds > MAX_MAX_ROUNDS
|
|
91
|
+
) {
|
|
92
|
+
errors.push(`updates.max_rounds must be an integer between 1 and ${MAX_MAX_ROUNDS}`)
|
|
50
93
|
}
|
|
51
94
|
}
|
|
52
95
|
if ('review' in updates) {
|
|
@@ -62,11 +105,11 @@ export function validateConfigUpdates(updates: Record<string, unknown>): string[
|
|
|
62
105
|
if (!a || typeof a !== 'object') {
|
|
63
106
|
errors.push('updates.atomic must be an object')
|
|
64
107
|
} else {
|
|
65
|
-
if (a.max_lines !== undefined && (typeof a.max_lines !== 'number' || !Number.isInteger(a.max_lines) || a.max_lines < 1)) {
|
|
66
|
-
errors.push(
|
|
108
|
+
if (a.max_lines !== undefined && (typeof a.max_lines !== 'number' || !Number.isInteger(a.max_lines) || a.max_lines < 1 || a.max_lines > MAX_ATOMIC_MAX_LINES)) {
|
|
109
|
+
errors.push(`updates.atomic.max_lines must be an integer between 1 and ${MAX_ATOMIC_MAX_LINES}`)
|
|
67
110
|
}
|
|
68
|
-
if (a.max_adjacent_methods !== undefined && (typeof a.max_adjacent_methods !== 'number' || a.max_adjacent_methods < 0)) {
|
|
69
|
-
errors.push(
|
|
111
|
+
if (a.max_adjacent_methods !== undefined && (typeof a.max_adjacent_methods !== 'number' || a.max_adjacent_methods < 0 || a.max_adjacent_methods > MAX_MAX_ADJACENT_METHODS)) {
|
|
112
|
+
errors.push(`updates.atomic.max_adjacent_methods must be a number between 0 and ${MAX_MAX_ADJACENT_METHODS}`)
|
|
70
113
|
}
|
|
71
114
|
}
|
|
72
115
|
}
|
|
@@ -168,7 +211,10 @@ export function readRawConfig(configPath: string): Record<string, unknown> {
|
|
|
168
211
|
} catch {
|
|
169
212
|
throw new Error('existing iterate.config.yaml is not a valid YAML mapping')
|
|
170
213
|
}
|
|
171
|
-
|
|
214
|
+
// A YAML sequence root (array) must not masquerade as a config object —
|
|
215
|
+
// `typeof [] === 'object'`, so the presence check alone would accept a
|
|
216
|
+
// config file that is actually a list. Writing over it would destroy data.
|
|
217
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
172
218
|
throw new Error('existing iterate.config.yaml is not a valid YAML mapping')
|
|
173
219
|
}
|
|
174
220
|
return parsed as Record<string, unknown>
|
|
@@ -207,5 +253,9 @@ export function writeConfigFile(
|
|
|
207
253
|
return { ok: false, error: `failed to write config: ${String(err)}${rollbackError}` }
|
|
208
254
|
}
|
|
209
255
|
|
|
256
|
+
// Success: bound the accumulation of timestamped backups so a long-lived
|
|
257
|
+
// project never collects an unbounded pile of config snapshots.
|
|
258
|
+
if (backupPath) pruneOldConfigBackups(configPath)
|
|
259
|
+
|
|
210
260
|
return { ok: true, backupPath }
|
|
211
261
|
}
|
package/src/evidence.ts
CHANGED
|
@@ -125,8 +125,36 @@ export function verifyFinding(
|
|
|
125
125
|
input: Locatable,
|
|
126
126
|
opts: { readSet?: Set<string> } = {},
|
|
127
127
|
): FindingEvidence {
|
|
128
|
-
|
|
128
|
+
// A null element / non-object finding (hostile or model-authored JSON) must
|
|
129
|
+
// be reported as poisoned evidence, never crash `path.resolve` downstream.
|
|
130
|
+
if (!input || typeof input !== 'object') {
|
|
131
|
+
return {
|
|
132
|
+
file: '',
|
|
133
|
+
line: null,
|
|
134
|
+
lineTotal: null,
|
|
135
|
+
resolvedPath: null,
|
|
136
|
+
verified: false,
|
|
137
|
+
error: 'file_not_found',
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// A non-string `file` (number, object, …) would make `resolve` throw
|
|
141
|
+
// ERR_INVALID_ARG_TYPE — coerce to "" so it fails closed as not-found.
|
|
142
|
+
const relFile = typeof input.file === 'string' ? input.file : ''
|
|
129
143
|
const line = typeof input.line === 'number' ? input.line : null
|
|
144
|
+
|
|
145
|
+
// "" resolves to the project ROOT (resolve(root, '') === root), which then
|
|
146
|
+
// trips the not-a-regular-file branch as `line_out_of_range` — but there is
|
|
147
|
+
// no file reference at all. Report it as `file_not_found` upfront.
|
|
148
|
+
if (relFile === '') {
|
|
149
|
+
return {
|
|
150
|
+
file: '',
|
|
151
|
+
line,
|
|
152
|
+
lineTotal: null,
|
|
153
|
+
resolvedPath: null,
|
|
154
|
+
verified: false,
|
|
155
|
+
error: 'file_not_found',
|
|
156
|
+
}
|
|
157
|
+
}
|
|
130
158
|
const resolved = resolveWithin(root, relFile)
|
|
131
159
|
|
|
132
160
|
if (resolved === null || !existsSync(resolved)) {
|
package/src/git-scope.ts
CHANGED
|
@@ -90,7 +90,10 @@ function decodeQuotedPath(content: string): string {
|
|
|
90
90
|
|
|
91
91
|
export function parseChangedFiles(stdout: string): string[] {
|
|
92
92
|
if (stdout.includes('\0')) {
|
|
93
|
-
|
|
93
|
+
// NUL mode: git emits exact names with no surrounding whitespace, so any
|
|
94
|
+
// trim would CORRUPT a filename that legitimately starts/ends with spaces
|
|
95
|
+
// (git supports such names). Split and drop only the trailing empty entry.
|
|
96
|
+
return stdout.split('\0').filter((s) => s.length > 0)
|
|
94
97
|
}
|
|
95
98
|
return stdout
|
|
96
99
|
.split('\n')
|
|
@@ -152,12 +155,19 @@ export function decideScope(changedFiles: string[]): {
|
|
|
152
155
|
export function runGit(
|
|
153
156
|
args: string[],
|
|
154
157
|
cwd: string,
|
|
158
|
+
opts: { signal?: AbortSignal } = {},
|
|
155
159
|
): Promise<{ ok: boolean; stdout: string; stderr: string; exitCode: number }> {
|
|
156
160
|
return new Promise((resolve) => {
|
|
157
161
|
execFile(
|
|
158
162
|
'git',
|
|
159
163
|
args,
|
|
160
|
-
{
|
|
164
|
+
{
|
|
165
|
+
cwd,
|
|
166
|
+
timeout: 30_000,
|
|
167
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
168
|
+
env: { ...process.env, PAGER: 'cat' },
|
|
169
|
+
signal: opts.signal,
|
|
170
|
+
},
|
|
161
171
|
(error, stdout, stderr) => {
|
|
162
172
|
const exitCode = error ? (typeof error.code === 'number' ? error.code : 1) : 0
|
|
163
173
|
resolve({ ok: exitCode === 0, stdout: stdout ?? '', stderr: stderr ?? '', exitCode })
|
|
@@ -175,6 +185,7 @@ export function runGit(
|
|
|
175
185
|
export async function resolveChangedFiles(
|
|
176
186
|
root: string,
|
|
177
187
|
targetBranch: string,
|
|
188
|
+
opts: { signal?: AbortSignal } = {},
|
|
178
189
|
): Promise<GitScopeResult> {
|
|
179
190
|
// Option-injection guard: a branch name starting with '-' would be parsed by
|
|
180
191
|
// git as an option (e.g. --output=...), not a ref. Reject it outright.
|
|
@@ -188,7 +199,7 @@ export async function resolveChangedFiles(
|
|
|
188
199
|
}
|
|
189
200
|
// -z: NUL-delimited names — machine-safe for any filename (spaces, quotes,
|
|
190
201
|
// non-ASCII), and never confused with option-like content.
|
|
191
|
-
const { ok, stdout, stderr } = await runGit(['diff', '--name-only', '-z', targetBranch, '--'], root)
|
|
202
|
+
const { ok, stdout, stderr } = await runGit(['diff', '--name-only', '-z', targetBranch, '--'], root, opts)
|
|
192
203
|
if (!ok) {
|
|
193
204
|
const reason = stderr.trim() || `git diff --name-only -z ${targetBranch} failed`
|
|
194
205
|
return { scope: 'full', changedFiles: [], fallbackToFull: true, error: reason }
|
package/src/jobs.ts
CHANGED
|
@@ -39,6 +39,20 @@ interface JobsLike {
|
|
|
39
39
|
}): string
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Read `ctx.jobs` defensively: the dsh plugin context can be a Proxy whose
|
|
44
|
+
* `jobs` getter throws (or is missing) when no job registry is present. A
|
|
45
|
+
* Proxy throw on property access must degrade to "no jobs", never crash the
|
|
46
|
+
* tool execution we are about to wrap.
|
|
47
|
+
*/
|
|
48
|
+
function safeJobs(ctx: unknown): JobsLike | undefined {
|
|
49
|
+
try {
|
|
50
|
+
return (ctx as { jobs?: JobsLike } | undefined)?.jobs
|
|
51
|
+
} catch {
|
|
52
|
+
return undefined
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
42
56
|
/**
|
|
43
57
|
* Run `fn` wrapped in a dsh background job, settling it completed/failed
|
|
44
58
|
* with the execution's outcome. When the host exposes no job registry (or
|
|
@@ -57,7 +71,7 @@ export async function runWithJob<T>(
|
|
|
57
71
|
label: string,
|
|
58
72
|
fn: () => Promise<T> | T,
|
|
59
73
|
): Promise<{ result: T; jobId: string | null }> {
|
|
60
|
-
const jobs = (ctx
|
|
74
|
+
const jobs = safeJobs(ctx)
|
|
61
75
|
if (!jobs || typeof jobs.start !== 'function') {
|
|
62
76
|
return { result: await fn(), jobId: null }
|
|
63
77
|
}
|
package/src/live.ts
CHANGED
|
@@ -68,6 +68,47 @@ export function liveFilePath(projectRoot: string): string {
|
|
|
68
68
|
return join(projectRoot, '.iterate', 'transcript-live.ndjson')
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Module-level in-process queue serializing live-feed writes. The trim path
|
|
73
|
+
* (read → rewrite → append) is a read-modify-write: two concurrent tool
|
|
74
|
+
* results could interleave and one's rewrite would silently drop the other's
|
|
75
|
+
* freshly-appended line. Chaining every append onto one tail makes the whole
|
|
76
|
+
* read+rewrite+append atomic within the process (the only realistic mode for
|
|
77
|
+
* the plugin); cross-process racing remains a theoretical hazard, but the
|
|
78
|
+
* cap-trim makes a lost line a cosmetic issue, not a correctness one.
|
|
79
|
+
*/
|
|
80
|
+
let liveWriteQueue: Promise<void> = Promise.resolve()
|
|
81
|
+
|
|
82
|
+
function enqueueLiveWrite(task: () => Promise<void>): Promise<void> {
|
|
83
|
+
const next = liveWriteQueue.then(task, task)
|
|
84
|
+
liveWriteQueue = next.catch(() => {})
|
|
85
|
+
return next
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Append one activity record to the project's live feed (byte-capped). */
|
|
89
|
+
export function appendLive(projectRoot: string, entry: LiveActivityEntry): Promise<void> {
|
|
90
|
+
return enqueueLiveWrite(async () => {
|
|
91
|
+
const file = liveFilePath(projectRoot)
|
|
92
|
+
const line = JSON.stringify(entry) + '\n'
|
|
93
|
+
await mkdir(join(projectRoot, '.iterate'), { recursive: true })
|
|
94
|
+
// Amortized O(1): only read+rewrite when the file has grown past the cap.
|
|
95
|
+
try {
|
|
96
|
+
const st = await stat(file).catch(() => null)
|
|
97
|
+
if (st && st.size > LIVE_MAX_BYTES) {
|
|
98
|
+
const raw = await readFile(file, 'utf-8')
|
|
99
|
+
const lines = raw.split('\n').filter(Boolean)
|
|
100
|
+
const tail = lines.slice(-LIVE_MAX_ENTRIES)
|
|
101
|
+
// Atomic rewrite (unique temp + rename) so a crash mid-trim never
|
|
102
|
+
// truncates the live feed; the temp file is prunable by iterate_prune.
|
|
103
|
+
await writeTextAtomicAsync(file, tail.join('\n') + '\n')
|
|
104
|
+
}
|
|
105
|
+
await appendFile(file, line, 'utf-8')
|
|
106
|
+
} catch {
|
|
107
|
+
// Fire-and-forget: never let live capture break a tool call.
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
71
112
|
/** Resolve the project root a tool execution belongs to, if any. */
|
|
72
113
|
function projectRootOf(exec: ToolExecution): string | null {
|
|
73
114
|
const cwd = exec.agent?.session?.header?.cwd
|
|
@@ -130,28 +171,6 @@ export function classifyTool(
|
|
|
130
171
|
return { ts: new Date().toISOString(), type, tool: name, target }
|
|
131
172
|
}
|
|
132
173
|
|
|
133
|
-
/** Append one activity record to the project's live feed (byte-capped). */
|
|
134
|
-
export async function appendLive(projectRoot: string, entry: LiveActivityEntry): Promise<void> {
|
|
135
|
-
const file = liveFilePath(projectRoot)
|
|
136
|
-
const line = JSON.stringify(entry) + '\n'
|
|
137
|
-
await mkdir(join(projectRoot, '.iterate'), { recursive: true })
|
|
138
|
-
// Amortized O(1): only read+rewrite when the file has grown past the cap.
|
|
139
|
-
try {
|
|
140
|
-
const st = await stat(file).catch(() => null)
|
|
141
|
-
if (st && st.size > LIVE_MAX_BYTES) {
|
|
142
|
-
const raw = await readFile(file, 'utf-8')
|
|
143
|
-
const lines = raw.split('\n').filter(Boolean)
|
|
144
|
-
const tail = lines.slice(-LIVE_MAX_ENTRIES)
|
|
145
|
-
// Atomic rewrite (unique temp + rename) so a crash mid-trim never
|
|
146
|
-
// truncates the live feed; the temp file is prunable by iterate_prune.
|
|
147
|
-
await writeTextAtomicAsync(file, tail.join('\n') + '\n')
|
|
148
|
-
}
|
|
149
|
-
await appendFile(file, line, 'utf-8')
|
|
150
|
-
} catch {
|
|
151
|
-
// Fire-and-forget: never let live capture break a tool call.
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
174
|
/** Read the live feed (newest first), capped at the last LIVE_MAX_ENTRIES. */
|
|
156
175
|
export async function readLive(projectRoot: string): Promise<LiveActivityEntry[]> {
|
|
157
176
|
const file = liveFilePath(projectRoot)
|
package/src/meta-review.ts
CHANGED
|
@@ -184,7 +184,7 @@ export function metaReviewReport(report: ReviewReport): MetaReviewResult {
|
|
|
184
184
|
`byDimension sums to ${dimSum}, but totalFindings is ${total}.`,
|
|
185
185
|
)
|
|
186
186
|
}
|
|
187
|
-
const invalidDim = findings.find((f) => !dimensions.includes(f?.dimension))
|
|
187
|
+
const invalidDim = findings.find((f) => !!f && !dimensions.includes(f?.dimension))
|
|
188
188
|
if (invalidDim) {
|
|
189
189
|
add(
|
|
190
190
|
'DIMENSION_UNKNOWN',
|
|
@@ -364,7 +364,7 @@ export function buildFinalReviewReport(
|
|
|
364
364
|
// surfaced it (best-effort; report rounds carry it).
|
|
365
365
|
for (const r of report.rounds ?? []) {
|
|
366
366
|
const matched = (r.findings ?? []).some(
|
|
367
|
-
(fnd) => fnd
|
|
367
|
+
(fnd) => !!fnd && fnd?.file === violation.file && fnd?.line === violation.line,
|
|
368
368
|
)
|
|
369
369
|
if (matched) {
|
|
370
370
|
roundHint = ` (round ${r.round})`
|