iterate-plugin 3.4.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +24 -9
  2. package/README.zh-CN.md +19 -9
  3. package/dist/atomic-fs.js +77 -0
  4. package/dist/config-write.js +3 -2
  5. package/dist/git-scope.js +3 -2
  6. package/dist/live.js +5 -4
  7. package/dist/skill-prompt.js +16 -5
  8. package/dist/tools/checkpoint.js +10 -11
  9. package/dist/tools/config.js +14 -0
  10. package/dist/tools/context.js +3 -0
  11. package/dist/tools/decision-log.js +25 -10
  12. package/dist/tools/defense-events.js +3 -0
  13. package/dist/tools/defense-store.js +6 -2
  14. package/dist/tools/experience-bank.js +3 -0
  15. package/dist/tools/experience-store.js +6 -2
  16. package/dist/tools/fix.js +34 -4
  17. package/dist/tools/history.js +3 -0
  18. package/dist/tools/prune.js +50 -6
  19. package/dist/tools/quality-gate.js +3 -0
  20. package/dist/tools/quality-store.js +2 -1
  21. package/dist/tools/transcript.js +7 -5
  22. package/dist/tools/triage.js +6 -2
  23. package/dist/tools/validate.js +32 -3
  24. package/lib/client.js +52 -9
  25. package/lib/parse.js +53 -6
  26. package/package.json +4 -2
  27. package/src/atomic-fs.ts +83 -0
  28. package/src/client/index.ts +12 -4
  29. package/src/config-write.ts +3 -2
  30. package/src/git-scope.ts +3 -2
  31. package/src/live.ts +5 -4
  32. package/src/skill-prompt.ts +16 -5
  33. package/src/tools/checkpoint.ts +10 -11
  34. package/src/tools/config.ts +14 -0
  35. package/src/tools/context.ts +3 -0
  36. package/src/tools/decision-log.ts +35 -10
  37. package/src/tools/defense-events.ts +3 -0
  38. package/src/tools/defense-store.ts +6 -2
  39. package/src/tools/experience-bank.ts +3 -0
  40. package/src/tools/experience-store.ts +6 -2
  41. package/src/tools/fix.ts +32 -4
  42. package/src/tools/history.ts +3 -0
  43. package/src/tools/prune.ts +51 -6
  44. package/src/tools/quality-gate.ts +3 -0
  45. package/src/tools/quality-store.ts +2 -1
  46. package/src/tools/transcript.ts +7 -5
  47. package/src/tools/triage.ts +6 -2
  48. package/src/tools/validate.ts +32 -3
  49. package/src/types.ts +2 -0
