iterate-plugin 3.4.0 → 3.5.0

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 (49) hide show
  1. package/README.md +24 -9
  2. package/README.zh-CN.md +19 -9
  3. package/dist/atomic-fs.js +77 -0
  4. package/dist/config-write.js +3 -2
  5. package/dist/git-scope.js +3 -2
  6. package/dist/live.js +5 -4
  7. package/dist/skill-prompt.js +16 -5
  8. package/dist/tools/checkpoint.js +10 -11
  9. package/dist/tools/config.js +14 -0
  10. package/dist/tools/context.js +3 -0
  11. package/dist/tools/decision-log.js +25 -10
  12. package/dist/tools/defense-events.js +3 -0
  13. package/dist/tools/defense-store.js +6 -2
  14. package/dist/tools/experience-bank.js +3 -0
  15. package/dist/tools/experience-store.js +6 -2
  16. package/dist/tools/fix.js +34 -4
  17. package/dist/tools/history.js +3 -0
  18. package/dist/tools/prune.js +50 -6
  19. package/dist/tools/quality-gate.js +3 -0
  20. package/dist/tools/quality-store.js +2 -1
  21. package/dist/tools/transcript.js +7 -5
  22. package/dist/tools/triage.js +6 -2
  23. package/dist/tools/validate.js +32 -3
  24. package/lib/client.js +52 -9
  25. package/lib/parse.js +53 -6
  26. package/package.json +4 -2
  27. package/src/atomic-fs.ts +83 -0
  28. package/src/client/index.ts +12 -4
  29. package/src/config-write.ts +3 -2
  30. package/src/git-scope.ts +3 -2
  31. package/src/live.ts +5 -4
  32. package/src/skill-prompt.ts +16 -5
  33. package/src/tools/checkpoint.ts +10 -11
  34. package/src/tools/config.ts +14 -0
  35. package/src/tools/context.ts +3 -0
  36. package/src/tools/decision-log.ts +35 -10
  37. package/src/tools/defense-events.ts +3 -0
  38. package/src/tools/defense-store.ts +6 -2
  39. package/src/tools/experience-bank.ts +3 -0
  40. package/src/tools/experience-store.ts +6 -2
  41. package/src/tools/fix.ts +32 -4
  42. package/src/tools/history.ts +3 -0
  43. package/src/tools/prune.ts +51 -6
  44. package/src/tools/quality-gate.ts +3 -0
  45. package/src/tools/quality-store.ts +2 -1
  46. package/src/tools/transcript.ts +7 -5
  47. package/src/tools/triage.ts +6 -2
  48. package/src/tools/validate.ts +32 -3
  49. package/src/types.ts +2 -0
package/README.md CHANGED
@@ -123,7 +123,7 @@ Beyond the 17 registered tools (full reference further down), the plugin closes
123
123
  - **Structured fix system** — each fix backs up first, writes a registry entry, records the diff; a failed validation can be reverted with `iterate_rollback`
124
124
  - **Breakpoint resume** — checkpoints saved at the start of each round; interrupted long iterations can resume
125
125
  - **History audit** — `iterate_history` reads the decision log (filtered by type / time / count) and the fix-registry summary to audit run process and fix details
126
- - **Runtime cleanup** — `iterate_prune` removes stale decision-log entries, stale checkpoints, orphaned fix backups and empty rounds; dry-run by default (report-only), real cleanup requires `dryRun:false`, and every cleanup is logged
126
+ - **Runtime cleanup** — `iterate_prune` removes stale decision-log entries, stale checkpoints, orphaned fix backups, empty rounds and stray temp files left by crashed atomic writes; dry-run by default (report-only), real cleanup requires `dryRun:false`, and every cleanup is logged
127
127
  - **Config read / write** — `iterate_config` supports validated, backed-up, rollback-capable partial writes
