iterate-plugin 3.4.1 → 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 (42) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/dist/atomic-fs.js +77 -0
  4. package/dist/config-write.js +3 -2
  5. package/dist/live.js +5 -4
  6. package/dist/tools/checkpoint.js +10 -11
  7. package/dist/tools/config.js +14 -0
  8. package/dist/tools/context.js +3 -0
  9. package/dist/tools/decision-log.js +25 -10
  10. package/dist/tools/defense-events.js +3 -0
  11. package/dist/tools/defense-store.js +6 -2
  12. package/dist/tools/experience-bank.js +3 -0
  13. package/dist/tools/experience-store.js +6 -2
  14. package/dist/tools/fix.js +34 -4
  15. package/dist/tools/history.js +3 -0
  16. package/dist/tools/prune.js +50 -6
  17. package/dist/tools/quality-gate.js +3 -0
  18. package/dist/tools/quality-store.js +2 -1
  19. package/dist/tools/transcript.js +7 -5
  20. package/dist/tools/triage.js +6 -2
  21. package/dist/tools/validate.js +32 -3
  22. package/package.json +1 -1
  23. package/src/atomic-fs.ts +83 -0
  24. package/src/config-write.ts +3 -2
  25. package/src/live.ts +5 -4
  26. package/src/tools/checkpoint.ts +10 -11
  27. package/src/tools/config.ts +14 -0
  28. package/src/tools/context.ts +3 -0
  29. package/src/tools/decision-log.ts +35 -10
  30. package/src/tools/defense-events.ts +3 -0
  31. package/src/tools/defense-store.ts +6 -2
  32. package/src/tools/experience-bank.ts +3 -0
  33. package/src/tools/experience-store.ts +6 -2
  34. package/src/tools/fix.ts +32 -4
  35. package/src/tools/history.ts +3 -0
  36. package/src/tools/prune.ts +51 -6
  37. package/src/tools/quality-gate.ts +3 -0
  38. package/src/tools/quality-store.ts +2 -1
  39. package/src/tools/transcript.ts +7 -5
  40. package/src/tools/triage.ts +6 -2
  41. package/src/tools/validate.ts +32 -3
  42. 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
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`)。真实的逐维度收敛率
@@ -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/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
  }
@@ -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)}` };
package/dist/tools/fix.js CHANGED
@@ -16,7 +16,8 @@
16
16
  * - Backups are written before any write, so a failure never destroys data.
17
17
  * - Atomicity is enforced against `config.atomic.max_lines` unless `force`.
18
18
  */
19
- import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
19
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs';
20
+ import { writeJsonAtomic, writeTextAtomic } from "../atomic-fs.js";
20
21
  import { join, sep } from 'node:path';
21
22
  import { defineTool } from '@deepseek-ai/dsh-tools';
22
23
  import { loadEffectiveConfig, resolveProjectRootForExec } from "../config-loader.js";
