iterate-plugin 3.4.1 → 3.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +4 -4
  2. package/README.zh-CN.md +4 -4
  3. package/dist/atomic-fs.js +77 -0
  4. package/dist/config-write.js +3 -2
  5. package/dist/live.js +10 -4
  6. package/dist/skill-prompt.js +2 -2
  7. package/dist/tools/checkpoint.js +31 -11
  8. package/dist/tools/config.js +14 -0
  9. package/dist/tools/context.js +3 -0
  10. package/dist/tools/decision-log.js +25 -10
  11. package/dist/tools/defense-events.js +3 -0
  12. package/dist/tools/defense-store.js +6 -2
  13. package/dist/tools/experience-bank.js +42 -3
  14. package/dist/tools/experience-store.js +30 -2
  15. package/dist/tools/fix.js +40 -4
  16. package/dist/tools/history.js +3 -0
  17. package/dist/tools/prune.js +50 -6
  18. package/dist/tools/quality-gate.js +24 -4
  19. package/dist/tools/quality-store.js +20 -1
  20. package/dist/tools/transcript.js +38 -5
  21. package/dist/tools/triage.js +6 -2
  22. package/dist/tools/validate.js +32 -3
  23. package/package.json +5 -2
  24. package/src/atomic-fs.ts +83 -0
  25. package/src/config-write.ts +3 -2
  26. package/src/live.ts +10 -4
  27. package/src/skill-prompt.ts +2 -2
  28. package/src/tools/checkpoint.ts +37 -11
  29. package/src/tools/config.ts +14 -0
  30. package/src/tools/context.ts +3 -0
  31. package/src/tools/decision-log.ts +35 -10
  32. package/src/tools/defense-events.ts +3 -0
  33. package/src/tools/defense-store.ts +6 -2
  34. package/src/tools/experience-bank.ts +43 -3
  35. package/src/tools/experience-store.ts +32 -2
  36. package/src/tools/fix.ts +39 -4
  37. package/src/tools/history.ts +3 -0
  38. package/src/tools/prune.ts +51 -6
  39. package/src/tools/quality-gate.ts +25 -4
  40. package/src/tools/quality-store.ts +21 -1
  41. package/src/tools/transcript.ts +39 -5
  42. package/src/tools/triage.ts +6 -2
  43. package/src/tools/validate.ts +32 -3
  44. 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,10 +258,10 @@ 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
- - `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
- - `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
263
+ - `iterate_experience` — **v3.2** query the experience bank (list / search / get), `add` a new verified fix (re-adding the same pattern+dimension bumps its hit count instead of duplicating it), or **v3.5 remove a stale/incorrect entry by `id`** so bad experiences never resurface. Persists to `.iterate/experience.json`
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. **v3.5: `clear` removes the persisted certificate to reset a stale gate before a fresh iteration**
265
265
  - `iterate_defense_events` — **v3.2** query defense events (list / counts), or `record` a new one. Human-readable labels follow the project language (en / zh)
266
266
 
267
267
  ---
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,10 +257,10 @@ 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
- - `iterate_experience` — **v3.2** 查询经验银行(list / search / get),或 `add` 一条新的已验证修复:重复添加同一 pattern + dimension 累加命中次数而非重复写入。持久化到 `.iterate/experience.json`
263
- - `iterate_quality_gate` — **v3.2** 读取质量凭证(`read`),或基于 findings、验证结果、`findingsByRound` 与 `fixedByDimension` 重新计算并持久化一份新凭证(`compute`)。真实的逐维度收敛率
262
+ - `iterate_experience` — **v3.2** 查询经验银行(list / search / get),或 `add` 一条新的已验证修复:重复添加同一 pattern + dimension 累加命中次数而非重复写入。**v3.5 新增 `remove`:按 `id` 删除过时或错误的经验条目**。持久化到 `.iterate/experience.json`
263
+ - `iterate_quality_gate` — **v3.2** 读取质量凭证(`read`),或基于 findings、验证结果、`findingsByRound` 与 `fixedByDimension` 重新计算并持久化一份新凭证(`compute`)。真实的逐维度收敛率。**v3.5 新增 `clear`:删除持久化的质量凭证**,用于新一轮迭代前重置陈旧的门禁
264
264
  - `iterate_defense_events` — **v3.2** 查询防御事件(list / counts),或 `record` 记录一条新事件。可读标签跟随项目语言(en / zh)
265
265
 
266
266
  ---
@@ -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. */
@@ -68,6 +69,11 @@ export function classifyTool(name, args, projectRoot) {
68
69
  iterate_status: 'info',
69
70
  iterate_config: 'info',
70
71
  iterate_context: 'info',
72
+ // v3.0 quality command-center tools — surface their activity in the live
73
+ // feed too (previously these three never produced a live entry).
74
+ iterate_experience: 'info',
75
+ iterate_quality_gate: 'info',
76
+ iterate_defense_events: 'info',
71
77
  };
72
78
  const type = records[name];
