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
@@ -59,6 +59,9 @@ function sanitizeRoundSeries(raw) {
59
59
  export function registerQualityGateTool(ctx) {
60
60
  ctx.tools.register(defineTool({
61
61
  name: 'iterate_quality_gate',
62
+ // `read` never writes; `compute` persists the snapshot → only read
63
+ // joins a parallel dispatch group.
64
+ isConcurrencySafe: (args) => args.operation === 'read',
62
65
  description: 'Query or write the quality gate status: dimension convergence rates, verification pass rates, ' +
63
66
  'and overall PASS/FAIL status. ' +
64
67
  'Operation "read" (default) returns the persisted machine-readable quality certificate. ' +
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import * as fs from 'node:fs';
9
9
  import * as path from 'node:path';
10
+ import { writeJsonAtomic } from "../atomic-fs.js";
10
11
  const QUALITY_GATE_FILE = 'quality-gate.json';
11
12
  /** Default empty quality gate snapshot. */
12
13
  function emptySnapshot() {
@@ -96,7 +97,7 @@ export function writeQualityGate(projectRoot, snapshot) {
96
97
  if (!fs.existsSync(dirPath)) {
97
98
  fs.mkdirSync(dirPath, { recursive: true });
98
99
  }
99
- fs.writeFileSync(filePath, JSON.stringify(snapshot, null, 2), 'utf-8');
100
+ writeJsonAtomic(filePath, snapshot);
100
101
  }
101
102
  catch (err) {
102
103
  return { ok: false, error: `unable to write ${filePath}: ${String(err)}` };
@@ -18,8 +18,9 @@
18
18
  * tmp+rename so a crashed writer never leaves a corrupt manifest.
19
19
  */
20
20
  import { defineTool } from '@deepseek-ai/dsh-tools';
21
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
21
+ import { mkdir, readFile } from 'node:fs/promises';
22
22
  import { existsSync } from 'node:fs';
23
+ import { writeTextAtomicAsync } from "../atomic-fs.js";
23
24
  import { dirname } from 'node:path';
24
25
  import { loadEffectiveConfig, resolveProjectRootForExec, } from "../config-loader.js";
25
26
  import { transcriptPath } from "../paths.js";
@@ -96,6 +97,9 @@ function normalizeFix(input) {
96
97
  export function registerTranscriptTool(ctx) {
97
98
  ctx.tools.register(defineTool({
98
99
  name: 'iterate_transcript',
100
+ // `read` never writes; `capture`/`nudge` persist transcript state → only
101
+ // read joins a parallel dispatch group.
102
+ isConcurrencySafe: (args) => args.operation === 'read',
99
103
  description: 'Runtime-observatory transcript for the iterate workflow. ' +
100
104
  '`read` returns the current persisted transcript manifest (per-reviewer threads, ' +
101
105
  'convergence series, findings, fixes, checkpoint, timeline, and any steering nudge ' +
@@ -322,10 +326,8 @@ function rehydrateBuilder(manifest, approval) {
322
326
  builder.finish();
323
327
  return builder;
324
328
  }
325
- /** Atomically persist a manifest (tmp + rename) under `.iterate/`. */
329
+ /** Atomically persist a manifest (unique temp + rename) under `.iterate/`. */
326
330
  async function persist(file, manifest) {
327
331
  await mkdir(dirname(file), { recursive: true });
328
- const tmp = `${file}.tmp`;
329
- await writeFile(tmp, JSON.stringify(manifest, null, 2), 'utf-8');
330
- await rename(tmp, file);
332
+ await writeTextAtomicAsync(file, JSON.stringify(manifest, null, 2));
331
333
  }
@@ -1,8 +1,9 @@
1
- import { copyFileSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
1
+ import { copyFileSync, existsSync, readFileSync, rmSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { defineTool } from '@deepseek-ai/dsh-tools';
4
4
  import yaml from 'js-yaml';
5
5
  import { resolveProjectRootForExec } from "../config-loader.js";
6
+ import { writeTextAtomic } from "../atomic-fs.js";
6
7
  const CONFIG_FILE = 'iterate.config.yaml';
7
8
  /** Personalization key that holds the known-intentional list. */
8
9
  const PERSONALIZATION_KEY = 'personalization';
@@ -191,7 +192,7 @@ function applyEntries(projectRoot, incoming) {
191
192
  }
192
193
  const yamlText = yaml.dump(nextConfig, { noRefs: true });
193
194
  try {
194
- writeFileSync(configPath, yamlText, 'utf-8');
195
+ writeTextAtomic(configPath, yamlText);
195
196
  }
196
197
  catch (err) {
197
198
  // Rollback: restore the backup, or REMOVE the file we just created when
@@ -231,6 +232,9 @@ function applyEntries(projectRoot, incoming) {
231
232
  export function registerTriageTool(ctx) {
232
233
  ctx.tools.register(defineTool({
233
234
  name: 'iterate_triage',
235
+ // `list` is a pure config read; `apply` rewrites iterate.config.yaml →
236
+ // only list joins a parallel dispatch group.
237
+ isConcurrencySafe: (args) => args.operation === 'list',
234
238
  description: 'Manage `personalization.known_intentional` entries in iterate.config.yaml. ' +
235
239
  'Use `apply` to write back triage verdicts (entries where the reviewer said "known intentional") so ' +
236
240
  'future review rounds filter them out. Entries are deduped by file|dimension|line and the config is ' +
@@ -19,8 +19,14 @@ export function clampTimeout(ms) {
19
19
  /**
20
20
  * Run a single shell command with timeout and return structured results.
21
21
  * Pure function (no side effects beyond the exec call).
22
+ *
23
+ * `signal` (the caller's `exec.signal` per the dsh tools contract) is forwarded
24
+ * to the child process so a cancelled tool call kills the running command
25
+ * instead of pinning the dispatch open until the timeout elapses. The result
26
+ * distinguishes "cancelled by the caller" (`canceled: true`) from "ran past
27
+ * `timeoutMs`" (`timedOut: true`).
22
28
  */
23
- async function runCommand(command, cwd, timeoutMs) {
29
+ export async function runCommand(command, cwd, timeoutMs, signal) {
24
30
  const start = performance.now();
25
31
  return new Promise((resolve) => {
26
32
  exec(command, {
@@ -28,18 +34,23 @@ async function runCommand(command, cwd, timeoutMs) {
28
34
  timeout: timeoutMs,
29
35
  maxBuffer: 10 * 1024 * 1024, // 10 MB
30
36
  env: { ...process.env, PAGER: 'cat' },
37
+ signal,
31
38
  }, (error, stdout, stderr) => {
32
39
  const durationMs = Math.round(performance.now() - start);
33
40
  // error.code is the exit code when the command ran; when the binary
34
41
  // cannot be spawned Node sets error.code to a STRING ('ENOENT' etc).
35
42
  // Coerce to a number so the integer output schema is never violated.
36
43
  const exitCode = typeof error?.code === 'number' ? error.code : (error ? 1 : 0);
44
+ const canceled = signal?.aborted === true;
37
45
  resolve({
38
46
  command,
39
47
  exitCode,
40
48
  stdout: stdout ?? '',
41
49
  stderr: stderr ?? '',
42
- timedOut: error?.killed === true,
50
+ // A caller cancellation also kills the child (killed === true), but
51
+ // that is not a timeout — only report timedOut for the deadline path.
52
+ timedOut: error?.killed === true && !canceled,
53
+ canceled,
43
54
  durationMs,
44
55
  });
45
56
  });
@@ -53,6 +64,19 @@ async function runCommand(command, cwd, timeoutMs) {
53
64
  export function registerValidateTool(ctx) {
54
65
  ctx.tools.register(defineTool({
55
66
  name: 'iterate_validate',
67
+ // Pending-call card: render the pending validation as a terminal card.
68
+ // Pure — derived from args only; no `cwd` (the UI bridge resolves the
69
+ // relative command against the session workspace).
70
+ presentCall: (args) => {
71
+ const a = args;
72
+ if (typeof a.command !== 'string' || a.command.length === 0)
73
+ return undefined;
74
+ return {
75
+ card: 'terminal',
76
+ title: a.command,
77
+ description: 'Run a preconfigured iterate validation command (exact match on validation.commands).',
78
+ };
79
+ },
56
80
  description: 'Run a validation command that is PRECONFIGURED in iterate.config.yaml `validation.commands`. ' +
57
81
  'The command must exactly match one of the configured commands (they are the only ones the user trusts). ' +
58
82
  'Returns exit code, stdout, stderr, and duration. ' +
@@ -83,6 +107,7 @@ export function registerValidateTool(ctx) {
83
107
  stdout: { type: 'string', required: true },
84
108
  stderr: { type: 'string', required: true },
85
109
  timedOut: { type: 'boolean', required: true },
110
+ canceled: { type: 'boolean', required: true },
86
111
  durationMs: { type: 'integer', required: true },
87
112
  rejectReason: { type: 'string' },
88
113
  },
@@ -96,6 +121,7 @@ export function registerValidateTool(ctx) {
96
121
  `Exit code: ${value.exitCode}`,
97
122
  `Duration: ${value.durationMs}ms`,
98
123
  value.timedOut ? '⚠ Timed out' : '',
124
+ value.canceled ? '⚠ Cancelled before completion' : '',
99
125
  '',
100
126
  value.stdout ? `[stdout]\n${value.stdout}` : '',
101
127
  value.stderr ? `[stderr]\n${value.stderr}` : '',
@@ -116,6 +142,7 @@ export function registerValidateTool(ctx) {
116
142
  stdout: '',
117
143
  stderr: '',
118
144
  timedOut: false,
145
+ canceled: false,
119
146
  durationMs: 0,
120
147
  rejectReason: resolved.reason,
121
148
  };
@@ -137,6 +164,7 @@ export function registerValidateTool(ctx) {
137
164
  stdout: '',
138
165
  stderr: '',
139
166
  timedOut: false,
167
+ canceled: false,
140
168
  durationMs: 0,
141
169
  rejectReason: (source === 'defaults'
142
170
  ? 'No iterate.config.yaml at project root — running on built-in defaults, which configure NO trusted validation commands. '
@@ -152,12 +180,13 @@ export function registerValidateTool(ctx) {
152
180
  stdout: '',
153
181
  stderr: '',
154
182
  timedOut: false,
183
+ canceled: false,
155
184
  durationMs: 0,
156
185
  rejectReason: `Command must exactly match a command predefined in iterate.config.yaml validation.commands. ` +
157
186
  `Allowed commands: ${predefinedCommands.join(' | ')}`,
158
187
  };
159
188
  }
160
- const result = await runCommand(args.command, projectRoot, timeout);
189
+ const result = await runCommand(args.command, projectRoot, timeout, exec.signal);
161
190
  return {
162
191
  allowed: true,
163
192
  ...result,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iterate-plugin",
3
- "version": "3.4.1",
3
+ "version": "3.5.0",
4
4
  "description": "dsh plugin that turns the iterate skill into an autonomous closed-loop harness with quality command center and experience bank (v3.3). Features: plan -> parallel review xN -> atomic fixes -> validate -> loop -> auto-stop, plus dry-run pure-review mode, quality gate compute/persist, writable experience bank, defense events stream (record + bilingual labels), and native command buttons — with a live quality command center (F8/F9/F10 render real session data), §8 assign-fix instruction, and task_mode indicator wired end to end.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,83 @@
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
+
14
+ import { dirname, join } from 'node:path'
15
+ import { renameSync, rmSync, writeFileSync } from 'node:fs'
16
+ import { rename, rm, writeFile } from 'node:fs/promises'
17
+
18
+ /** Best-effort unlink of a leftover temp file (never throws). */
19
+ function cleanupTemp(tmp: string): void {
20
+ try {
21
+ rmSync(tmp, { force: true })
22
+ } catch {
23
+ /* ignore — temp files follow the prunable `.tmp-<pid>-<rand>` convention */
24
+ }
25
+ }
26
+
27
+ /** Best-effort async unlink of a leftover temp file (never throws). */
28
+ async function cleanupTempAsync(tmp: string): Promise<void> {
29
+ try {
30
+ await rm(tmp, { force: true })
31
+ } catch {
32
+ /* ignore — temp files follow the prunable `.tmp-<pid>-<rand>` convention */
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Unique temp file path for `filePath`, in the same directory, following the
38
+ * prunable `.<basename>.tmp-<pid>-<random>` convention (see prune.ts
39
+ * `isPrunableTemp`). The pid+random suffix makes concurrent writers safe.
40
+ */
41
+ function tempPathFor(filePath: string): string {
42
+ return join(
43
+ dirname(filePath),
44
+ `.${filePath.split('/').pop() ?? 'file'}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`,
45
+ )
46
+ }
47
+
48
+ /**
49
+ * Write `data` to `filePath` atomically (temp file + rename, same dir).
50
+ * Temp files follow the `<name>.tmp-<pid>-<random>` convention also used by
51
+ * prune.ts / checkpoint.ts so they are recognizable and prunable.
52
+ * Throws on failure (callers own error reporting — they map failures to
53
+ * structured tool results).
54
+ */
55
+ export function writeTextAtomic(filePath: string, data: string): void {
56
+ const tmp = tempPathFor(filePath)
57
+ writeFileSync(tmp, data, 'utf-8')
58
+ try {
59
+ renameSync(tmp, filePath)
60
+ } catch (err) {
61
+ // Best-effort temp cleanup so a failed write never litters the directory.
62
+ cleanupTemp(tmp)
63
+ throw err
64
+ }
65
+ }
66
+
67
+ /** Async variant of {@link writeTextAtomic} (same temp naming convention). */
68
+ export async function writeTextAtomicAsync(filePath: string, data: string): Promise<void> {
69
+ const tmp = tempPathFor(filePath)
70
+ await writeFile(tmp, data, 'utf-8')
71
+ try {
72
+ await rename(tmp, filePath)
73
+ } catch (err) {
74
+ // Best-effort temp cleanup so a failed write never litters the directory.
75
+ await cleanupTempAsync(tmp)
76
+ throw err
77
+ }
78
+ }
79
+
80
+ /** JSON.stringify (2-space indent) + atomic write in one call. */
81
+ export function writeJsonAtomic(filePath: string, value: unknown): void {
82
+ writeTextAtomic(filePath, JSON.stringify(value, null, 2))
83
+ }
@@ -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'
@@ -136,9 +137,9 @@ export async function appendLive(projectRoot: string, entry: LiveActivityEntry):
136
137
  const raw = await readFile(file, 'utf-8')
137
138
  const lines = raw.split('\n').filter(Boolean)
138
139
  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)
140
+ // Atomic rewrite (unique temp + rename) so a crash mid-trim never
141
+ // truncates the live feed; the temp file is prunable by iterate_prune.
142
+ await writeTextAtomicAsync(file, tail.join('\n') + '\n')
142
143
  }
143
144
  await appendFile(file, line, 'utf-8')
144
145
  } catch {
@@ -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.',
@@ -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
  }
@@ -72,6 +72,9 @@ 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"). ' +
@@ -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
  }