128
128
  - **v3.2 Experience Bank** — `iterate_experience` queries historical fixes and patterns with search / filter / adopt, and can persist new verified fixes (`add`) — re-adding the same pattern+dimension bumps its hit count instead of duplicating it
129
129
  - **v3.2 Quality Gate** — `iterate_quality_gate` reads quality gate status with per-dimension convergence rates and PASS/FAIL, and can recompute + persist a fresh certificate (`compute`) from this round's findings / validation results (real convergence from `findingsByRound`)
@@ -258,7 +258,7 @@ validation:
258
258
  - `iterate_checkpoint` — iteration breakpoint: `save` persists progress to `.iterate/checkpoint.json`, `load` reads it back, `resume` loads + bumps the resume counter (interruption recovery), `clear` removes it. Resumable interrupted long iterations
259
259
  - `iterate_status` — summarize current iteration state: mode, current/last round, fixes applied, remaining architectural, decision-log entry count, whether a checkpoint exists; **v3.4: also surfaces the persisted quality-gate snapshot, experience-bank summary, and defense-events summary** (`qualityGate` / `experienceBank` / `defenseEvents`)
260
260
  - `iterate_history` — read iteration history (read-only): decision-log entries (filter by `type` / `since` / `limit`, default latest 50, cap 200) + fix-registry summary (per-round fixed/failed counts). For auditing the run, tracing logs, and inventorying fixes
261
- - `iterate_prune` — clean runtime artifacts: stale decision-log entries (by `retainDays`, default 30), stale checkpoints, orphaned fix backups, empty rounds. Dry-run by default (report-only); real cleanup with `dryRun:false`, each cleanup logged
261
+ - `iterate_prune` — clean runtime artifacts: stale decision-log entries (by `retainDays`, default 30), stale checkpoints, orphaned fix backups, empty rounds, stray temp files (crashed atomic writes). Dry-run by default (report-only); real cleanup with `dryRun:false`, each cleanup logged
262
262
  - `iterate_transcript` — runtime observatory: persist review transcripts, threads, fixes, and nudge directions to `.iterate/transcript.json` for the client observatory
263
263
  - `iterate_experience` — **v3.2** query the experience bank (list / search / get), or `add` a new verified fix: re-adding the same pattern+dimension bumps its hit count instead of duplicating it. Persists to `.iterate/experience.json`
264
264
  - `iterate_quality_gate` — **v3.2** read the quality certificate (`read`), or recompute + persist a fresh one (`compute`) from findings, validation results, `findingsByRound`, and `fixedByDimension`. Real per-dimension convergence rates