@@ -276,6 +277,20 @@ function readProjectFile(projectRoot, file) {
276
277
  export function registerFixTool(ctx) {
277
278
  ctx.tools.register(defineTool({
278
279
  name: 'iterate_fix',
280
+ // Pending-call card: surface the file about to change as an inline diff.
281
+ // oldText is null — a call-time presenter has no access to the file's
282
+ // prior content (an overwrite, per the presentation contract).
283
+ presentCall: (args) => {
284
+ const a = args;
285
+ if (typeof a.file !== 'string' || typeof a.content !== 'string')
286
+ return undefined;
287
+ return {
288
+ card: 'diff',
289
+ title: `Fix ${a.file}`,
290
+ diffs: [{ path: a.file, oldText: null, newText: a.content }],
291
+ locations: [{ path: a.file }],
292
+ };
293
+ },
279
294
  description: 'Apply ONE atomic fix to a file. Pass the target relative `file`, the finding that motivated ' +
280
295
  'the fix, the NEW full `content` of that file (after your edit), and the current `round`. ' +
281
296
  'The tool backs up the original, enforces the atomic `max_lines` and `max_adjacent_methods` thresholds (unless `force`), ' +
@@ -445,7 +460,7 @@ export function registerFixTool(ctx) {
445
460
  return { ok: false, error: `failed to create backup: ${String(err)}` };
446
461
  }
447
462
  try {
448
- writeFileSync(target.resolved, args.content, 'utf-8');
463
+ writeTextAtomic(target.resolved, args.content);
449
464
  }
450
465
  catch (err) {
451
466
  return { ok: false, error: `failed to write file: ${String(err)}` };
@@ -463,7 +478,7 @@ export function registerFixTool(ctx) {
463
478
  };
464
479
  const nextRegistry = upsertRecord(registry, record);
465
480
  try {
466
- writeFileSync(fixRegistryPath(projectRoot), JSON.stringify(nextRegistry, null, 2), 'utf-8');
481
+ writeJsonAtomic(fixRegistryPath(projectRoot), nextRegistry);
467
482
  }
468
483
  catch (err) {
469
484
  // Registry write failed → the file was already modified but no record
@@ -511,6 +526,9 @@ export function registerFixTool(ctx) {
511
526
  export function registerDiffTool(ctx) {
512
527
  ctx.tools.register(defineTool({
513
528
  name: 'iterate_diff',
529
+ // Read-only (never writes project files or plugin state) → safe to join
530
+ // a parallel dispatch group alongside other read-only sibling calls.
531
+ isConcurrencySafe: () => true,
514
532
  description: 'Show the changes made by iterate fixes. With `file`, returns the unified diff of the current ' +
515
533
  'file content vs its original (first backup). Without `file`, returns a summary of every fixed file.',
516
534
  parameters: {
@@ -612,6 +630,18 @@ export function registerDiffTool(ctx) {
612
630
  export function registerRollbackTool(ctx) {
613
631
  ctx.tools.register(defineTool({
614
632
  name: 'iterate_rollback',
633
+ // Pending-call card: which fix is about to be reverted.
634
+ presentCall: (args) => {
635
+ const a = args;
636
+ if (typeof a.id !== 'string' || a.id.length === 0)
637
+ return undefined;
638
+ return {
639
+ card: 'generic',
640
+ title: `Rollback fix ${a.id}`,
641
+ kind: 'edit',
642
+ rawInput: { id: a.id },
643
+ };
644
+ },
615
645
  description: 'Revert a previously applied fix. Pass the fix `id` (returned by iterate_fix). ' +
616
646
  'The file is restored from the fix backup, the fix is removed from the registry, ' +
617
647
  'and a `revert` entry is appended to the decision log. Use when a round\'s validation fails.',
@@ -667,7 +697,7 @@ export function registerRollbackTool(ctx) {
667
697
  }
668
698
  const nextRegistry = removeRecord(registry, id);
669
699
  try {
670
- writeFileSync(fixRegistryPath(projectRoot), JSON.stringify(nextRegistry, null, 2), 'utf-8');
700
+ writeJsonAtomic(fixRegistryPath(projectRoot), nextRegistry);
671
701
  }
672
702
  catch (err) {
673
703
  return { ok: false, error: `failed to update fix registry: ${String(err)}` };
@@ -64,6 +64,9 @@ export function summarizeFixRegistry(registry) {
64
64
  export function registerHistoryTool(ctx) {
65
65
  ctx.tools.register(defineTool({
66
66
  name: 'iterate_history',
67
+ // Read-only audit view over on-disk state → safe to join a parallel
68
+ // dispatch group alongside other read-only sibling calls.
69
+ isConcurrencySafe: () => true,
67
70
  description: 'Read the iteration history: decision-log entries (optionally filtered by entry `type`, `since` ' +
68
71
  'timestamp, and a `limit`) plus a summary of the fix registry (per-round fixed/failed counts). ' +
69
72
  'Read-only — use it to review what the run did, audit a log, or inspect fixes.',
@@ -11,16 +11,19 @@
11
11
  * - Fix backups left over from old rounds (backups whose fix-id no longer
12
12
  * appears in the registry).
13
13
  * - Empty fix rounds (rounds with 0 records).
14
+ * - Stray temp files left behind by a crashed atomic write (see
15
+ * `isPrunableTemp` for the recognized naming conventions).
14
16
  *
15
17
  * Security model:
16
18
  * - Only operates under the resolved project `.iterate/` directory.
17
19
  * - dryRun=true by default — the caller must explicitly opt into deletion.
18
20
  * - Each deletion is logged to the decision log (when not dry-run).
19
21
  */
20
- import { existsSync, readdirSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
22
+ import { existsSync, readdirSync, rmSync, unlinkSync } from 'node:fs';
21
23
  import { join } from 'node:path';
22
24
  import { defineTool } from '@deepseek-ai/dsh-tools';
23
25
  import { resolveProjectRootForExec } from "../config-loader.js";
26
+ import { writeTextAtomic, writeJsonAtomic } from "../atomic-fs.js";
24
27
  import { readDecisionEntries, appendDecisionEntry } from "./decision-log.js";
25
28
  import { readRegistry, recomputeRoundCounts } from "./fix.js";
26
29
  import { iterateDir, fixesDir, checkpointPath, fixRegistryPath } from "../paths.js";
@@ -41,6 +44,28 @@ export function cutoffTimestamp(retainDays) {
41
44
  d.setDate(d.getDate() - retainDays);
42
45
  return d.toISOString();
43
46
  }
47
+ /**
48
+ * Whether a file name inside `.iterate/` is a prunable leftover temp file.
49
+ *
50
+ * Recognized conventions (all written to the SAME directory as their target,
51
+ * so they can never be confused with real state files):
52
+ * - `.<name>.tmp-<pid>-<rand>` — current atomic-fs.ts temp prefix;
53
+ * - `.tmp-<pid>-<rand>` — legacy temp prefix (older plugin builds);
54
+ * - `<name>.tmp` — legacy transcript.ts / live.ts temp suffix.
55
+ *
56
+ * Pure and exported for unit tests. A live writer's temp file is only visible
57
+ * to it (unique name), so deleting any match is always safe.
58
+ */
59
+ export function isPrunableTemp(name) {
60
+ if (typeof name !== 'string' || name.length === 0 || name === '.' || name === '..')
61
+ return false;
62
+ if (name.startsWith('.tmp-'))
63
+ return true;
64
+ if (name.endsWith('.tmp'))
65
+ return true;
66
+ // .<basename>.tmp-<pid>-<rand>: starts with a dot and contains ".tmp-" after it.
67
+ return name.startsWith('.') && name.includes('.tmp-');
68
+ }
44
69
  /**
45
70
  * Inspect the runtime state and report what would be pruned.
46
71
  * Pure (no deletions). Returns a structured report.
@@ -79,10 +104,17 @@ export function inspectPrune(projectRoot, retainDays) {
79
104
  const emptyRounds = registry.rounds
80
105
  .filter((r) => r.records.length === 0)
81
106
  .map((r) => r.round);
107
+ // 5. Stray atomic-write temp files left behind by a crashed writer. The
108
+ // unique prefix makes them recognizable and safe to delete — no live writer
109
+ // ever reads another writer's temp file.
110
+ const staleTemps = existsSync(iterateDir(projectRoot))
111
+ ? readdirSync(iterateDir(projectRoot)).filter((f) => isPrunableTemp(f)).sort()
112
+ : [];
82
113
  return {
83
114
  oldLogEntries,
84
115
  hasCheckpoint,
85
116
  staleBackups,
117
+ staleTemps,
86
118
  emptyRounds,
87
119
  totalLogEntries: entries.length,
88
120
  registryRounds: registry.rounds.length,
@@ -98,6 +130,7 @@ export function executePrune(projectRoot, retainDays, report) {
98
130
  deletedLogEntries: 0,
99
131
  deletedCheckpoint: false,
100
132
  deletedBackups: [],
133
+ deletedTemps: [],
101
134
  trimmedEmptyRounds: 0,
102
135
  errors: [],
103
136
  };
@@ -109,9 +142,7 @@ export function executePrune(projectRoot, retainDays, report) {
109
142
  result.deletedLogEntries = entries.length - kept.length;
110
143
  if (result.deletedLogEntries > 0) {
111
144
  const logPath = join(iterateDir(projectRoot), 'decision-log.jsonl');
112
- const tmpPath = `${logPath}.tmp-${Date.now()}`;
113
- writeFileSync(tmpPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n', 'utf-8');
114
- renameSync(tmpPath, logPath);
145
+ writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n');
115
146
  }
116
147
  }
117
148
  catch (err) {
@@ -151,13 +182,23 @@ export function executePrune(projectRoot, retainDays, report) {
151
182
  rounds: registry.rounds.filter((r) => !emptyRoundNos.has(r.round) || (r.records?.length ?? 0) > 0),
152
183
  };
153
184
  registry = recomputeRoundCounts(registry);
154
- writeFileSync(fixRegistryPath(projectRoot), JSON.stringify(registry, null, 2), 'utf-8');
185
+ writeJsonAtomic(fixRegistryPath(projectRoot), registry);
155
186
  result.trimmedEmptyRounds = report.emptyRounds.length;
156
187
  }
157
188
  catch (err) {
158
189
  result.errors.push(`failed to trim empty rounds: ${String(err)}`);
159
190
  }
160
191
  }
192
+ // 5. Remove stray atomic-write temp files.
193
+ for (const tmp of report.staleTemps) {
194
+ try {
195
+ rmSync(join(iterateDir(projectRoot), tmp), { force: true });
196
+ result.deletedTemps.push(tmp);
197
+ }
198
+ catch (err) {
199
+ result.errors.push(`failed to delete temp file ${tmp}: ${String(err)}`);
200
+ }
201
+ }
161
202
  return result;
162
203
  }
163
204
  /**
@@ -170,7 +211,8 @@ export function registerPruneTool(ctx) {
170
211
  name: 'iterate_prune',
171
212
  description: 'Inspect or clean up old iterate runtime artifacts (.iterate/). ' +
172
213
  'Defaults to dry-run (report-only, no deletion). Pass `dryRun: false` to actually prune. ' +
173
- 'Manages: old decision-log entries, stale checkpoints, orphaned fix backups, empty fix rounds. ' +
214
+ 'Manages: old decision-log entries, stale checkpoints, orphaned fix backups, empty fix rounds, ' +
215
+ 'and stray temp files left by crashed atomic writes. ' +
174
216
  'Each deletion is logged to the decision log.',
175
217
  parameters: {
176
218
  dryRun: {
@@ -210,6 +252,7 @@ export function registerPruneTool(ctx) {
210
252
  ` Decision-log entries to remove: ${report?.oldLogEntries ?? '?'} (of ${report?.totalLogEntries ?? '?'})`,
211
253
  ` Checkpoint to delete: ${report?.hasCheckpoint ? 'yes' : 'none'}`,
212
254
  ` Stale backups to delete: ${report?.staleBackups?.length ?? 0}`,
255
+ ` Stray temp files to delete: ${report?.staleTemps?.length ?? 0}`,
213
256
  ` Empty rounds to trim: ${report?.emptyRounds?.length ?? 0}`,
214
257
  '',
215
258
  'Pass dryRun:false to execute the prune.',
@@ -221,6 +264,7 @@ export function registerPruneTool(ctx) {
221
264
  ` Deleted ${result?.deletedLogEntries ?? 0} old log entries.`,
222
265
  ` Checkpoint deleted: ${result?.deletedCheckpoint ? 'yes' : 'no'}`,
223
266
  ` Deleted ${result?.deletedBackups?.length ?? 0} stale backups.`,
267
+ ` Deleted ${result?.deletedTemps?.length ?? 0} stray temp files.`,
224
268
  ` Trimmed ${result?.trimmedEmptyRounds ?? 0} empty rounds.`,
225
269
  ];
226
270
  const errs = result?.errors ?? [];