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
@@ -10,9 +10,10 @@
10
10
  * config, always back up before writing, roll back on failure.
11
11
  */
12
12
 
13
- import { copyFileSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
13
+ import { copyFileSync, existsSync, readFileSync, rmSync } from 'node:fs'
14
14
  import { join } from 'node:path'
15
15
  import yaml from 'js-yaml'
16
+ import { writeTextAtomic } from './atomic-fs.ts'
16
17
 
17
18
  /** Config file name (must match config-loader). */
18
19
  export const CONFIG_FILE = 'iterate.config.yaml'
@@ -194,7 +195,7 @@ export function writeConfigFile(
194
195
  }
195
196
 
196
197
  try {
197
- writeFileSync(configPath, yaml.dump(config, { noRefs: true }), 'utf-8')
198
+ writeTextAtomic(configPath, yaml.dump(config, { noRefs: true }))
198
199
  } catch (err) {
199
200
  let rollbackError = ''
200
201
  try {
package/src/live.ts CHANGED
@@ -24,8 +24,9 @@
24
24
  * or crash a tool call.
25
25
  */
26
26
 
27
- import { mkdir, readFile, writeFile, stat, appendFile, rename } from 'node:fs/promises'
27
+ import { mkdir, readFile, stat, appendFile } from 'node:fs/promises'
28
28
  import { existsSync } from 'node:fs'
29
+ import { writeTextAtomicAsync } from './atomic-fs.ts'
29
30
  import { join } from 'node:path'
30
31
  import type { Context } from '@deepseek-ai/cordis'
31
32
  import type { ToolExecution } from '@deepseek-ai/dsh-tools'
@@ -106,6 +107,11 @@ export function classifyTool(
106
107
  iterate_status: 'info',
107
108
  iterate_config: 'info',
108
109
  iterate_context: 'info',
110
+ // v3.0 quality command-center tools — surface their activity in the live
111
+ // feed too (previously these three never produced a live entry).
112
+ iterate_experience: 'info',
113
+ iterate_quality_gate: 'info',
114
+ iterate_defense_events: 'info',
109
115
  }
110
116
  const type = records[name]
111
117
  if (!type) return null
@@ -136,9 +142,9 @@ export async function appendLive(projectRoot: string, entry: LiveActivityEntry):
136
142
  const raw = await readFile(file, 'utf-8')
137
143
  const lines = raw.split('\n').filter(Boolean)
138
144
  const tail = lines.slice(-LIVE_MAX_ENTRIES)
139
- const tmp = `${file}.trim.tmp`
140
- await writeFile(tmp, tail.join('\n') + '\n', 'utf-8')
141
- await rename(tmp, file)
145
+ // Atomic rewrite (unique temp + rename) so a crash mid-trim never
146
+ // truncates the live feed; the temp file is prunable by iterate_prune.
147
+ await writeTextAtomicAsync(file, tail.join('\n') + '\n')
142
148
  }
143
149
  await appendFile(file, line, 'utf-8')
144
150
  } catch {
@@ -24,8 +24,8 @@ You have the iterate plugin installed, which registers these tools:
24
24
  - \`iterate_history\` — inspect the runtime state in detail: decision-log entries and applied fixes (optionally scoped to a round or a fixed file)
25
25
  - \`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.
26
26
  - \`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.
27
- - \`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.
28
- - \`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\`.
27
+ - \`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.
28
+ - \`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.
29
29
  - \`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).
30
30
 
31
31
  ### When to use
@@ -9,10 +9,11 @@
9
9
  * Checkpoint layout: `.iterate/checkpoint.json`.
10
10
  */
11
11
 
12
- import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
12
+ import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
13
13
  import { defineTool } from '@deepseek-ai/dsh-tools'
14
14
  import type { JsonValue } from '@deepseek-ai/dsh-util-values'
15
15
  import { resolveProjectRootForExec } from '../config-loader.ts'
16
+ import { writeJsonAtomic } from '../atomic-fs.ts'
16
17
  import { checkpointPath, iterateDir, transcriptPath } from '../paths.ts'
17
18
  import { readRegistry } from './fix.ts'
18
19
  import { readDecisionEntries } from './decision-log.ts'
@@ -223,10 +224,7 @@ export function registerCheckpointTool(ctx: { tools: { register: (def: ReturnTyp
223
224
  }
224
225
  try {
225
226
  mkdirSync(iterateDir(projectRoot), { recursive: true })
226
- const cpPath = checkpointPath(projectRoot)
227
- const tmpPath = `${cpPath}.tmp-${Date.now()}`
228
- writeFileSync(tmpPath, JSON.stringify(resumed, null, 2), 'utf-8')
229
- renameSync(tmpPath, cpPath)
227
+ writeJsonAtomic(checkpointPath(projectRoot), resumed)
230
228
  } catch (err) {
231
229
  return { operation: 'resume', ok: false, error: `failed to persist resumed checkpoint: ${String(err)}` }
232
230
  }
@@ -266,12 +264,10 @@ export function registerCheckpointTool(ctx: { tools: { register: (def: ReturnTyp
266
264
  }
267
265
  try {
268
266
  mkdirSync(iterateDir(projectRoot), { recursive: true })
269
- // Atomic write (temp + rename): a crash mid-write must not corrupt
270
- // the checkpoint and silently lose the interruption state.
271
- const cpPath = checkpointPath(projectRoot)
272
- const tmpPath = `${cpPath}.tmp-${Date.now()}`
273
- writeFileSync(tmpPath, JSON.stringify(checkpoint, null, 2), 'utf-8')
274
- renameSync(tmpPath, cpPath)
267
+ // Atomic write (temp + rename, via writeJsonAtomic): a crash
268
+ // mid-write must not corrupt the checkpoint and silently lose the
269
+ // interruption state.
270
+ writeJsonAtomic(checkpointPath(projectRoot), checkpoint)
275
271
  } catch (err) {
276
272
  return { operation: 'save', ok: false, error: `failed to write checkpoint: ${String(err)}` }
277
273
  }
@@ -294,6 +290,9 @@ export function registerStatusTool(ctx: { tools: { register: (def: ReturnType<ty
294
290
  ctx.tools.register(
295
291
  defineTool({
296
292
  name: 'iterate_status',
293
+ // Read-only aggregation of on-disk state → safe to join a parallel
294
+ // dispatch group alongside other read-only sibling calls.
295
+ isConcurrencySafe: () => true,
297
296
  description:
298
297
  'Summarize the current iterate run: mode, current round vs total, fixes applied, architectural ' +
299
298
  'findings remaining, decision-log size, and whether a resume checkpoint exists.',
@@ -336,6 +335,33 @@ export function registerStatusTool(ctx: { tools: { register: (def: ReturnType<ty
336
335
  `Checkpoint: ${value.hasCheckpoint ? 'yes' : 'no'}${value.interrupted ? ' (interrupted — resumable)' : ''}${value.resumeCount ? ` · resumed ${value.resumeCount}x` : ''}`,
337
336
  value.lastUpdated ? `Last updated: ${value.lastUpdated}` : '',
338
337
  ]
338
+ // v3.0 quality command-center summaries (surfaced here so a single
339
+ // status read reports the whole run state in text too).
340
+ const gate = value.qualityGate as QualityGateSnapshot | null | undefined
341
+ if (gate) {
342
+ lines.push(
343
+ `Quality gate: ${gate.overallStatus.toUpperCase()} · score ${gate.overallScore} · ` +
344
+ `verification ${gate.passedChecks}/${gate.totalChecks} passed` +
345
+ (gate.failReason ? ` · ${gate.failReason}` : ''),
346
+ )
347
+ }
348
+ const exp = value.experienceBank as { totalEntries?: number; totalHits?: number } | null | undefined
349
+ if (exp) {
350
+ lines.push(
351
+ `Experience bank: ${exp.totalEntries ?? 0} entries · ${exp.totalHits ?? 0} cumulative hits`,
352
+ )
353
+ }
354
+ const def = value.defenseEvents as { totalEvents?: number; counts?: Record<DefenseEventType, number> } | null | undefined
355
+ if (def) {
356
+ const counts = def.counts ?? ({} as Record<DefenseEventType, number>)
357
+ const total = Object.values(counts).reduce((s, n) => s + (n || 0), 0)
358
+ lines.push(
359
+ `Defense events: ${def.totalEvents ?? total} recorded` +
360
+ (total > 0
361
+ ? ` (precondition_failed:${counts.precondition_failed ?? 0}, rollback:${counts.rollback ?? 0}, invariant_violated:${counts.invariant_violated ?? 0}, assumption_falsified:${counts.assumption_falsified ?? 0})`
362
+ : ''),
363
+ )
364
+ }
339
365
  return [{ type: 'text', text: lines.filter(Boolean).join('\n') }]
340
366
  },
341
367
  },
@@ -19,6 +19,20 @@ export function registerConfigTool(ctx: { tools: { register: (def: ReturnType<ty
19
19
  ctx.tools.register(
20
20
  defineTool({
21
21
  name: 'iterate_config',
22
+ // `read` never writes; `write` persists a config update → only read
23
+ // joins a parallel dispatch group.
24
+ isConcurrencySafe: (args) => (args as { operation?: unknown }).operation !== 'write',
25
+ // Pending-call card: a config read vs a validated write.
26
+ presentCall: (args) => {
27
+ const a = args as { operation?: unknown; section?: unknown }
28
+ const isWrite = a.operation === 'write'
29
+ return {
30
+ card: 'generic',
31
+ title: isWrite ? 'Update iterate.config.yaml' : 'Read iterate.config.yaml',
32
+ kind: isWrite ? 'edit' : 'read',
33
+ ...(typeof a.section === 'string' && a.section ? { rawInput: { section: a.section } } : {}),
34
+ }
35
+ },
22
36
  description:
23
37
  'Read or update the iterate.config.yaml configuration from the project root. ' +
24
38
  'Returns the full parsed config, a specific section, or validation errors. ' +
@@ -178,6 +178,9 @@ export function registerContextTool(ctx: { tools: { register: (def: ReturnType<t
178
178
  ctx.tools.register(
179
179
  defineTool({
180
180
  name: 'iterate_context',
181
+ // Read-only (reads SKILL.md / ITERATE.md) → safe to join a parallel
182
+ // dispatch group alongside other read-only sibling calls.
183
+ isConcurrencySafe: () => true,
181
184
  description:
182
185
  'Read project context files (SKILL.md and/or ITERATE.md). ' +
183
186
  'SKILL.md contains the original iterate skill instructions; it is searched in ' +
@@ -77,30 +77,50 @@ export function appendDecisionEntry(projectRoot: string, entry: DecisionLogEntry
77
77
  }
78
78
 
79
79
  /**
80
- * Read all entries from the decision log.
80
+ * Detailed log read: valid entries plus the number of corrupt lines that were
81
+ * skipped, so callers can surface silent audit-trail loss to the model/UI.
82
+ */
83
+ export interface DecisionLogRead {
84
+ entries: DecisionLogEntry[]
85
+ invalidLines: number
86
+ }
87
+
88
+ /**
89
+ * Read all entries from the decision log, plus the count of corrupt lines.
81
90
  * A single corrupt line (partial write, hand-edit) is SKIPPED, not fatal —
82
- * one bad line must never empty the whole history for every reader.
91
+ * one bad line must never empty the whole history for every reader — but it
92
+ * is counted in `invalidLines` so it never disappears silently.
83
93
  */
84
- export function readDecisionEntries(projectRoot: string): DecisionLogEntry[] {
94
+ export function readDecisionLogDetailed(projectRoot: string): DecisionLogRead {
85
95
  const filePath = join(projectRoot, LOG_DIR, LOG_FILE)
86
- if (!existsSync(filePath)) return []
96
+ if (!existsSync(filePath)) return { entries: [], invalidLines: 0 }
87
97
  let content: string
88
98
  try {
89
99
  content = readFileSync(filePath, 'utf-8')
90
100
  } catch {
91
- return []
101
+ return { entries: [], invalidLines: 0 }
92
102
  }
93
- const out: DecisionLogEntry[] = []
103
+ const entries: DecisionLogEntry[] = []
104
+ let invalidLines = 0
94
105
  for (const line of content.split('\n')) {
95
106
  const trimmed = line.trim()
96
107
  if (trimmed.length === 0) continue
97
108
  try {
98
- out.push(JSON.parse(trimmed) as DecisionLogEntry)
109
+ entries.push(JSON.parse(trimmed) as DecisionLogEntry)
99
110
  } catch {
100
- // skip the corrupt line, keep the rest
111
+ invalidLines += 1
101
112
  }
102
113
  }
103
- return out
114
+ return { entries, invalidLines }
115
+ }
116
+
117
+ /**
118
+ * Read all entries from the decision log.
119
+ * A single corrupt line (partial write, hand-edit) is SKIPPED, not fatal —
120
+ * one bad line must never empty the whole history for every reader.
121
+ */
122
+ export function readDecisionEntries(projectRoot: string): DecisionLogEntry[] {
123
+ return readDecisionLogDetailed(projectRoot).entries
104
124
  }
105
125
 
106
126
  /**
@@ -112,6 +132,9 @@ export function registerDecisionLogTool(ctx: { tools: { register: (def: ReturnTy
112
132
  ctx.tools.register(
113
133
  defineTool({
114
134
  name: 'iterate_decision_log',
135
+ // `read` never writes; `append` extends the audit log → only read joins
136
+ // a parallel dispatch group.
137
+ isConcurrencySafe: (args) => (args as { operation?: unknown }).operation === 'read',
115
138
  description:
116
139
  'Append-only decision log for the iterate loop. ' +
117
140
  'Use `append` to record a round start, review finding, fix, validation result, or decision. ' +
@@ -164,6 +187,7 @@ export function registerDecisionLogTool(ctx: { tools: { register: (def: ReturnTy
164
187
  properties: {
165
188
  operation: { type: 'string', required: true },
166
189
  entryCount: { type: 'integer' },
190
+ invalidLines: { type: 'integer' },
167
191
  logPath: { type: 'string' },
168
192
  entries: { type: 'json' },
169
193
  success: { type: 'boolean' },
@@ -184,10 +208,11 @@ export function registerDecisionLogTool(ctx: { tools: { register: (def: ReturnTy
184
208
  const projectRoot = resolved.root
185
209
 
186
210
  if (args.operation === 'read') {
187
- const entries = readDecisionEntries(projectRoot)
211
+ const { entries, invalidLines } = readDecisionLogDetailed(projectRoot)
188
212
  return {
189
213
  operation: 'read',
190
214
  entryCount: entries.length,
215
+ invalidLines,
191
216
  logPath: join(projectRoot, LOG_DIR, LOG_FILE),
192
217
  entries: entries as unknown as JsonValue,
193
218
  }
@@ -91,6 +91,9 @@ export function registerDefenseEventsTool(ctx: { tools: { register: (def: Return
91
91
  ctx.tools.register(
92
92
  defineTool({
93
93
  name: 'iterate_defense_events',
94
+ // List/counts never write; only `record` persists an event → read shapes
95
+ // join a parallel dispatch group.
96
+ isConcurrencySafe: (args) => (args as { operation?: unknown }).operation !== 'record',
94
97
  description:
95
98
  'Query or record defense events: precondition failures, rollbacks, invariant violations, ' +
96
99
  'and assumption falsifications. ' +
@@ -7,6 +7,8 @@
7
7
 
8
8
  import * as fs from 'node:fs'
9
9
  import * as path from 'node:path'
10
+ import { iterateDir } from '../paths.ts'
11
+ import { writeJsonAtomic } from '../atomic-fs.ts'
10
12
  import type { DefenseEvent, DefenseEventStream, DefenseEventType } from '../types.ts'
11
13
 
12
14
  const DEFENSE_EVENTS_FILE = 'defense-events.json'
@@ -83,14 +85,16 @@ export function writeDefenseEvents(
83
85
  projectRoot: string,
84
86
  stream: DefenseEventStream,
85
87
  ): { ok: true } | { ok: false; error: string } {
86
- const dirPath = path.join(projectRoot, '.iterate')
88
+ const dirPath = iterateDir(projectRoot)
87
89
  const filePath = path.join(dirPath, DEFENSE_EVENTS_FILE)
88
90
 
89
91
  try {
90
92
  if (!fs.existsSync(dirPath)) {
91
93
  fs.mkdirSync(dirPath, { recursive: true })
92
94
  }
93
- fs.writeFileSync(filePath, JSON.stringify(stream, null, 2), 'utf-8')
95
+ // Atomic (temp + rename): a crash mid-write can never leave a truncated
96
+ // defense event stream behind.
97
+ writeJsonAtomic(filePath, stream)
94
98
  } catch (err) {
95
99
  return { ok: false, error: `unable to write ${filePath}: ${String(err)}` }
96
100
  }
@@ -9,7 +9,7 @@
9
9
  import { defineTool } from '@deepseek-ai/dsh-tools'
10
10
  import type { JsonValue } from '@deepseek-ai/dsh-util-values'
11
11
  import { resolveProjectRootForExec } from '../config-loader.ts'
12
- import { readExperienceBank, writeExperienceBank, searchExperienceEntries, upsertExperience } from './experience-store.ts'
12
+ import { readExperienceBank, writeExperienceBank, searchExperienceEntries, upsertExperience, removeExperience } from './experience-store.ts'
13
13
  import type { ExperienceEntryInput } from './experience-store.ts'
14
14
  import type { ExperienceEntry } from '../types.ts'
15
15
 
@@ -72,18 +72,22 @@ export function registerExperienceBankTool(ctx: { tools: { register: (def: Retur
72
72
  ctx.tools.register(
73
73
  defineTool({
74
74
  name: 'iterate_experience',
75
+ // List/search/get never write; only `add` upserts the bank → read shapes
76
+ // join a parallel dispatch group.
77
+ isConcurrencySafe: (args) => (args as { operation?: unknown }).operation !== 'add',
75
78
  description:
76
79
  'Query or extend the experience bank: browse/search historical fixes and patterns, ' +
77
80
  'or record a new verified fix (operation:"add"). ' +
78
81
  'List/search/get return matching entries with hit counts, verified fixes, and related context. ' +
79
82
  '"add" upserts an experience entry into .iterate/experience.json — a repeat of the same ' +
80
83
  'pattern+dimension increments its hit count instead of duplicating it. ' +
84
+ '"remove" deletes an entry by id (useful for pruning stale or incorrect experiences). ' +
81
85
  'Use it to remember fixes that worked so future rounds apply them first.',
82
86
  parameters: {
83
87
  operation: {
84
88
  type: 'string',
85
- description: 'Operation: list (browse all), search (by query), get (by id), add (add a new experience). Default: list.',
86
- enum: ['list', 'search', 'get', 'add'],
89
+ description: 'Operation: list (browse all), search (by query), get (by id), add (add a new experience), remove (delete by id). Default: list.',
90
+ enum: ['list', 'search', 'get', 'add', 'remove'],
87
91
  },
88
92
  query: {
89
93
  type: 'string',
@@ -151,6 +155,9 @@ export function registerExperienceBankTool(ctx: { tools: { register: (def: Retur
151
155
  `Tags: ${entry.tags.join(', ')}`,
152
156
  ].join('\n') }]
153
157
  }
158
+ if (value.operation === 'remove') {
159
+ return [{ type: 'text', text: `Removed experience entry. Bank now holds ${value.count ?? 0} entries (${value.totalHits ?? 0} cumulative hits).` }]
160
+ }
154
161
  if (value.operation === 'get' && value.entry) {
155
162
  const entry = value.entry as unknown as ExperienceEntry
156
163
  return [{ type: 'text', text: [
@@ -211,6 +218,39 @@ export function registerExperienceBankTool(ctx: { tools: { register: (def: Retur
211
218
  }
212
219
  }
213
220
 
221
+ if (operation === 'remove') {
222
+ const id = typeof args.id === 'string' && args.id ? args.id : ''
223
+ if (!id) {
224
+ return {
225
+ ok: false,
226
+ kind: 'experience',
227
+ operation: 'remove',
228
+ error: 'id is required for remove',
229
+ }
230
+ }
231
+ const bank = readExperienceBank(projectRoot)
232
+ const { bank: next, removed } = removeExperience(bank, id)
233
+ if (!removed) {
234
+ return {
235
+ ok: false,
236
+ kind: 'experience',
237
+ operation: 'remove',
238
+ error: `Experience not found: ${id}`,
239
+ }
240
+ }
241
+ const write = writeExperienceBank(projectRoot, next)
242
+ if (!write.ok) {
243
+ return { ok: false, kind: 'experience', operation: 'remove', error: write.error }
244
+ }
245
+ return {
246
+ ok: true,
247
+ kind: 'experience',
248
+ operation: 'remove',
249
+ count: next.entries.length,
250
+ totalHits: next.totalHits,
251
+ }
252
+ }
253
+
214
254
  const bank = readExperienceBank(projectRoot)
215
255
 
216
256
  if (operation === 'get' && typeof args.id === 'string') {
@@ -7,6 +7,8 @@
7
7
 
8
8
  import * as fs from 'node:fs'
9
9
  import * as path from 'node:path'
10
+ import { iterateDir } from '../paths.ts'
11
+ import { writeJsonAtomic } from '../atomic-fs.ts'
10
12
  import type { ExperienceBank, ExperienceEntry } from '../types.ts'
11
13
 
12
14
  const EXPERIENCE_FILE = 'experience.json'
@@ -45,14 +47,16 @@ export function writeExperienceBank(
45
47
  projectRoot: string,
46
48
  bank: ExperienceBank,
47
49
  ): { ok: true } | { ok: false; error: string } {
48
- const dirPath = path.join(projectRoot, '.iterate')
50
+ const dirPath = iterateDir(projectRoot)
49
51
  const filePath = path.join(dirPath, EXPERIENCE_FILE)
50
52
 
51
53
  try {
52
54
  if (!fs.existsSync(dirPath)) {
53
55
  fs.mkdirSync(dirPath, { recursive: true })
54
56
  }
55
- fs.writeFileSync(filePath, JSON.stringify(bank, null, 2), 'utf-8')
57
+ // Atomic (temp + rename): a crash mid-write can never leave a truncated
58
+ // experience bank behind.
59
+ writeJsonAtomic(filePath, bank)
56
60
  } catch (err) {
57
61
  return { ok: false, error: `unable to write ${filePath}: ${String(err)}` }
58
62
  }
@@ -167,3 +171,29 @@ export function upsertExperience(
167
171
  entryId: id,
168
172
  }
169
173
  }
174
+
175
+ /**
176
+ * Remove an experience entry by id.
177
+ * Returns the resulting bank plus whether an entry was actually removed. An
178
+ * unknown id is a no-op (the caller decides how to surface it). Never mutates
179
+ * the input bank.
180
+ */
181
+ export function removeExperience(
182
+ bank: ExperienceBank,
183
+ id: string,
184
+ ): { bank: ExperienceBank; removed: boolean } {
185
+ const target = typeof id === 'string' && id ? id : ''
186
+ if (!target) return { bank, removed: false }
187
+ const entries = bank.entries.filter((e) => e.id !== target)
188
+ if (entries.length === bank.entries.length) return { bank, removed: false }
189
+ const lastUpdated = new Date().toISOString()
190
+ return {
191
+ bank: {
192
+ ...bank,
193
+ entries,
194
+ lastUpdated,
195
+ totalHits: bank.totalHits ?? 0,
196
+ },
197
+ removed: true,
198
+ }
199
+ }
package/src/tools/fix.ts CHANGED
@@ -17,7 +17,8 @@
17
17
  * - Atomicity is enforced against `config.atomic.max_lines` unless `force`.
18
18
  */
19
19
 
20
- import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
20
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs'
21
+ import { writeJsonAtomic, writeTextAtomic } from '../atomic-fs.ts'
21
22
  import { join, sep } from 'node:path'
22
23
  import { defineTool } from '@deepseek-ai/dsh-tools'
23
24
  import type { JsonValue } from '@deepseek-ai/dsh-util-values'
@@ -26,6 +27,7 @@ import { runWithJob } from '../jobs.ts'
26
27
  import { countTouchedMethods } from '../method-scope.ts'
27
28
  import { fixBackupPath, fixRegistryPath, fixesDir } from '../paths.ts'
28
29
  import { appendDecisionEntry } from './decision-log.ts'
30
+ import { markFixRolledBackInTranscript } from './transcript.ts'
29
31
  import type { FileDiffHunk, FixRecord, FixRegistry, ReviewFinding } from '../types.ts'
30
32
 
31
33
  // ─── Constants ───────────────────────────────────────────────────────────────
@@ -277,6 +279,19 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
277
279
  ctx.tools.register(
278
280
  defineTool({
279
281
  name: 'iterate_fix',
282
+ // Pending-call card: surface the file about to change as an inline diff.
283
+ // oldText is null — a call-time presenter has no access to the file's
284
+ // prior content (an overwrite, per the presentation contract).
285
+ presentCall: (args) => {
286
+ const a = args as { file?: unknown; content?: unknown }
287
+ if (typeof a.file !== 'string' || typeof a.content !== 'string') return undefined
288
+ return {
289
+ card: 'diff',
290
+ title: `Fix ${a.file}`,
291
+ diffs: [{ path: a.file, oldText: null, newText: a.content }],
292
+ locations: [{ path: a.file }],
293
+ }
294
+ },
280
295
  description:
281
296
  'Apply ONE atomic fix to a file. Pass the target relative `file`, the finding that motivated ' +
282
297
  'the fix, the NEW full `content` of that file (after your edit), and the current `round`. ' +
@@ -454,7 +469,7 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
454
469
  }
455
470
 
456
471
  try {
457
- writeFileSync(target.resolved, args.content, 'utf-8')
472
+ writeTextAtomic(target.resolved, args.content)
458
473
  } catch (err) {
459
474
  return { ok: false, error: `failed to write file: ${String(err)}` }
460
475
  }
@@ -472,7 +487,7 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
472
487
  }
473
488
  const nextRegistry = upsertRecord(registry, record)
474
489
  try {
475
- writeFileSync(fixRegistryPath(projectRoot), JSON.stringify(nextRegistry, null, 2), 'utf-8')
490
+ writeJsonAtomic(fixRegistryPath(projectRoot), nextRegistry)
476
491
  } catch (err) {
477
492
  // Registry write failed → the file was already modified but no record
478
493
  // exists, so a later rollback/diff could never see it and a retry would
@@ -524,6 +539,9 @@ export function registerDiffTool(ctx: { tools: { register: (def: ReturnType<type
524
539
  ctx.tools.register(
525
540
  defineTool({
526
541
  name: 'iterate_diff',
542
+ // Read-only (never writes project files or plugin state) → safe to join
543
+ // a parallel dispatch group alongside other read-only sibling calls.
544
+ isConcurrencySafe: () => true,
527
545
  description:
528
546
  'Show the changes made by iterate fixes. With `file`, returns the unified diff of the current ' +
529
547
  'file content vs its original (first backup). Without `file`, returns a summary of every fixed file.',
@@ -627,6 +645,17 @@ export function registerRollbackTool(ctx: { tools: { register: (def: ReturnType<
627
645
  ctx.tools.register(
628
646
  defineTool({
629
647
  name: 'iterate_rollback',
648
+ // Pending-call card: which fix is about to be reverted.
649
+ presentCall: (args) => {
650
+ const a = args as { id?: unknown }
651
+ if (typeof a.id !== 'string' || a.id.length === 0) return undefined
652
+ return {
653
+ card: 'generic',
654
+ title: `Rollback fix ${a.id}`,
655
+ kind: 'edit',
656
+ rawInput: { id: a.id },
657
+ }
658
+ },
630
659
  description:
631
660
  'Revert a previously applied fix. Pass the fix `id` (returned by iterate_fix). ' +
632
661
  'The file is restored from the fix backup, the fix is removed from the registry, ' +
@@ -683,7 +712,7 @@ export function registerRollbackTool(ctx: { tools: { register: (def: ReturnType<
683
712
 
684
713
  const nextRegistry = removeRecord(registry, id)
685
714
  try {
686
- writeFileSync(fixRegistryPath(projectRoot), JSON.stringify(nextRegistry, null, 2), 'utf-8')
715
+ writeJsonAtomic(fixRegistryPath(projectRoot), nextRegistry)
687
716
  } catch (err) {
688
717
  return { ok: false, error: `failed to update fix registry: ${String(err)}` }
689
718
  }
@@ -695,6 +724,12 @@ export function registerRollbackTool(ctx: { tools: { register: (def: ReturnType<
695
724
  data: { id, file: record.finding.file, revertedDiff: record.diffSummary },
696
725
  })
697
726
 
727
+ // Mirror the reversal into the persisted observatory transcript (F4) so
728
+ // the client no longer shows a rolled-back fix as "成功". Best-effort:
729
+ // a missing/corrupt transcript is left untouched and never breaks the
730
+ // rollback flow.
731
+ await markFixRolledBackInTranscript(projectRoot, id)
732
+
698
733
  return { ok: true, id, file: record.finding.file }
699
734
  },
700
735
  }),
@@ -80,6 +80,9 @@ export function registerHistoryTool(ctx: { tools: { register: (def: ReturnType<t
80
80
  ctx.tools.register(
81
81
  defineTool({
82
82
  name: 'iterate_history',
83
+ // Read-only audit view over on-disk state → safe to join a parallel
84
+ // dispatch group alongside other read-only sibling calls.
85
+ isConcurrencySafe: () => true,
83
86
  description:
84
87
  'Read the iteration history: decision-log entries (optionally filtered by entry `type`, `since` ' +
85
88
  'timestamp, and a `limit`) plus a summary of the fix registry (per-round fixed/failed counts). ' +