@@ -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`, and `0.1.2-rc.1` releases. The plugin is built
296
- against `@deepseek-ai/dsh-tools` / `@deepseek-ai/dsh-util-values` `0.1.2-rc.1`
297
- and uses only public contracts (tool registration, client slots + theme, bundle patch).
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 registers model-facing tools; any shell execution that
312
- the model performs (build / test / `git`) is run by the **host**, not by this
313
- plugin. The client "command buttons" only copy paste-able instruction text; they
314
- do not spawn processes.
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
@@ -122,7 +122,7 @@ UI 层为**防御式设计**:`slots` / `theme` / `React` 任一不可用时自
122
122
  - **结构化修复系统** — 每次修复先备份、写注册表、记录 diff,验证失败可 `iterate_rollback` 还原
123
123
  - **断点续跑** — 长迭代在每轮开头保存 checkpoint,中断后可恢复进度
124
124
  - **历史审计** — `iterate_history` 读取决策日志(按类型/时间/数量过滤)与修复注册表汇总,审查运行过程与修复明细
125
- - **运行时清理** — `iterate_prune` 清理过期的决策日志条目、陈旧断点、孤儿修复备份与空轮次;默认 dry-run 只报告不删除,显式 `dryRun:false` 才真正清理,每次清理写入决策日志
125
+ - **运行时清理** — `iterate_prune` 清理过期的决策日志条目、陈旧断点、孤儿修复备份、空轮次与原子写入崩溃残留的临时文件;默认 dry-run 只报告不删除,显式 `dryRun:false` 才真正清理,每次清理写入决策日志
126
126
  - **配置读写** — `iterate_config` 支持带校验、备份、回滚的局部写入
127
127
  - **v3.2 经验银行** — `iterate_experience` 以检索 / 过滤 / 采纳查阅历史修复与模式,并可 `add` 持久化新的已验证修复——重复添加同一 pattern + dimension 时累加命中次数而非重复写入
128
128
  - **v3.2 质量门禁** — `iterate_quality_gate` 读取质量门禁状态(各维度收敛率 + PASS/FAIL),并可基于本轮 findings / 验证结果 `compute` 重新计算并持久化一份新的质量凭证(收敛率来自 `findingsByRound` 的真实收敛序列)
@@ -257,7 +257,7 @@ validation:
257
257
  - `iterate_checkpoint` — 迭代断点:`save` 保存当前进度到 `.iterate/checkpoint.json`,`load` 读回,`resume` 加载并累加恢复计数(中断恢复),`clear` 清除。长迭代可中断续跑
258
258
  - `iterate_status` — 汇总当前迭代状态:模式、当前轮/总轮、已修复数、剩余 architectural、决策日志条数、是否存在 checkpoint;**v3.4:同时返回持久化的质量门禁快照、经验银行摘要与防御事件摘要**(`qualityGate` / `experienceBank` / `defenseEvents`)
259
259
  - `iterate_history` — 读取迭代历史(只读):决策日志条目(可按 `type` / `since` / `limit` 过滤,默认取最新 50 条,上限 200 条)+ 修复注册表汇总(各轮 fixed/failed 计数)。用于审查运行过程、审计日志、盘点修复
260
- - `iterate_prune` — 清理运行时产物:过期决策日志条目(按 `retainDays`,默认 30 天)、陈旧断点、孤儿修复备份、空轮次。默认 dry-run 只报告不删除;`dryRun:false` 才真正清理,每次清理写入决策日志
260
+ - `iterate_prune` — 清理运行时产物:过期决策日志条目(按 `retainDays`,默认 30 天)、陈旧断点、孤儿修复备份、空轮次、崩溃原子写入残留的临时文件。默认 dry-run 只报告不删除;`dryRun:false` 才真正清理,每次清理写入决策日志
261
261
  - `iterate_transcript` — 运行时观测台:把审查转录、线程、修复与 nudge 指令持久化到 `.iterate/transcript.json`,供客户端观测台读取
262
262
  - `iterate_experience` — **v3.2** 查询经验银行(list / search / get),或 `add` 一条新的已验证修复:重复添加同一 pattern + dimension 累加命中次数而非重复写入。持久化到 `.iterate/experience.json`
263
263
  - `iterate_quality_gate` — **v3.2** 读取质量凭证(`read`),或基于 findings、验证结果、`findingsByRound` 与 `fixedByDimension` 重新计算并持久化一份新凭证(`compute`)。真实的逐维度收敛率
@@ -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.2-alpha.4`、
294
- `0.1.2-alpha.5`、`0.1.2-rc.1` 三个版本为 `compatible`。插件基于
295
- `@deepseek-ai/dsh-tools` / `@deepseek-ai/dsh-util-values` `0.1.2-rc.1` 构建,
296
- 只使用公开契约(工具注册、客户端 slots + theme、bundle patch)。
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)** — 插件只注册模型可调用工具;模型要执行的任何 shell 操作
308
- (build / test / `git`)都由**宿主**执行,不是本插件。客户端"指挥按钮"只复制可粘贴
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;它们是独立包,本插件从不加载它们。
@@ -0,0 +1,77 @@
1
+ /**
2
+ * src/atomic-fs.ts — crash-safe file writes for all plugin state.
3
+ *
4
+ * Every JSON/YAML artifact the plugin persists (.iterate/*.json,
5
+ * iterate.config.yaml) is written through here: content goes to a
6
+ * uniquely-named temp file in the SAME directory as the target (rename is
7
+ * only atomic within one filesystem), then is `renameSync`ed over the
8
+ * destination. A crash mid-write can therefore leave a stale temp file
9
+ * behind, but never a truncated / half-written state file — readers
10
+ * (which all tolerate missing/corrupt files) see either the previous or
11
+ * the new content, never a mixture.
12
+ */
13
+ import { dirname, join } from 'node:path';
14
+ import { renameSync, rmSync, writeFileSync } from 'node:fs';
15
+ import { rename, rm, writeFile } from 'node:fs/promises';
16
+ /** Best-effort unlink of a leftover temp file (never throws). */
17
+ function cleanupTemp(tmp) {
18
+ try {
19
+ rmSync(tmp, { force: true });
20
+ }
21
+ catch {
22
+ /* ignore — temp files follow the prunable `.tmp-<pid>-<rand>` convention */
23
+ }
24
+ }
25
+ /** Best-effort async unlink of a leftover temp file (never throws). */
26
+ async function cleanupTempAsync(tmp) {
27
+ try {
28
+ await rm(tmp, { force: true });
29
+ }
30
+ catch {
31
+ /* ignore — temp files follow the prunable `.tmp-<pid>-<rand>` convention */
32
+ }
33
+ }
34
+ /**
35
+ * Unique temp file path for `filePath`, in the same directory, following the
36
+ * prunable `.<basename>.tmp-<pid>-<random>` convention (see prune.ts
37
+ * `isPrunableTemp`). The pid+random suffix makes concurrent writers safe.
38
+ */
39
+ function tempPathFor(filePath) {
40
+ return join(dirname(filePath), `.${filePath.split('/').pop() ?? 'file'}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`);
41
+ }
42
+ /**
43
+ * Write `data` to `filePath` atomically (temp file + rename, same dir).
44
+ * Temp files follow the `<name>.tmp-<pid>-<random>` convention also used by
45
+ * prune.ts / checkpoint.ts so they are recognizable and prunable.
46
+ * Throws on failure (callers own error reporting — they map failures to
47
+ * structured tool results).
48
+ */
49
+ export function writeTextAtomic(filePath, data) {
50
+ const tmp = tempPathFor(filePath);
51
+ writeFileSync(tmp, data, 'utf-8');
52
+ try {
53
+ renameSync(tmp, filePath);
54
+ }
55
+ catch (err) {
56
+ // Best-effort temp cleanup so a failed write never litters the directory.
57
+ cleanupTemp(tmp);
58
+ throw err;
59
+ }
60
+ }
61
+ /** Async variant of {@link writeTextAtomic} (same temp naming convention). */
62
+ export async function writeTextAtomicAsync(filePath, data) {
63
+ const tmp = tempPathFor(filePath);
64
+ await writeFile(tmp, data, 'utf-8');
65
+ try {
66
+ await rename(tmp, filePath);
67
+ }
68
+ catch (err) {
69
+ // Best-effort temp cleanup so a failed write never litters the directory.
70
+ await cleanupTempAsync(tmp);
71
+ throw err;
72
+ }
73
+ }
74
+ /** JSON.stringify (2-space indent) + atomic write in one call. */
75
+ export function writeJsonAtomic(filePath, value) {
76
+ writeTextAtomic(filePath, JSON.stringify(value, null, 2));
77
+ }
@@ -9,9 +9,10 @@
9
9
  * The security posture mirrors the triage tool: never overwrite a malformed