73
79
  if (!type)
@@ -101,9 +107,9 @@ export async function appendLive(projectRoot, entry) {
101
107
  const raw = await readFile(file, 'utf-8');
102
108
  const lines = raw.split('\n').filter(Boolean);
103
109
  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);
110
+ // Atomic rewrite (unique temp + rename) so a crash mid-trim never
111
+ // truncates the live feed; the temp file is prunable by iterate_prune.
112
+ await writeTextAtomicAsync(file, tail.join('\n') + '\n');
107
113
  }
108
114
  await appendFile(file, line, 'utf-8');
109
115
  }
@@ -23,8 +23,8 @@ You have the iterate plugin installed, which registers these tools:
23
23
  - \`iterate_history\` — inspect the runtime state in detail: decision-log entries and applied fixes (optionally scoped to a round or a fixed file)
24
24
  - \`iterate_prune\` — remove stale runtime artifacts (\`.iterate/\` entries). Defaults to a read-only dry-run that reports what WOULD be removed; pass \`dryRun:false\` to actually prune.
25
25
  - \`iterate_transcript\` — runtime observatory file (\`.iterate/transcript.json\`). \`read\` fetches the persisted manifest including any steering \`nudge\` for this run's reviewers; \`capture\` (call once after the final report) persists the per-reviewer threads, convergence trend, findings, fixes, checkpoint, and timeline so the client observatory panel reflects the run; \`nudge\` sets/clears steering text the next round's reviewers read. Purely local, never touches source files.
26
- - \`iterate_experience\` — experience bank (\`.iterate/experience.json\`): \`list\`/\`search\`/\`get\` recall verified fixes and patterns from past sessions (read the bank before fixing so proven fixes are applied first); \`add\` records a new verified fix — re-adding the same pattern+dimension bumps its hit count instead of duplicating it.
27
- - \`iterate_quality_gate\` — quality certificate: \`read\` loads the persisted dimension convergence rates / verification pass rate / PASS-FAIL status; \`compute\` recomputes a fresh snapshot from this round's findings + validation results (supply \`findingsByRound\` for real convergence) and persists it to \`.iterate/quality-gate.json\`.
26
+ - \`iterate_experience\` — experience bank (\`.iterate/experience.json\`): \`list\`/\`search\`/\`get\` recall verified fixes and patterns from past sessions (read the bank before fixing so proven fixes are applied first); \`add\` records a new verified fix — re-adding the same pattern+dimension bumps its hit count instead of duplicating it; \`remove\` deletes a stale or incorrect entry by \`id\` so bad experiences never resurface.
27
+ - \`iterate_quality_gate\` — quality certificate: \`read\` loads the persisted dimension convergence rates / verification pass rate / PASS-FAIL status; \`compute\` recomputes a fresh snapshot from this round's findings + validation results (supply \`findingsByRound\` for real convergence) and persists it to \`.iterate/quality-gate.json\`; \`clear\` removes the persisted certificate to reset a stale gate before a fresh iteration.
28
28
  - \`iterate_defense_events\` — defense event stream (\`.iterate/defense-events.json\`): \`list\`/\`counts\` review precondition failures, rollbacks, invariant violations, and falsified assumptions; \`record\` logs a new event when a defense fires. Human-readable labels follow the project \`language\` (en/zh).
29
29
 
30
30
  ### When to use
@@ -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: {
@@ -303,6 +302,27 @@ export function registerStatusTool(ctx) {
303
302
  `Checkpoint: ${value.hasCheckpoint ? 'yes' : 'no'}${value.interrupted ? ' (interrupted — resumable)' : ''}${value.resumeCount ? ` · resumed ${value.resumeCount}x` : ''}`,
304
303
  value.lastUpdated ? `Last updated: ${value.lastUpdated}` : '',
305
304
  ];
305
+ // v3.0 quality command-center summaries (surfaced here so a single
306
+ // status read reports the whole run state in text too).
307
+ const gate = value.qualityGate;
308
+ if (gate) {
309
+ lines.push(`Quality gate: ${gate.overallStatus.toUpperCase()} · score ${gate.overallScore} · ` +
310
+ `verification ${gate.passedChecks}/${gate.totalChecks} passed` +
311
+ (gate.failReason ? ` · ${gate.failReason}` : ''));
312
+ }
313
+ const exp = value.experienceBank;
314
+ if (exp) {
315
+ lines.push(`Experience bank: ${exp.totalEntries ?? 0} entries · ${exp.totalHits ?? 0} cumulative hits`);
316
+ }
317
+ const def = value.defenseEvents;
318
+ if (def) {
319
+ const counts = def.counts ?? {};
320
+ const total = Object.values(counts).reduce((s, n) => s + (n || 0), 0);
321
+ lines.push(`Defense events: ${def.totalEvents ?? total} recorded` +
322
+ (total > 0
323
+ ? ` (precondition_failed:${counts.precondition_failed ?? 0}, rollback:${counts.rollback ?? 0}, invariant_violated:${counts.invariant_violated ?? 0}, assumption_falsified:${counts.assumption_falsified ?? 0})`
324
+ : ''));
325
+ }
306
326
  return [{ type: 'text', text: lines.filter(Boolean).join('\n') }];
307
327
  },
308
328
  },
@@ -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)}` };
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { defineTool } from '@deepseek-ai/dsh-tools';
9
9
  import { resolveProjectRootForExec } from "../config-loader.js";
