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.
Files changed (53) hide show
  1. package/dist/atomic-fs.js +22 -4
  2. package/dist/config-loader.js +8 -3
  3. package/dist/config-write.js +50 -9
  4. package/dist/evidence.js +28 -1
  5. package/dist/git-scope.js +14 -5
  6. package/dist/jobs.js +15 -1
  7. package/dist/live.js +39 -22
  8. package/dist/meta-review.js +2 -2
  9. package/dist/review-scope.js +6 -2
  10. package/dist/review.js +7 -6
  11. package/dist/session-hooks.js +48 -4
  12. package/dist/skill-prompt.js +66 -32
  13. package/dist/tools/context.js +5 -1
  14. package/dist/tools/decision-log.js +142 -17
  15. package/dist/tools/defense-events.js +7 -3
  16. package/dist/tools/defense-store.js +14 -7
  17. package/dist/tools/experience-bank.js +6 -3
  18. package/dist/tools/experience-store.js +24 -12
  19. package/dist/tools/fix.js +24 -3
  20. package/dist/tools/prune.js +185 -17
  21. package/dist/tools/review.js +17 -3
  22. package/dist/tools/transcript.js +28 -6
  23. package/dist/tools/triage.js +38 -4
  24. package/dist/transcript.js +68 -34
  25. package/lib/client.js +41 -11
  26. package/lib/parse.js +30 -2
  27. package/package.json +10 -7
  28. package/src/atomic-fs.ts +20 -4
  29. package/src/client/index.ts +23 -3
  30. package/src/config-loader.ts +8 -3
  31. package/src/config-write.ts +59 -9
  32. package/src/evidence.ts +29 -1
  33. package/src/git-scope.ts +14 -3
  34. package/src/jobs.ts +15 -1
  35. package/src/live.ts +41 -22
  36. package/src/meta-review.ts +2 -2
  37. package/src/review-scope.ts +6 -2
  38. package/src/review.ts +7 -6
  39. package/src/session-hooks.ts +50 -4
  40. package/src/skill-prompt.ts +66 -32
  41. package/src/tools/context.ts +5 -1
  42. package/src/tools/decision-log.ts +136 -16
  43. package/src/tools/defense-events.ts +7 -3
  44. package/src/tools/defense-store.ts +14 -7
  45. package/src/tools/experience-bank.ts +6 -3
  46. package/src/tools/experience-store.ts +24 -12
  47. package/src/tools/fix.ts +25 -3
  48. package/src/tools/prune.ts +194 -17
  49. package/src/tools/review.ts +16 -3
  50. package/src/tools/transcript.ts +26 -5
  51. package/src/tools/triage.ts +39 -4
  52. package/src/transcript.ts +68 -31
  53. 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 getCurrentRound(report) {
952
- return (
953
- /** @type {Array<unknown>} */
954
- (report.rounds ?? []).length
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 ? "\u8FD0\u884C\u4E2D" : "\u5DF2\u7ED3\u675F"}`) : null;
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 ? "\u8FD0\u884C\u4E2D" : "\u5DF2\u7ED3\u675F"),
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 ? "\u8FD0\u884C\u4E2D" : "\u5DF2\u7ED3\u675F"),
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 = /** @type {Array<unknown>} */ (report.rounds ?? []).length
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 (/** @type {Array<unknown>} */ (report.rounds ?? [])).length
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",
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.5-rc.2",
79
- "@deepseek-ai/dsh-util-values": "0.1.5-rc.2",
80
- "js-yaml": "4.3.1"
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-jobs": "^0.1.5-rc.2",
84
- "@deepseek-ai/dsh-session": "0.1.5-rc.2",
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
- writeFileSync(tmp, data, 'utf-8')
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 write never litters the directory.
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
- await writeFile(tmp, data, 'utf-8')
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 write never litters the directory.
90
+ // Best-effort temp cleanup so a failed rename never litters the directory.
75
91
  await cleanupTempAsync(tmp)
76
92
  throw err
77
93
  }
@@ -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': '',
@@ -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
- if (!parsed || typeof parsed !== 'object') return null
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
- const raw = (input ?? '').trim()
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')) {
@@ -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 (typeof updates.max_rounds !== 'number' || !Number.isInteger(updates.max_rounds) || updates.max_rounds < 1) {
49
- errors.push('updates.max_rounds must be a positive integer')
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('updates.atomic.max_lines must be a positive integer')
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('updates.atomic.max_adjacent_methods must be a non-negative number')
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
- if (!parsed || typeof parsed !== 'object') {
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
- const relFile = input.file ?? ''
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
- return stdout.split('\0').map((s) => s.trim()).filter((s) => s.length > 0)
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
- { cwd, timeout: 30_000, maxBuffer: 10 * 1024 * 1024, env: { ...process.env, PAGER: 'cat' } },
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 as { jobs?: JobsLike } | undefined)?.jobs
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)
@@ -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.file === violation.file && fnd.line === violation.line,
367
+ (fnd) => !!fnd && fnd?.file === violation.file && fnd?.line === violation.line,
368
368
  )
369
369
  if (matched) {
370
370
  roundHint = ` (round ${r.round})`