10
10
  * config, always back up before writing, roll back on failure.
11
11
  */
12
- import { copyFileSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
12
+ import { copyFileSync, existsSync, readFileSync, rmSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
14
  import yaml from 'js-yaml';
15
+ import { writeTextAtomic } from "./atomic-fs.js";
15
16
  /** Config file name (must match config-loader). */
16
17
  export const CONFIG_FILE = 'iterate.config.yaml';
17
18
  /** Backup suffix helper (filesystem-safe timestamp). */
@@ -185,7 +186,7 @@ export function writeConfigFile(projectRoot, config) {
185
186
  }
186
187
  }
187
188
  try {
188
- writeFileSync(configPath, yaml.dump(config, { noRefs: true }), 'utf-8');
189
+ writeTextAtomic(configPath, yaml.dump(config, { noRefs: true }));
189
190
  }
190
191
  catch (err) {
191
192
  let rollbackError = '';
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(root + '/') && candidate !== root)
120
+ if (!candidate.startsWith(rootPrefix) && candidate !== root)
120
121
  continue;
121
122
  try {
122
123
  if (existsSync(candidate) && statSync(candidate).isFile())
package/dist/live.js CHANGED
@@ -23,8 +23,9 @@
23
23
  * - Any capture failure is swallowed (fire-and-forget) so it can never block
24
24
  * or crash a tool call.
25
25
  */
26
- import { mkdir, readFile, writeFile, stat, appendFile, rename } from 'node:fs/promises';
26
+ import { mkdir, readFile, stat, appendFile } from 'node:fs/promises';
27
27
  import { existsSync } from 'node:fs';
28
+ import { writeTextAtomicAsync } from "./atomic-fs.js";
28
29
  import { join } from 'node:path';
29
30
  import { resolveProjectRoot } from "./config-loader.js";
30
31
  /** Keep at most this many live activity entries. */
@@ -101,9 +102,9 @@ export async function appendLive(projectRoot, entry) {
101
102
  const raw = await readFile(file, 'utf-8');
102
103
  const lines = raw.split('\n').filter(Boolean);
103
104
  const tail = lines.slice(-LIVE_MAX_ENTRIES);
104
- const tmp = `${file}.trim.tmp`;
105
- await writeFile(tmp, tail.join('\n') + '\n', 'utf-8');
106
- await rename(tmp, file);
105
+ // Atomic rewrite (unique temp + rename) so a crash mid-trim never
106
+ // truncates the live feed; the temp file is prunable by iterate_prune.
107
+ await writeTextAtomicAsync(file, tail.join('\n') + '\n');
107
108
  }
108
109
  await appendFile(file, line, 'utf-8');
109
110
  }
@@ -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
- if (agg && agg.report && agg.report.convergence && agg.report.convergence.findingsByRound[r-1] === 0) {
146
- log('round ' + r + ' found 0 new findings — converged')
147
- break
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({ validate: true }) and return the config JSON.',
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
@@ -8,9 +8,10 @@
8
8
  *
9
9
  * Checkpoint layout: `.iterate/checkpoint.json`.
10
10
  */
11
- import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
11
+ import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
12
12
  import { defineTool } from '@deepseek-ai/dsh-tools';
13
13
  import { resolveProjectRootForExec } from "../config-loader.js";
14
+ import { writeJsonAtomic } from "../atomic-fs.js";
14
15
  import { checkpointPath, iterateDir, transcriptPath } from "../paths.js";
15
16
  import { readRegistry } from "./fix.js";
16
17
  import { readDecisionEntries } from "./decision-log.js";
@@ -192,10 +193,7 @@ export function registerCheckpointTool(ctx) {
192
193
  };
193
194
  try {
194
195
  mkdirSync(iterateDir(projectRoot), { recursive: true });
195
- const cpPath = checkpointPath(projectRoot);
196
- const tmpPath = `${cpPath}.tmp-${Date.now()}`;
197
- writeFileSync(tmpPath, JSON.stringify(resumed, null, 2), 'utf-8');
198
- renameSync(tmpPath, cpPath);
196
+ writeJsonAtomic(checkpointPath(projectRoot), resumed);
199
197
  }
200
198
  catch (err) {
201
199
  return { operation: 'resume', ok: false, error: `failed to persist resumed checkpoint: ${String(err)}` };
@@ -238,12 +236,10 @@ export function registerCheckpointTool(ctx) {
238
236
  };
239
237
  try {
240
238
  mkdirSync(iterateDir(projectRoot), { recursive: true });
241
- // Atomic write (temp + rename): a crash mid-write must not corrupt
242
- // the checkpoint and silently lose the interruption state.
243
- const cpPath = checkpointPath(projectRoot);
244
- const tmpPath = `${cpPath}.tmp-${Date.now()}`;
245
- writeFileSync(tmpPath, JSON.stringify(checkpoint, null, 2), 'utf-8');
246
- renameSync(tmpPath, cpPath);
239
+ // Atomic write (temp + rename, via writeJsonAtomic): a crash
240
+ // mid-write must not corrupt the checkpoint and silently lose the
241
+ // interruption state.
242
+ writeJsonAtomic(checkpointPath(projectRoot), checkpoint);
247
243
  }
248
244
  catch (err) {
249
245
  return { operation: 'save', ok: false, error: `failed to write checkpoint: ${String(err)}` };
@@ -262,6 +258,9 @@ export function registerCheckpointTool(ctx) {
262
258
  export function registerStatusTool(ctx) {
263
259
  ctx.tools.register(defineTool({
264
260
  name: 'iterate_status',
261
+ // Read-only aggregation of on-disk state → safe to join a parallel
262
+ // dispatch group alongside other read-only sibling calls.
263
+ isConcurrencySafe: () => true,
265
264
  description: 'Summarize the current iterate run: mode, current round vs total, fixes applied, architectural ' +
266
265
  'findings remaining, decision-log size, and whether a resume checkpoint exists.',
267
266
  parameters: {
@@ -11,6 +11,20 @@ import { applyConfigUpdates, readRawConfig, validateConfigUpdates, writeConfigFi
11
11
  export function registerConfigTool(ctx) {
12
12
  ctx.tools.register(defineTool({
13
13
  name: 'iterate_config',
14
+ // `read` never writes; `write` persists a config update → only read
15
+ // joins a parallel dispatch group.
16
+ isConcurrencySafe: (args) => args.operation !== 'write',
17
+ // Pending-call card: a config read vs a validated write.
18
+ presentCall: (args) => {
19
+ const a = args;
20
+ const isWrite = a.operation === 'write';
21
+ return {
22
+ card: 'generic',
23
+ title: isWrite ? 'Update iterate.config.yaml' : 'Read iterate.config.yaml',
24
+ kind: isWrite ? 'edit' : 'read',
25
+ ...(typeof a.section === 'string' && a.section ? { rawInput: { section: a.section } } : {}),
26
+ };
27
+ },
14
28
  description: 'Read or update the iterate.config.yaml configuration from the project root. ' +
15
29
  'Returns the full parsed config, a specific section, or validation errors. ' +
16
30
  'Use this to discover available dimensions, validation commands, git settings, and personalization rules, ' +
@@ -161,6 +161,9 @@ export { findSkillMd };
161
161
  export function registerContextTool(ctx) {
162
162
  ctx.tools.register(defineTool({
163
163
  name: 'iterate_context',
164
+ // Read-only (reads SKILL.md / ITERATE.md) → safe to join a parallel
165
+ // dispatch group alongside other read-only sibling calls.
166
+ isConcurrencySafe: () => true,
164
167
  description: 'Read project context files (SKILL.md and/or ITERATE.md). ' +
165
168
  'SKILL.md contains the original iterate skill instructions; it is searched in ' +
166
169
  'the skill directory (auto-detected), the project root, or an explicit `skillDir`. ' +
@@ -71,34 +71,44 @@ export function appendDecisionEntry(projectRoot, entry) {
71
71
  return { count, path: filePath };
72
72
  }
73
73
  /**
74
- * Read all entries from the decision log.
74
+ * Read all entries from the decision log, plus the count of corrupt lines.
75
75
  * A single corrupt line (partial write, hand-edit) is SKIPPED, not fatal —
76
- * one bad line must never empty the whole history for every reader.
76
+ * one bad line must never empty the whole history for every reader — but it
77
+ * is counted in `invalidLines` so it never disappears silently.
77
78
  */
78
- export function readDecisionEntries(projectRoot) {
79
+ export function readDecisionLogDetailed(projectRoot) {
79
80
  const filePath = join(projectRoot, LOG_DIR, LOG_FILE);
80
81
  if (!existsSync(filePath))
81
- return [];
82
+ return { entries: [], invalidLines: 0 };
82
83
  let content;
83
84
  try {
84
85
  content = readFileSync(filePath, 'utf-8');
85
86
  }
86
87
  catch {
87
- return [];
88
+ return { entries: [], invalidLines: 0 };
88
89
  }
89
- const out = [];
90
+ const entries = [];
91
+ let invalidLines = 0;
90
92
  for (const line of content.split('\n')) {
91
93
  const trimmed = line.trim();
92
94
  if (trimmed.length === 0)
93
95
  continue;
94
96
  try {
95
- out.push(JSON.parse(trimmed));
97
+ entries.push(JSON.parse(trimmed));
96
98
  }
97
99
  catch {
98
- // skip the corrupt line, keep the rest
100
+ invalidLines += 1;
99
101
  }
100
102
  }
101
- return out;
103
+ return { entries, invalidLines };
104
+ }
105
+ /**
106
+ * Read all entries from the decision log.
107
+ * A single corrupt line (partial write, hand-edit) is SKIPPED, not fatal —
108
+ * one bad line must never empty the whole history for every reader.
109
+ */
110
+ export function readDecisionEntries(projectRoot) {
111
+ return readDecisionLogDetailed(projectRoot).entries;
102
112
  }
103
113
  /**
104
114
  * Register the `iterate_decision_log` tool.
@@ -108,6 +118,9 @@ export function readDecisionEntries(projectRoot) {
108
118
  export function registerDecisionLogTool(ctx) {
109
119
  ctx.tools.register(defineTool({
110
120
  name: 'iterate_decision_log',
121
+ // `read` never writes; `append` extends the audit log → only read joins
122
+ // a parallel dispatch group.
123
+ isConcurrencySafe: (args) => args.operation === 'read',
111
124
  description: 'Append-only decision log for the iterate loop. ' +
112
125
  'Use `append` to record a round start, review finding, fix, validation result, or decision. ' +
113
126
  'Use `read` to retrieve all entries for review. ' +
@@ -156,6 +169,7 @@ export function registerDecisionLogTool(ctx) {
156
169
  properties: {
157
170
  operation: { type: 'string', required: true },
158
171
  entryCount: { type: 'integer' },
172
+ invalidLines: { type: 'integer' },
159
173
  logPath: { type: 'string' },
160
174
  entries: { type: 'json' },
161
175
  success: { type: 'boolean' },
@@ -174,10 +188,11 @@ export function registerDecisionLogTool(ctx) {
174
188
  }
175
189
  const projectRoot = resolved.root;
176
190
  if (args.operation === 'read') {
177
- const entries = readDecisionEntries(projectRoot);
191
+ const { entries, invalidLines } = readDecisionLogDetailed(projectRoot);
178
192
  return {
179
193
  operation: 'read',
180
194
  entryCount: entries.length,
195
+ invalidLines,
181
196
  logPath: join(projectRoot, LOG_DIR, LOG_FILE),
182
197
  entries: entries,
183
198
  };
@@ -72,6 +72,9 @@ function validateRecordInput(args) {
72
72
  export function registerDefenseEventsTool(ctx) {
73
73
  ctx.tools.register(defineTool({
74
74
  name: 'iterate_defense_events',
75
+ // List/counts never write; only `record` persists an event → read shapes
76
+ // join a parallel dispatch group.
77
+ isConcurrencySafe: (args) => args.operation !== 'record',
75
78
  description: 'Query or record defense events: precondition failures, rollbacks, invariant violations, ' +
76
79
  'and assumption falsifications. ' +
77
80
  'List/counts return events with descriptions, outcomes, and summary counts; ' +
@@ -6,6 +6,8 @@
6
6
  */
7
7
  import * as fs from 'node:fs';
8
8
  import * as path from 'node:path';
9
+ import { iterateDir } from "../paths.js";
10
+ import { writeJsonAtomic } from "../atomic-fs.js";
9
11
  const DEFENSE_EVENTS_FILE = 'defense-events.json';
10
12
  /** Valid defense event types (must stay in sync with DefenseEventType). */
11
13
  const VALID_EVENT_TYPES = new Set([
@@ -70,13 +72,15 @@ export function readDefenseEvents(projectRoot) {
70
72
  * an event that was never persisted.
71
73
  */
72
74
  export function writeDefenseEvents(projectRoot, stream) {
73
- const dirPath = path.join(projectRoot, '.iterate');
75
+ const dirPath = iterateDir(projectRoot);
74
76
  const filePath = path.join(dirPath, DEFENSE_EVENTS_FILE);
75
77
  try {
76
78
  if (!fs.existsSync(dirPath)) {
77
79
  fs.mkdirSync(dirPath, { recursive: true });
78
80
  }
79
- fs.writeFileSync(filePath, JSON.stringify(stream, null, 2), 'utf-8');
81
+ // Atomic (temp + rename): a crash mid-write can never leave a truncated
82
+ // defense event stream behind.
83
+ writeJsonAtomic(filePath, stream);
80
84
  }
81
85
  catch (err) {
82
86
  return { ok: false, error: `unable to write ${filePath}: ${String(err)}` };
@@ -67,6 +67,9 @@ function normalizeExperienceInput(raw) {
67
67
  export function registerExperienceBankTool(ctx) {
68
68
  ctx.tools.register(defineTool({
69
69
  name: 'iterate_experience',
70
+ // List/search/get never write; only `add` upserts the bank → read shapes
71
+ // join a parallel dispatch group.
72
+ isConcurrencySafe: (args) => args.operation !== 'add',
70
73
  description: 'Query or extend the experience bank: browse/search historical fixes and patterns, ' +
71
74
  'or record a new verified fix (operation:"add"). ' +
72
75
  'List/search/get return matching entries with hit counts, verified fixes, and related context. ' +
@@ -6,6 +6,8 @@
6
6
  */
7
7
  import * as fs from 'node:fs';
8
8
  import * as path from 'node:path';
9
+ import { iterateDir } from "../paths.js";
10
+ import { writeJsonAtomic } from "../atomic-fs.js";
9
11
  const EXPERIENCE_FILE = 'experience.json';
10
12
  /** Default empty experience bank. */
11
13
  function emptyBank() {
@@ -37,13 +39,15 @@ export function readExperienceBank(projectRoot) {
37
39
  * an entry that was never persisted.
38
40
  */
39
41
  export function writeExperienceBank(projectRoot, bank) {
40
- const dirPath = path.join(projectRoot, '.iterate');
42
+ const dirPath = iterateDir(projectRoot);
41
43
  const filePath = path.join(dirPath, EXPERIENCE_FILE);
42
44
  try {
43
45
  if (!fs.existsSync(dirPath)) {
44
46
  fs.mkdirSync(dirPath, { recursive: true });
45
47
  }
46
- fs.writeFileSync(filePath, JSON.stringify(bank, null, 2), 'utf-8');
48
+ // Atomic (temp + rename): a crash mid-write can never leave a truncated
49
+ // experience bank behind.
50
+ writeJsonAtomic(filePath, bank);
47
51
  }
48
52
  catch (err) {
49
53
  return { ok: false, error: `unable to write ${filePath}: ${String(err)}` };