10
- import { readExperienceBank, writeExperienceBank, searchExperienceEntries, upsertExperience } from "./experience-store.js";
10
+ import { readExperienceBank, writeExperienceBank, searchExperienceEntries, upsertExperience, removeExperience } from "./experience-store.js";
11
11
  const DEFAULT_LIMIT = 50;
12
12
  const MAX_LIMIT = 100;
13
13
  /** Clamp a caller-supplied limit to a sane range. */
@@ -67,17 +67,21 @@ 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. ' +
73
76
  '"add" upserts an experience entry into .iterate/experience.json — a repeat of the same ' +
74
77
  'pattern+dimension increments its hit count instead of duplicating it. ' +
78
+ '"remove" deletes an entry by id (useful for pruning stale or incorrect experiences). ' +
75
79
  'Use it to remember fixes that worked so future rounds apply them first.',
76
80
  parameters: {
77
81
  operation: {
78
82
  type: 'string',
79
- description: 'Operation: list (browse all), search (by query), get (by id), add (add a new experience). Default: list.',
80
- enum: ['list', 'search', 'get', 'add'],
83
+ description: 'Operation: list (browse all), search (by query), get (by id), add (add a new experience), remove (delete by id). Default: list.',
84
+ enum: ['list', 'search', 'get', 'add', 'remove'],
81
85
  },
82
86
  query: {
83
87
  type: 'string',
@@ -144,6 +148,9 @@ export function registerExperienceBankTool(ctx) {
144
148
  `Tags: ${entry.tags.join(', ')}`,
145
149
  ].join('\n') }];
146
150
  }
151
+ if (value.operation === 'remove') {
152
+ return [{ type: 'text', text: `Removed experience entry. Bank now holds ${value.count ?? 0} entries (${value.totalHits ?? 0} cumulative hits).` }];
153
+ }
147
154
  if (value.operation === 'get' && value.entry) {
148
155
  const entry = value.entry;
149
156
  return [{ type: 'text', text: [
@@ -201,6 +208,38 @@ export function registerExperienceBankTool(ctx) {
201
208
  totalHits: next.totalHits,
202
209
  };
203
210
  }
211
+ if (operation === 'remove') {
212
+ const id = typeof args.id === 'string' && args.id ? args.id : '';
213
+ if (!id) {
214
+ return {
215
+ ok: false,
216
+ kind: 'experience',
217
+ operation: 'remove',
218
+ error: 'id is required for remove',
219
+ };
220
+ }
221
+ const bank = readExperienceBank(projectRoot);
222
+ const { bank: next, removed } = removeExperience(bank, id);
223
+ if (!removed) {
224
+ return {
225
+ ok: false,
226
+ kind: 'experience',
227
+ operation: 'remove',
228
+ error: `Experience not found: ${id}`,
229
+ };
230
+ }
231
+ const write = writeExperienceBank(projectRoot, next);
232
+ if (!write.ok) {
233
+ return { ok: false, kind: 'experience', operation: 'remove', error: write.error };
234
+ }
235
+ return {
236
+ ok: true,
237
+ kind: 'experience',
238
+ operation: 'remove',
239
+ count: next.entries.length,
240
+ totalHits: next.totalHits,
241
+ };
242
+ }
204
243
  const bank = readExperienceBank(projectRoot);
205
244
  if (operation === 'get' && typeof args.id === 'string') {
206
245
  const entry = bank.entries.find((e) => e.id === args.id);
@@ -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)}` };
@@ -136,3 +140,27 @@ export function upsertExperience(bank, entry) {
136
140
  entryId: id,
137
141
  };
138
142
  }
143
+ /**
144
+ * Remove an experience entry by id.
145
+ * Returns the resulting bank plus whether an entry was actually removed. An
146
+ * unknown id is a no-op (the caller decides how to surface it). Never mutates
147
+ * the input bank.
148
+ */
149
+ export function removeExperience(bank, id) {
150
+ const target = typeof id === 'string' && id ? id : '';
151
+ if (!target)
152
+ return { bank, removed: false };
153
+ const entries = bank.entries.filter((e) => e.id !== target);
154
+ if (entries.length === bank.entries.length)
155
+ return { bank, removed: false };
156
+ const lastUpdated = new Date().toISOString();
157
+ return {
158
+ bank: {
159
+ ...bank,
160
+ entries,
161
+ lastUpdated,
162
+ totalHits: bank.totalHits ?? 0,
163
+ },
164
+ removed: true,
165
+ };
166
+ }