package/lib/parse.js CHANGED
@@ -133,12 +133,13 @@ export function countSessionImages(session) {
133
133
  if (!session || typeof session !== 'object') return 0
134
134
 
135
135
  const ids = new Set()
136
+ const consumed = new Set()
136
137
  let count = 0
137
138
 
138
139
  /** @param {unknown} obj */
139
140
  const walk = (obj, depth) => {
140
141
  if (depth <= 0 || !obj || typeof obj !== 'object') return
141
- if (seen.has(obj)) return
142
+ if (seen.has(obj) || consumed.has(obj)) return
142
143
  seen.add(obj)
143
144
  const o = /** @type {Record<string, unknown>} */ (obj)
144
145
 
@@ -158,6 +159,9 @@ export function countSessionImages(session) {
158
159
  } else {
159
160
  count += 1
160
161
  }
162
+ // The ref node is already counted; mark it consumed so descending into it
163
+ // (its own mediaType/attachmentId keys) does not double-count the image.
164
+ consumed.add(ref)
161
165
  }
162
166
 
163
167
  if (Array.isArray(obj)) {
@@ -364,18 +368,25 @@ function attachLive(manifest, source) {
364
368
  * manifest buried inside an arbitrary result still surfaces. Never throws.
365
369
  *
366
370
  * @param {unknown} obj
371
+ * @param {Set<unknown>} [seen]
372
+ * @param {number} [depth]
367
373
  * @returns {Record<string, unknown> | null}
368
374
  */
369
- function extractTranscript(obj) {
375
+ function extractTranscript(obj, seen, depth) {
376
+ if (depth === undefined) depth = 0
377
+ if (depth > 20) return null
370
378
  if (typeof obj === 'string') {
371
379
  try {
372
380
  const parsed = JSON.parse(obj)
373
- return extractTranscript(parsed)
381
+ return extractTranscript(parsed, seen, depth + 1)
374
382
  } catch {
375
383
  return null
376
384
  }
377
385
  }
378
386
  if (!obj || typeof obj !== 'object') return null
387
+ if (!seen) seen = new Set()
388
+ if (seen.has(obj)) return null
389
+ seen.add(obj)
379
390
 
380
391
  // Direct manifest.
381
392
  if (isTranscriptManifest(obj)) return /** @type {Record<string, unknown>} */ (obj)
@@ -397,11 +408,11 @@ function extractTranscript(obj) {
397
408
  if (val !== undefined) {
398
409
  if (Array.isArray(val)) {
399
410
  for (const item of val) {
400
- const found = extractTranscript(item)
411
+ const found = extractTranscript(item, seen, depth + 1)
401
412
  if (found) return attachLive(found, o)
402
413
  }
403
414
  } else {
404
- const found = extractTranscript(val)
415
+ const found = extractTranscript(val, seen, depth + 1)
405
416
  if (found) return attachLive(found, o)
406
417
  }
407
418
  }
@@ -745,6 +756,29 @@ function latestToolResultNode(session, toolName) {
745
756
  const messages = safeGet(s, 'messages')
746
757
  if (Array.isArray(messages)) {
747
758
  const msgs = /** @type {Array<Record<string, unknown>>} */ (messages)
759
+ // Assistant tool-call variant: the tool result may live on the call object
760
+ // inside message.tool_calls rather than on session.toolCalls. Scan every
761
+ // message for a matching call (newest-first) before falling back to the
762
+ // last message's raw content — a conversational closing message must not
763
+ // shadow an earlier message's real tool result.
764
+ for (let i = msgs.length - 1; i >= 0; i--) {
765
+ const msg = msgs[i]
766
+ if (!msg) continue
767
+ const calls = safeGet(msg, 'tool_calls')
768
+ if (!Array.isArray(calls)) continue
769
+ const callList = /** @type {Array<Record<string, unknown>>} */ (calls)
770
+ for (let j = callList.length - 1; j >= 0; j--) {
771
+ const call = callList[j]
772
+ if (!call) continue
773
+ const name = String(safeGet(call, 'name') ?? safeGet(call, 'tool') ?? '')
774
+ if (name !== toolName && !name.endsWith(toolName)) continue
775
+ const result = safeGet(call, 'result') ?? safeGet(call, 'response') ?? safeGet(call, 'message')
776
+ if (result !== undefined && result !== null) return result
777
+ }
778
+ }
779
+ // Fallback: the last message's raw content (pre-existing behavior — a
780
+ // plain conversational string yields null upstream, but an embedded JSON
781
+ // body is still worth surfacing for the deep-scan finders).
748
782
  for (let i = msgs.length - 1; i >= 0; i--) {
749
783
  const msg = msgs[i]
750
784
  if (!msg) continue
@@ -1151,6 +1185,7 @@ function computeSummaryFromFindings(findings) {
1151
1185
  const byDimension = {}
1152
1186
 
1153
1187
  for (const f of findings) {
1188
+ if (!f || typeof f !== 'object') continue
1154
1189
  const sev = String(f.severity ?? 'low')
1155
1190
  if (sev in counts) counts[sev]++
1156
1191
  const dim = String(f.dimension ?? 'unknown')
@@ -1224,6 +1259,7 @@ export function severityStats(report) {
1224
1259
  const findings = /** @type {Array<Record<string, unknown>>} */ (report.findings ?? [])
1225
1260
  const counts = { critical: 0, high: 0, medium: 0, low: 0 }
1226
1261
  for (const f of findings) {
1262
+ if (!f || typeof f !== 'object') continue
1227
1263
  const sev = String(f.severity ?? 'low')
1228
1264
  if (sev in counts) counts[sev]++
1229
1265
  }
@@ -1243,6 +1279,7 @@ export function groupByDimension(report) {
1243
1279
  /** @type {Record<string, Array<Record<string, unknown>>>} */
1244
1280
  const groups = {}
1245
1281
  for (const f of findings) {
1282
+ if (!f || typeof f !== 'object') continue
1246
1283
  const dim = String(f.dimension ?? 'unknown')
1247
1284
  if (!groups[dim]) groups[dim] = []
1248
1285
  groups[dim].push(f)
@@ -1412,6 +1449,7 @@ export function normalizeFindingFilter(filter) {
1412
1449
  * @returns {boolean}
1413
1450
  */
1414
1451
  export function findingMatches(finding, filter) {
1452
+ if (!finding || typeof finding !== 'object') return false
1415
1453
  const f = normalizeFindingFilter(filter)
1416
1454
  const sev = String(finding.severity ?? 'low')
1417
1455
  if (f.severities.length > 0 && !f.severities.includes(sev)) return false
@@ -1553,6 +1591,7 @@ export function setAllVerdicts(triageState, verdict, indices) {
1553
1591
  export function buildRoundHistory(report) {
1554
1592
  const rounds = Array.isArray(report.rounds) ? report.rounds : []
1555
1593
  return rounds.map((r) => {
1594
+ if (!r || typeof r !== 'object') return { round: 0, count: 0, critical: 0, high: 0, medium: 0, low: 0 }
1556
1595
  const rr = /** @type {Record<string, unknown>} */ (r)
1557
1596
  const findings = Array.isArray(rr.findings) ? rr.findings : []
1558
1597
  const sev = severityStats({ findings })
@@ -1823,7 +1862,15 @@ export function filterTimelineEntries(entries, opts) {
1823
1862
  if (type && String(t.type ?? '') !== type) return false
1824
1863
  if (round && String(t.round ?? '') !== round) return false
1825
1864
  if (q) {
1826
- const hay = [String(t.type ?? ''), String(t.round ?? ''), JSON.stringify(t.data ?? {})].join(' ').toLowerCase()
1865
+ let dataText = ''
1866
+ try {
1867
+ dataText = JSON.stringify(t.data ?? {})
1868
+ } catch {
1869
+ // A self-referential `data` reference would throw on stringify — degrade
1870
+ // to a string representation rather than crashing the search.
1871
+ dataText = t.data === undefined ? '{}' : String(t.data)
1872
+ }
1873
+ const hay = [String(t.type ?? ''), String(t.round ?? ''), dataText].join(' ').toLowerCase()
1827
1874
  if (hay.indexOf(q) < 0) return false
1828
1875
  }
1829
1876
  return true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iterate-plugin",
3
- "version": "3.4.0",
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",
@@ -29,9 +29,11 @@
29
29
  },
30
30
  "compatibility": {
31
31
  "dshReleases": {
32
+ "0.1.1-rc.1": "compatible",
32
33
  "0.1.2-alpha.4": "compatible",
33
34
  "0.1.2-alpha.5": "compatible",
34
- "0.1.2-rc.1": "compatible"
35
+ "0.1.2-rc.1": "compatible",
36
+ "0.1.3-alpha.1": "compatible"
35
37
  }
36
38
  },
37
39
  "client": {
@@ -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
+ }
@@ -61,7 +61,6 @@ import {
61
61
  batchSetVerdict,
62
62
  setAllVerdicts,
63
63
  buildRoundHistory,
64
- buildFindingTrend,
65
64
  computeTrendMetrics,
66
65
  trendMax,
67
66
  buildCompletionSummary,
@@ -1226,7 +1225,10 @@ function TriagePanel(props: SlotProps) {
1226
1225
 
1227
1226
  // v3.0: Command handlers for native action buttons
1228
1227
  const doApproveArchitecturalFix = () => {
1229
- const cmd = '请调用 `iterate_fix` 批准架构修复(设置 is_architectural: true)'
1228
+ // Architectural findings exceed the atomic threshold, so "approving" one
1229
+ // means telling the model to apply it as a deliberate forced change via
1230
+ // iterate_fix with force:true (is_architectural is not a real parameter).
1231
+ const cmd = '请调用 `iterate_fix`,对本项目剩余架构型 finding 生成修复内容并设置 force: true 应用(架构修复允许超过 atomic 阈值)'
1230
1232
  copyText(cmd).then((ok) => {
1231
1233
  if (ok) {
1232
1234
  setCmdCopied('approve-arch')
@@ -1246,7 +1248,10 @@ function TriagePanel(props: SlotProps) {
1246
1248
  }
1247
1249
 
1248
1250
  const doRollbackToCheckpoint = () => {
1249
- const cmd = '请调用 `iterate_checkpoint` 回滚到上一个检查点'
1251
+ // iterate_checkpoint has NO rollback operation (save/load/resume/clear
1252
+ // only); reverting applied fixes is done via iterate_rollback by fix id,
1253
+ // so the paste-able instruction must point there instead.
1254
+ const cmd = '请回滚上一轮迭代的修复:调用 `iterate_history` 查看本轮 fix id,再用 `iterate_rollback` 逐个撤销这些修复'
1250
1255
  copyText(cmd).then((ok) => {
1251
1256
  if (ok) {
1252
1257
  setCmdCopied('rollback')
@@ -2267,7 +2272,10 @@ function ObservatoryPanel(props: SlotProps) {
2267
2272
  const sorted = filtered.slice()
2268
2273
  const rows = sorted.map((t, i) => {
2269
2274
  const k = `f7-${i}`
2270
- const dataText = t.data && typeof t.data === 'object' ? JSON.stringify(t.data) : ''
2275
+ let dataText = ''
2276
+ if (t.data && typeof t.data === 'object') {
2277
+ try { dataText = JSON.stringify(t.data) } catch { dataText = String(t.data) }
2278
+ }
2271
2279
  return React.createElement('div', { key: k, className: 'iterate-obs-row', style: { flexDirection: 'column', alignItems: 'flex-start' } },
2272
2280
  React.createElement('div', { className: 'iterate-obs-bar', style: { width: '100%' } },
2273
2281
  React.createElement('span', { className: 'iterate-obs-chip' }, String(t.type || '?')),
@@ -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/git-scope.ts CHANGED
@@ -23,7 +23,7 @@
23
23
 
24
24
  import { execFile } from 'node:child_process'
25
25
  import { existsSync, statSync } from 'node:fs'
26
- import { join } from 'node:path'
26
+ import { join, sep } from 'node:path'
27
27
 
28
28
  /** A resolved changed-only scope result. */
29
29
  export interface GitScopeResult {
@@ -113,10 +113,11 @@ export function parseChangedFiles(stdout: string): string[] {
113
113
  */
114
114
  export function filterExistingFiles(root: string, files: string[]): string[] {
115
115
  const out: string[] = []
116
+ const rootPrefix = root.endsWith(sep) ? root : root + sep
116
117
  for (const rel of files) {
117
118
  if (rel.startsWith('/') || rel.includes('\0')) continue
118
119
  const candidate = join(root, rel)
119
- if (!candidate.startsWith(root + '/') && candidate !== root) continue
120
+ if (!candidate.startsWith(rootPrefix) && candidate !== root) continue
120
121
  try {
121
122
  if (existsSync(candidate) && statSync(candidate).isFile()) out.push(rel)
122
123
  } catch {
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 {
@@ -143,9 +143,20 @@ for (let r = 1; r <= maxRounds; r++) {
143
143
  // Feed the DEDUPED + already-filtered set back (not raw findings) so the known
144
144
  // list stays bounded and reviewers never see the same issue twice.
145
145
  if (agg && agg.report && Array.isArray(agg.report.findings)) known = agg.report.findings
146
- if (agg && agg.report && agg.report.convergence && agg.report.convergence.findingsByRound[r-1] === 0) {
147
- log('round ' + r + ' found 0 new findings — converged')
148
- break
146
+ // A round counts as "converged" ONLY when the aggregate accepted it
147
+ // (schema-valid) AND it genuinely found zero NEW findings. If reviewers all
148
+ // failed or their output stayed schema-invalid after retries, the round is
149
+ // INCONCLUSIVE — never report an empty-but-broken round as converged (an
150
+ // invalid/failed round must fail the run, not masquerade as a clean pass).
151
+ if (!schemaInvalid) {
152
+ const newCount =
153
+ agg && agg.report && agg.report.convergence
154
+ ? agg.report.convergence.findingsByRound[r - 1]
155
+ : thisRound.findings.length
156
+ if (newCount === 0) {
157
+ log('round ' + r + ' found 0 new findings — converged')
158
+ break
159
+ }
149
160
  }
150
161
  }
151
162
 
@@ -238,7 +249,7 @@ if (checkpoint) {
238
249
 
239
250
  phase('plan')
240
251
  const configRes = await agent(
241
- 'Call iterate_config({ validate: true }) and return the config JSON.',
252
+ 'Call iterate_config({}) and return the config JSON.',
242
253
  Object.assign({ label: 'config:read' }, backend)
243
254
  )
244
255
  const cfg = (configRes && configRes.config) ? configRes.config : null
@@ -421,7 +432,7 @@ for (let r = startRound; r <= maxRounds; r++) {
421
432
  Object.assign({ label: 'checkpoint:save:r' + r }, backend)
422
433
  )
423
434
 
424
- if (atomic.length === 0 && remaining.length === 0) {
435
+ if (atomic.length === 0 && remaining.length === 0 && !schemaInvalid) {
425
436
  log('round ' + r + ' found nothing to fix — converged')
426
437
  converged = true
427
438
  break
@@ -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"). ' +