iterate-plugin 3.5.7 → 3.7.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 (79) hide show
  1. package/README.md +97 -22
  2. package/README.zh-CN.md +79 -19
  3. package/dist/approval-gate.js +143 -23
  4. package/dist/atomic-fs.js +148 -17
  5. package/dist/config-loader.js +516 -35
  6. package/dist/config-write.js +83 -22
  7. package/dist/evidence.js +141 -87
  8. package/dist/file-lock.js +193 -0
  9. package/dist/index.js +61 -28
  10. package/dist/jobs.js +56 -11
  11. package/dist/live.js +54 -14
  12. package/dist/meta-review.js +132 -28
  13. package/dist/method-scope.js +31 -8
  14. package/dist/review-scope.js +32 -9
  15. package/dist/review.js +96 -17
  16. package/dist/session-hooks.js +61 -14
  17. package/dist/skill-prompt.js +396 -66
  18. package/dist/tools/checkpoint.js +85 -19
  19. package/dist/tools/config.js +44 -2
  20. package/dist/tools/context.js +22 -5
  21. package/dist/tools/decision-log.js +106 -28
  22. package/dist/tools/defense-events.js +90 -31
  23. package/dist/tools/defense-store.js +28 -3
  24. package/dist/tools/experience-bank.js +108 -43
  25. package/dist/tools/experience-store.js +54 -4
  26. package/dist/tools/fix.js +347 -119
  27. package/dist/tools/history.js +68 -14
  28. package/dist/tools/present.js +80 -0
  29. package/dist/tools/prune.js +150 -53
  30. package/dist/tools/quality-gate.js +52 -2
  31. package/dist/tools/quality-store.js +17 -1
  32. package/dist/tools/review.js +158 -7
  33. package/dist/tools/transcript.js +173 -36
  34. package/dist/tools/triage.js +67 -12
  35. package/dist/tools/validate.js +115 -24
  36. package/dist/transcript.js +267 -4
  37. package/dist/working-directory.js +114 -0
  38. package/lib/client.js +1912 -512
  39. package/lib/parse.js +1001 -103
  40. package/package.json +16 -9
  41. package/src/approval-gate.ts +160 -23
  42. package/src/atomic-fs.ts +152 -17
  43. package/src/client/i18n.ts +517 -0
  44. package/src/client/index.ts +1031 -369
  45. package/src/client/session-cache.ts +54 -0
  46. package/src/config-loader.ts +549 -36
  47. package/src/config-write.ts +108 -26
  48. package/src/evidence.ts +194 -92
  49. package/src/file-lock.ts +211 -0
  50. package/src/index.ts +60 -29
  51. package/src/jobs.ts +63 -12
  52. package/src/live.ts +50 -12
  53. package/src/meta-review.ts +151 -34
  54. package/src/method-scope.ts +31 -8
  55. package/src/review-scope.ts +33 -10
  56. package/src/review.ts +96 -17
  57. package/src/session-hooks.ts +60 -13
  58. package/src/skill-prompt.ts +396 -66
  59. package/src/tools/checkpoint.ts +81 -19
  60. package/src/tools/config.ts +44 -2
  61. package/src/tools/context.ts +25 -5
  62. package/src/tools/decision-log.ts +114 -23
  63. package/src/tools/defense-events.ts +87 -31
  64. package/src/tools/defense-store.ts +30 -3
  65. package/src/tools/experience-bank.ts +112 -44
  66. package/src/tools/experience-store.ts +54 -5
  67. package/src/tools/fix.ts +258 -20
  68. package/src/tools/history.ts +74 -15
  69. package/src/tools/present.ts +89 -0
  70. package/src/tools/prune.ts +154 -51
  71. package/src/tools/quality-gate.ts +57 -2
  72. package/src/tools/quality-store.ts +18 -1
  73. package/src/tools/review.ts +155 -6
  74. package/src/tools/transcript.ts +178 -36
  75. package/src/tools/triage.ts +70 -13
  76. package/src/tools/validate.ts +138 -32
  77. package/src/transcript.ts +279 -4
  78. package/src/types.ts +29 -1
  79. package/src/working-directory.ts +126 -0
package/src/tools/fix.ts CHANGED
@@ -19,10 +19,11 @@
19
19
 
20
20
  import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs'
21
21
  import { writeJsonAtomic, writeTextAtomic } from '../atomic-fs.ts'
22
- import { dirname, join, sep } from 'node:path'
22
+ import { dirname, isAbsolute, join, normalize, sep } from 'node:path'
23
23
  import { defineTool } from '@deepseek-ai/dsh-tools'
24
24
  import type { JsonValue } from '@deepseek-ai/dsh-util-values'
25
25
  import { loadEffectiveConfig, resolveProjectRootForExec } from '../config-loader.ts'
26
+ import { withProjectLock } from '../file-lock.ts'
26
27
  import { runWithJob } from '../jobs.ts'
27
28
  import { countTouchedMethods } from '../method-scope.ts'
28
29
  import { fixBackupPath, fixRegistryPath, fixesDir } from '../paths.ts'
@@ -56,11 +57,28 @@ export function fixId(finding: Pick<ReviewFinding, 'file' | 'dimension' | 'line'
56
57
  return `fix-${hashString(key)}`
57
58
  }
58
59
 
60
+ /**
61
+ * Exact-alignment budget for the changed middle block: a dynamic-programming
62
+ * LCS pass over `m × n` cells. Beyond these bounds diffLines falls back to the
63
+ * legacy single-hunk report (coarser counts, but never a hang), so a
64
+ * pathological multi-thousand-line input cannot stall the tool loop.
65
+ */
66
+ export const DIFF_MAX_REGION_CELLS = 20_000
67
+ export const DIFF_MAX_REGION_LINES = 5_000
68
+
59
69
  /**
60
70
  * Compute a minimal line diff between two texts.
61
- * Returns an array of hunks (empty when unchanged). Uses common-prefix/suffix
62
- * trimming then reports the changed middle block — sufficient and deterministic
63
- * for the small atomic edits this toolchain produces.
71
+ * Returns an array of hunks (empty when unchanged).
72
+ *
73
+ * Common prefix/suffix are trimmed first, then the changed middle block is
74
+ * aligned EXACTLY (suffix-LCS dynamic programming, bounded by
75
+ * DIFF_MAX_REGION_CELLS / DIFF_MAX_REGION_LINES) and split into one hunk per
76
+ * run of changed lines separated by unchanged lines. This keeps counts honest:
77
+ * changing line 1 AND line 100 of a 200-line file reports {added:2, removed:2}
78
+ * across two hunks, not {100,100} for the whole span — the atomic `max_lines`
79
+ * gate and the persisted FixRecord line counts are derived from these numbers.
80
+ * When the region exceeds the budget, the old single-hunk behavior is kept so
81
+ * huge inputs degrade to coarse-but-correct counts instead of hanging.
64
82
  */
65
83
  export function diffLines(before: string, after: string): FileDiffHunk[] {
66
84
  const a = before.split('\n')
@@ -76,6 +94,14 @@ export function diffLines(before: string, after: string): FileDiffHunk[] {
76
94
  const removed = a.slice(start, endA)
77
95
  const added = b.slice(start, endB)
78
96
  if (removed.length === 0 && added.length === 0) return []
97
+ return alignRegion(removed, added, start) ?? singleRegionHunk(removed, added, start)
98
+ }
99
+
100
+ /**
101
+ * Legacy single-hunk report for the trimmed changed block (also the bounded
102
+ * fallback when `alignRegion` refuses an over-budget region).
103
+ */
104
+ function singleRegionHunk(removed: string[], added: string[], start: number): FileDiffHunk[] {
79
105
  const contentLines: string[] = []
80
106
  for (const line of removed) contentLines.push(`- ${line}`)
81
107
  for (const line of added) contentLines.push(`+ ${line}`)
@@ -90,6 +116,80 @@ export function diffLines(before: string, after: string): FileDiffHunk[] {
90
116
  ]
91
117
  }
92
118
 
119
+ /**
120
+ * Exactly align the trimmed changed block and split it into per-spot hunks
121
+ * (each maximal run of changed lines, separated by at least one unchanged
122
+ * line). Returns `null` when the region exceeds the DP budget so the caller
123
+ * can fall back to {@link singleRegionHunk}.
124
+ *
125
+ * Hunk coordinates follow GNU `diff -U0` conventions: for a pure insertion
126
+ * `oldStart` is the line BEFORE the insertion point (oldLines = 0), for a pure
127
+ * deletion `newStart` is the line before the deletion point (newLines = 0),
128
+ * and for a replacement both point at the replaced line.
129
+ */
130
+ function alignRegion(removed: string[], added: string[], start: number): FileDiffHunk[] | null {
131
+ const m = removed.length
132
+ const n = added.length
133
+ if (m > DIFF_MAX_REGION_LINES || n > DIFF_MAX_REGION_LINES || m * n > DIFF_MAX_REGION_CELLS) return null
134
+
135
+ // dp[i][j] = LCS length of removed[i..] / added[j..] (suffix table).
136
+ const stride = n + 1
137
+ const dp = new Int32Array((m + 1) * stride)
138
+ for (let i = m - 1; i >= 0; i--) {
139
+ for (let j = n - 1; j >= 0; j--) {
140
+ dp[i * stride + j] = removed[i] === added[j]
141
+ ? dp[(i + 1) * stride + j + 1]! + 1
142
+ : Math.max(dp[(i + 1) * stride + j]!, dp[i * stride + j + 1]!)
143
+ }
144
+ }
145
+
146
+ const hunks: FileDiffHunk[] = []
147
+ let open: { iOpen: number; jOpen: number; oldLines: number; newLines: number; content: string[] } | null = null
148
+ const closeHunk = (): void => {
149
+ if (!open) return
150
+ hunks.push({
151
+ // oldLines > 0 → first removed line (1-based); pure insertion → the
152
+ // line before the insertion point (= its 0-based index).
153
+ oldStart: start + open.iOpen + (open.oldLines > 0 ? 1 : 0),
154
+ oldLines: open.oldLines,
155
+ newStart: start + open.jOpen + (open.newLines > 0 ? 1 : 0),
156
+ newLines: open.newLines,
157
+ content: open.content.join('\n'),
158
+ })
159
+ open = null
160
+ }
161
+
162
+ let i = 0 // region index into removed
163
+ let j = 0 // region index into added
164
+ while (i < m || j < n) {
165
+ // A matching pair always belongs to SOME optimal alignment (CLRS
166
+ // x_i = y_j ⇒ LCS(i,j) = 1 + LCS(i+1,j+1)), so greedily closing the
167
+ // current hunk here is exact, not heuristic.
168
+ if (i < m && j < n && removed[i] === added[j]) {
169
+ closeHunk()
170
+ i++
171
+ j++
172
+ continue
173
+ }
174
+ // Diverged: consume whichever side the suffix table says costs less.
175
+ // Ties prefer removal so a plain replacement reads `- old / + new`.
176
+ const takeRemoved = i >= m ? false : j >= n ? true : dp[(i + 1) * stride + j]! >= dp[i * stride + j + 1]!
177
+ if (takeRemoved) {
178
+ if (!open) open = { iOpen: i, jOpen: j, oldLines: 0, newLines: 0, content: [] }
179
+ open.oldLines++
180
+ open.content.push(`- ${removed[i]}`)
181
+ i++
182
+ } else {
183
+ if (!open) open = { iOpen: i, jOpen: j, oldLines: 0, newLines: 0, content: [] }
184
+ open.newLines++
185
+ open.content.push(`+ ${added[j]}`)
186
+ j++
187
+ }
188
+ }
189
+ closeHunk()
190
+ return hunks
191
+ }
192
+
93
193
  /** Added/removed line counts for a change (derived from diffLines). */
94
194
  export function countChangedLines(before: string, after: string): { added: number; removed: number } {
95
195
  const hunks = diffLines(before, after)
@@ -130,11 +230,18 @@ export function readRegistry(projectRoot: string): FixRegistry {
130
230
  // contain a round without a `records` array, or records that are missing
131
231
  // their id / finding object — all of which would make readers
132
232
  // (findFixRecord / recordsForFile / iterate_diff) throw or sum NaN.
233
+ // Numeric counters are also floored to non-negative integers: the status
234
+ // output schema declares them `type: 'integer'`, so a `fixedCount: 1.5`
235
+ // must never reach iterate_status.
236
+ const intCount = (v: unknown): number =>
237
+ typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0
133
238
  parsed.rounds = parsed.rounds
134
239
  .filter((r) => r && typeof r === 'object' && Array.isArray(r.records))
135
240
  .map((r) => ({
136
241
  ...r,
137
242
  round: typeof r.round === 'number' && Number.isFinite(r.round) ? Math.floor(r.round) : 0,
243
+ fixedCount: intCount(r.fixedCount),
244
+ failedCount: intCount(r.failedCount),
138
245
  records: r.records.filter(
139
246
  (rec): rec is FixRecord =>
140
247
  !!rec &&
@@ -160,12 +267,18 @@ export function findFixRecord(registry: FixRegistry, id: string): FixRecord | un
160
267
  return undefined
161
268
  }
162
269
 
163
- /** All fix records for a file, in chronological order. */
270
+ /**
271
+ * All fix records for a file, in chronological order.
272
+ * Both sides are canonicalized with {@link normalizeProjectPath} so a record
273
+ * persisted as `./README.md` (pre-normalization builds) still matches a
274
+ * query for `README.md` and vice versa.
275
+ */
164
276
  export function recordsForFile(registry: FixRegistry, file: string): FixRecord[] {
277
+ const want = normalizeProjectPath(file)
165
278
  const out: FixRecord[] = []
166
279
  for (const round of registry.rounds) {
167
280
  for (const r of round.records) {
168
- if (r.finding.file === file && r.success) out.push(r)
281
+ if (normalizeProjectPath(r.finding.file) === want && r.success) out.push(r)
169
282
  }
170
283
  }
171
284
  return out
@@ -251,6 +364,66 @@ export function resolveProjectFile(projectRoot: string, file: string): { ok: tru
251
364
  return { ok: true, resolved }
252
365
  }
253
366
 
367
+ /**
368
+ * Canonicalize a model-supplied relative path ONCE at argument intake.
369
+ *
370
+ * Strips a leading `./`, collapses duplicate separators, and resolves `.` /
371
+ * `..` segments lexically so the containment check, the protected-path glob,
372
+ * the persisted FixRecord, and every later diff/rollback lookup all compare
373
+ * the SAME string — a raw `./README.md` must not dodge a `README.md` veto
374
+ * glob, and `src/../README.md` must record as `README.md`.
375
+ * Absolute paths and `..` escapes survive normalization and are rejected by
376
+ * {@link resolveProjectFile} afterwards. Pure, exported for unit tests.
377
+ */
378
+ export function normalizeProjectPath(file: string): string {
379
+ // An empty intake must stay empty (node's normalize('') === '.') so
380
+ // resolveProjectFile still rejects it instead of resolving to the root.
381
+ if (typeof file !== 'string' || file === '') return ''
382
+ return normalize(file)
383
+ }
384
+
385
+ /**
386
+ * Validate a registry-recorded backup path before it is read (iterate_diff)
387
+ * or used to overwrite a project file (iterate_rollback).
388
+ *
389
+ * The registry is a plain JSON file on disk: a hand-edited/corrupt record can
390
+ * point `backupPath` anywhere, turning diff into an arbitrary-file read and
391
+ * rollback into arbitrary-content injection. A backup must stay inside the
392
+ * project's fixes directory — lexically AND via realpath when it exists (so a
393
+ * symlink planted inside `.iterate/fixes` cannot smuggle content either).
394
+ */
395
+ export function resolveBackupPath(
396
+ projectRoot: string,
397
+ backupPath: string,
398
+ ): { ok: true; resolved: string } | { ok: false; reason: string } {
399
+ if (typeof backupPath !== 'string' || backupPath.trim().length === 0) {
400
+ return { ok: false, reason: 'backup path is missing from the fix record' }
401
+ }
402
+ const fixes = fixesDir(projectRoot)
403
+ // Relative entries are anchored at the project root (never the process cwd)
404
+ // so `../../etc/passwd` fails the containment check below.
405
+ const resolved = isAbsolute(backupPath) ? normalize(backupPath) : join(projectRoot, backupPath)
406
+ const prefix = fixes.endsWith(sep) ? fixes : fixes + sep
407
+ if (resolved !== fixes && !resolved.startsWith(prefix)) {
408
+ return { ok: false, reason: `backup path escapes the fixes directory: ${backupPath}` }
409
+ }
410
+ if (existsSync(resolved)) {
411
+ let realFixes: string
412
+ let real: string
413
+ try {
414
+ realFixes = realpathSync(fixes)
415
+ real = realpathSync(resolved)
416
+ } catch {
417
+ return { ok: false, reason: `failed to resolve real path for backup containment check: ${backupPath}` }
418
+ }
419
+ const realPrefix = realFixes.endsWith(sep) ? realFixes : realFixes + sep
420
+ if (real !== realFixes && !real.startsWith(realPrefix)) {
421
+ return { ok: false, reason: `backup path escapes the fixes directory (symlink): ${backupPath}` }
422
+ }
423
+ }
424
+ return { ok: true, resolved }
425
+ }
426
+
254
427
  // ─── Shared execute helpers ──────────────────────────────────────────────────
255
428
 
256
429
  /**
@@ -384,8 +557,14 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
384
557
  const maxLines = config.atomic?.max_lines ?? 20
385
558
  const maxAdjacentMethods = config.atomic?.max_adjacent_methods ?? 3
386
559
 
387
- const file = typeof args.file === 'string' ? args.file : ''
388
- if (!file) return { ok: false, error: 'file is required' }
560
+ // Canonicalize ONCE at intake: `./README.md` and `src/../README.md`
561
+ // must behave exactly like `README.md` downstream — the protected-path
562
+ // glob, the fix id, the persisted FixRecord, and later diff/rollback
563
+ // lookups all compare this normalized form (escapes/absolute paths
564
+ // survive normalization and are rejected by resolveProjectFile).
565
+ const rawFile = typeof args.file === 'string' ? args.file : ''
566
+ if (!rawFile) return { ok: false, error: 'file is required' }
567
+ const file = normalizeProjectPath(rawFile)
389
568
  if (typeof args.content !== 'string') return { ok: false, error: 'content must be a string' }
390
569
  if (args.content.length > MAX_FIX_CONTENT_CHARS) {
391
570
  return {
@@ -396,21 +575,23 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
396
575
  if (typeof args.round !== 'number' || !Number.isInteger(args.round) || args.round < 1) {
397
576
  return { ok: false, error: 'round must be a positive integer' }
398
577
  }
399
- const finding = args.finding as unknown as ReviewFinding | undefined
400
- if (!finding || typeof finding !== 'object') {
578
+ const rawFinding = args.finding as unknown as ReviewFinding | undefined
579
+ if (!rawFinding || typeof rawFinding !== 'object') {
401
580
  return { ok: false, error: 'finding must be an object' }
402
581
  }
403
- if (typeof finding.file !== 'string' || finding.file.trim().length === 0) {
582
+ if (typeof rawFinding.file !== 'string' || rawFinding.file.trim().length === 0) {
404
583
  return { ok: false, error: 'finding.file must be a non-empty string' }
405
584
  }
406
- if (typeof finding.dimension !== 'string' || finding.dimension.trim().length === 0) {
585
+ if (typeof rawFinding.dimension !== 'string' || rawFinding.dimension.trim().length === 0) {
407
586
  return { ok: false, error: 'finding.dimension must be a non-empty string' }
408
587
  }
409
588
  // The finding must reference the file being fixed — the fix id and the
410
589
  // rollback/diff target are derived from finding.file, so a mismatch
411
- // would back up/restore the WRONG file.
590
+ // would back up/restore the WRONG file. Both sides are compared in
591
+ // their NORMALIZED form (`./src/a.ts` and `src/a.ts` are the same fix).
592
+ const finding: ReviewFinding = { ...rawFinding, file: normalizeProjectPath(rawFinding.file) }
412
593
  if (finding.file !== file) {
413
- return { ok: false, error: `finding.file ("${finding.file}") must match the file being fixed ("${file}")` }
594
+ return { ok: false, error: `finding.file ("${rawFinding.file}") must match the file being fixed ("${rawFile}")` }
414
595
  }
415
596
  // Full finding validation, mirroring the review schema: malformed
416
597
  // findings would produce lossy registry/log entries and a degraded id.
@@ -452,6 +633,16 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
452
633
  }
453
634
 
454
635
  const id = fixId(finding)
636
+ const timestamp = new Date().toISOString()
637
+
638
+ // Cross-process read-modify-write guard: the registry read (dup check)
639
+ // and the record write form ONE window — two plugin processes racing
640
+ // here could each read the old registry and silently drop the other's
641
+ // update. The shared advisory lock (src/file-lock.ts) serializes the
642
+ // window; the decision-log append runs after it is released.
643
+ const applied = withProjectLock(projectRoot, 'fix-registry', ():
644
+ | { ok: false; error: string; id?: string }
645
+ | { ok: true; record: FixRecord; backupPath: string } => {
455
646
  const registry = readRegistry(projectRoot)
456
647
  if (findFixRecord(registry, id)) {
457
648
  return { ok: false, error: `finding already fixed this run (id: ${id})`, id }
@@ -474,7 +665,8 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
474
665
  // Personalization guards (SKILL.md Phase 2): protected_paths veto the
475
666
  // fix outright; forbidden_fixes veto fix approaches appearing in the
476
667
  // new content. Both are security-relevant, so they are enforced here
477
- // in the tool, not left to the model.
668
+ // in the tool, not left to the model. The glob runs against the
669
+ // NORMALIZED `file`, so `./README.md` cannot dodge a `README.md` veto.
478
670
  const pers = config.personalization as
479
671
  | { protected_paths?: unknown; forbidden_fixes?: unknown }
480
672
  | undefined
@@ -495,7 +687,6 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
495
687
  }
496
688
  }
497
689
 
498
- const timestamp = new Date().toISOString()
499
690
  const backupPath = fixBackupPath(projectRoot, id, timestamp)
500
691
  try {
501
692
  mkdirSync(fixesDir(projectRoot), { recursive: true })
@@ -539,6 +730,11 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
539
730
  }
540
731
  return { ok: false, error: `failed to write fix registry: ${String(err)} (file restored from backup)` }
541
732
  }
733
+ return { ok: true, record, backupPath }
734
+ })
735
+
736
+ if (!applied.ok) return applied
737
+ const { record, backupPath } = applied
542
738
 
543
739
  const logRes = appendDecisionEntry(projectRoot, {
544
740
  timestamp,
@@ -629,17 +825,24 @@ export function registerDiffTool(ctx: { tools: { register: (def: ReturnType<type
629
825
  if (!resolved.ok) return { ok: false, error: resolved.reason }
630
826
  const projectRoot = resolved.root
631
827
  const registry = readRegistry(projectRoot)
632
- const file = typeof args.file === 'string' && args.file.trim() ? args.file : undefined
828
+ const file = typeof args.file === 'string' && args.file.trim()
829
+ ? normalizeProjectPath(args.file)
830
+ : undefined
633
831
 
634
832
  if (file) {
635
833
  const records = recordsForFile(registry, file)
636
834
  const first = records[0]
637
835
  if (!first) return { ok: false, error: `no fixes recorded for ${file}` }
836
+ // Containment: the registry is on-disk JSON that a hand edit can
837
+ // point anywhere — a tampered backupPath must never turn this
838
+ // read-only diff into an arbitrary-file read.
839
+ const backup = resolveBackupPath(projectRoot, first.backupPath)
840
+ if (!backup.ok) return { ok: false, error: `invalid backup for ${file}: ${backup.reason}` }
638
841
  const current = readProjectFile(projectRoot, file)
639
842
  if (!current.ok) return { ok: false, error: current.reason }
640
843
  let original = ''
641
844
  try {
642
- original = readFileSync(first.backupPath, 'utf-8')
845
+ original = readFileSync(backup.resolved, 'utf-8')
643
846
  } catch (err) {
644
847
  return { ok: false, error: `backup missing for ${file}: ${String(err)}` }
645
848
  }
@@ -742,10 +945,40 @@ export function registerRollbackTool(ctx: { tools: { register: (def: ReturnType<
742
945
  const id = typeof args.id === 'string' ? args.id : ''
743
946
  if (!id) return { ok: false, error: 'id is required' }
744
947
 
948
+ // One locked read-modify-write window (see src/file-lock.ts): the
949
+ // registry read, the file restore, and the record removal must not
950
+ // interleave with another process's fix/rollback or the update is lost.
951
+ const restored = withProjectLock(projectRoot, 'fix-registry', ():
952
+ | { ok: false; error: string }
953
+ | { ok: true; record: FixRecord } => {
745
954
  const registry = readRegistry(projectRoot)
746
955
  const record = findFixRecord(registry, id)
747
956
  if (!record) return { ok: false, error: `fix not found: ${id}` }
748
- if (!existsSync(record.backupPath)) {
957
+
958
+ // LIFO safety: restoring an OLDER backup would silently destroy every
959
+ // NEWER successful fix to the same file — their records would stay
960
+ // success:true while the bytes they wrote are gone. Refuse and name
961
+ // the clobbered ids so the caller can roll back in reverse order first.
962
+ const fileRecords = recordsForFile(registry, record.finding.file)
963
+ const targetIdx = fileRecords.findIndex((r) => r.id === id)
964
+ const newer = targetIdx >= 0
965
+ ? fileRecords.slice(targetIdx + 1)
966
+ : fileRecords.filter((r) => r.id !== id && r.timestamp > record.timestamp)
967
+ if (newer.length > 0) {
968
+ const clobbered = newer.map((r) => r.id).join(', ')
969
+ return {
970
+ ok: false,
971
+ error: `rollback refused: fix ${id} is older than fix(es) ${clobbered} on ${record.finding.file} — ` +
972
+ 'restoring its backup would destroy them. Roll back the later fix(es) first (LIFO order).',
973
+ }
974
+ }
975
+
976
+ // Containment: the registry is on-disk JSON that a hand edit can
977
+ // point anywhere — a tampered backupPath must never inject arbitrary
978
+ // content into the project file.
979
+ const backup = resolveBackupPath(projectRoot, record.backupPath)
980
+ if (!backup.ok) return { ok: false, error: `invalid backup for fix ${id}: ${backup.reason}` }
981
+ if (!existsSync(backup.resolved)) {
749
982
  return { ok: false, error: `backup missing for fix ${id}` }
750
983
  }
751
984
 
@@ -754,7 +987,7 @@ export function registerRollbackTool(ctx: { tools: { register: (def: ReturnType<
754
987
  try {
755
988
  // Atomic restore: never leave a truncated source file if we crash
756
989
  // mid-restore (matches the writeTextAtomic guarantee used by apply).
757
- writeTextAtomic(target.resolved, readFileSync(record.backupPath, 'utf-8'))
990
+ writeTextAtomic(target.resolved, readFileSync(backup.resolved, 'utf-8'))
758
991
  } catch (err) {
759
992
  return { ok: false, error: `failed to restore backup: ${String(err)}` }
760
993
  }
@@ -765,6 +998,11 @@ export function registerRollbackTool(ctx: { tools: { register: (def: ReturnType<
765
998
  } catch (err) {
766
999
  return { ok: false, error: `failed to update fix registry: ${String(err)}` }
767
1000
  }
1001
+ return { ok: true, record }
1002
+ })
1003
+
1004
+ if (!restored.ok) return restored
1005
+ const record = restored.record
768
1006
 
769
1007
  const logRes = appendDecisionEntry(projectRoot, {
770
1008
  timestamp: new Date().toISOString(),
@@ -30,18 +30,30 @@ export function clampHistoryLimit(limit: number | undefined): number {
30
30
  * Filter + cap decision-log entries. Pure, unit-tested.
31
31
  * Returns the newest `limit` matching entries plus the total match count
32
32
  * (before the cap), so callers can tell when the result was truncated.
33
+ *
34
+ * `round` keeps only entries logged for that round; `file` keeps only entries
35
+ * whose `data.file` names that fixed file. Invalid `round`/`file` inputs are
36
+ * ignored (same convention as `type`/`since`), never treated as a match-nothing
37
+ * filter by accident.
33
38
  */
34
39
  export function filterDecisionEntries(
35
40
  entries: DecisionLogEntry[],
36
- opts: { type?: unknown; since?: unknown; limit?: unknown },
41
+ opts: { type?: unknown; since?: unknown; limit?: unknown; round?: unknown; file?: unknown },
37
42
  ): { entries: DecisionLogEntry[]; filteredCount: number; limit: number } {
38
43
  const type = typeof opts.type === 'string' && opts.type ? opts.type : undefined
39
44
  const since = typeof opts.since === 'string' && opts.since ? opts.since : undefined
45
+ const round =
46
+ typeof opts.round === 'number' && Number.isInteger(opts.round) && opts.round >= 0
47
+ ? opts.round
48
+ : undefined
49
+ const file = typeof opts.file === 'string' && opts.file ? opts.file : undefined
40
50
  const limit = clampHistoryLimit(opts.limit as number | undefined)
41
51
 
42
52
  const matching = (Array.isArray(entries) ? entries : []).filter((e) => {
43
53
  if (type && e.type !== type) return false
44
54
  if (since && e.timestamp <= since) return false
55
+ if (round !== undefined && e.round !== round) return false
56
+ if (file && (!e.data || e.data.file !== file)) return false
45
57
  return true
46
58
  })
47
59
  return {
@@ -51,20 +63,53 @@ export function filterDecisionEntries(
51
63
  }
52
64
  }
53
65
 
54
- /** Per-round fix counts + totals from a fix registry. Pure, unit-tested. */
55
- export function summarizeFixRegistry(registry: FixRegistry): {
66
+ /**
67
+ * Per-round fix counts + totals from a fix registry. Pure, unit-tested.
68
+ *
69
+ * `opts.round` narrows the view to one round. `opts.file` narrows each round to
70
+ * the records that fixed that file: the stored per-round counts cover every
71
+ * file and cannot be subsetted, so a file-scoped summary RECOMPUTES the counts
72
+ * from the kept records (mirroring `recomputeRoundCounts`), and rounds left
73
+ * without any matching record are dropped entirely — an all-zero placeholder
74
+ * round would falsely suggest the file was touched that round.
75
+ */
76
+ export function summarizeFixRegistry(
77
+ registry: FixRegistry,
78
+ opts: { round?: unknown; file?: unknown } = {},
79
+ ): {
56
80
  totalFixed: number
57
81
  totalFailed: number
58
82
  roundCount: number
59
83
  rounds: { round: number; fixedCount: number; failedCount: number }[]
60
84
  } {
61
- const rounds = (registry.rounds ?? []).map((r) => ({
62
- round: r.round,
63
- // Coerce defensively so a hand-edited registry round missing either count
64
- // can never propagate NaN into the integer output fields.
65
- fixedCount: Number(r.fixedCount) || 0,
66
- failedCount: Number(r.failedCount) || 0,
67
- }))
85
+ const roundFilter =
86
+ typeof opts.round === 'number' && Number.isInteger(opts.round) && opts.round >= 0
87
+ ? opts.round
88
+ : undefined
89
+ const fileFilter = typeof opts.file === 'string' && opts.file ? opts.file : undefined
90
+
91
+ const rounds = (registry.rounds ?? [])
92
+ .filter((r) => roundFilter === undefined || r.round === roundFilter)
93
+ .flatMap((r) => {
94
+ if (fileFilter === undefined) {
95
+ // Coerce defensively so a hand-edited registry round missing either count
96
+ // can never propagate NaN into the integer output fields.
97
+ return [{
98
+ round: r.round,
99
+ fixedCount: Number(r.fixedCount) || 0,
100
+ failedCount: Number(r.failedCount) || 0,
101
+ }]
102
+ }
103
+ const kept = (r.records ?? []).filter((rec) => rec && rec.finding && rec.finding.file === fileFilter)
104
+ if (kept.length === 0) return []
105
+ // Recompute from the kept records: a rolled-back (success:false) record
106
+ // counts as failed, matching the registry's own recomputeRoundCounts.
107
+ return [{
108
+ round: r.round,
109
+ fixedCount: kept.filter((rec) => rec.success).length,
110
+ failedCount: kept.filter((rec) => !rec.success).length,
111
+ }]
112
+ })
68
113
  return {
69
114
  totalFixed: rounds.reduce((s, r) => s + r.fixedCount, 0),
70
115
  totalFailed: rounds.reduce((s, r) => s + r.failedCount, 0),
@@ -75,8 +120,9 @@ export function summarizeFixRegistry(registry: FixRegistry): {
75
120
 
76
121
  /**
77
122
  * Register the `iterate_history` tool.
78
- * Reads the decision log (optionally filtered by type / since / limit) and a
79
- * fix-registry summary. Read-only; never modifies the filesystem.
123
+ * Reads the decision log (optionally filtered by type / since / round / file /
124
+ * limit) and a fix-registry summary (scoped by the same round / file filters).
125
+ * Read-only; never modifies the filesystem.
80
126
  */
81
127
  export function registerHistoryTool(ctx: { tools: { register: (def: ReturnType<typeof defineTool>) => void } }): void {
82
128
  ctx.tools.register(
@@ -87,7 +133,8 @@ export function registerHistoryTool(ctx: { tools: { register: (def: ReturnType<t
87
133
  isConcurrencySafe: () => true,
88
134
  description:
89
135
  'Read the iteration history: decision-log entries (optionally filtered by entry `type`, `since` ' +
90
- 'timestamp, and a `limit`) plus a summary of the fix registry (per-round fixed/failed counts). ' +
136
+ 'timestamp, `round`, fixed `file`, and a `limit`) plus a summary of the fix registry (per-round ' +
137
+ 'fixed/failed counts, scoped by the same round/file filters). ' +
91
138
  'Read-only — use it to review what the run did, audit a log, or inspect fixes.',
92
139
  parameters: {
93
140
  type: {
@@ -100,6 +147,18 @@ export function registerHistoryTool(ctx: { tools: { register: (def: ReturnType<t
100
147
  type: 'string',
101
148
  description: 'Optional ISO timestamp; only entries AFTER this timestamp are returned.',
102
149
  },
150
+ round: {
151
+ type: 'integer',
152
+ description:
153
+ 'Optional round number; only decision-log entries logged for this round are returned, ' +
154
+ 'and the fix summary is scoped to this round.',
155
+ },
156
+ file: {
157
+ type: 'string',
158
+ description:
159
+ 'Optional fixed-file path; only decision-log entries whose `data.file` matches are returned, ' +
160
+ 'and the fix summary counts only fixes applied to that file.',
161
+ },
103
162
  limit: {
104
163
  type: 'integer',
105
164
  description: `Max entries to return (default: ${DEFAULT_LIMIT}, cap: ${MAX_LIMIT}). Returns the newest window in chronological order.`,
@@ -148,9 +207,9 @@ export function registerHistoryTool(ctx: { tools: { register: (def: ReturnType<t
148
207
 
149
208
  const { entries, filteredCount, limit } = filterDecisionEntries(
150
209
  readDecisionEntries(projectRoot),
151
- { type: args.type, since: args.since, limit: args.limit },
210
+ { type: args.type, since: args.since, limit: args.limit, round: args.round, file: args.file },
152
211
  )
153
- const fixes = summarizeFixRegistry(readRegistry(projectRoot))
212
+ const fixes = summarizeFixRegistry(readRegistry(projectRoot), { round: args.round, file: args.file })
154
213
 
155
214
  return {
156
215
  ok: true,
@@ -0,0 +1,89 @@
1
+ /**
2
+ * src/tools/present.ts — shared `presentResult` helpers for the iterate tools (#12).
3
+ *
4
+ * dsh presents a completed call through `ToolDefinition.presentResult(args,
5
+ * result)` when the tool supplies one; the return value is a `ToolResultView`
6
+ * (a `card`-tagged render intent from `@deepseek-ai/dsh-tools`). These helpers
7
+ * read ONLY the durable result projection handed to the presenter
8
+ * (`result.content` / `result.isError`) — they never re-execute anything, so a
9
+ * live call and a session-log replay produce the identical card.
10
+ *
11
+ * House rules for every presenter built on this module:
12
+ * - Pure / replay-safe: depend on `args` + `result` only.
13
+ * - Defensive: any shape it cannot read returns `undefined`, which makes the
14
+ * UI fall back to the default presentation (pending title + raw result)
15
+ * instead of a wrong or half-guessed card.
16
+ * - No invented copy: card text is derived from the render output the tool
17
+ * itself produced.
18
+ */
19
+
20
+ /** The content-block subset a presenter inspects (`ContentBlock` is structural). */
21
+ interface RenderedBlock {
22
+ type?: unknown
23
+ text?: unknown
24
+ }
25
+
26
+ /**
27
+ * Concatenated text of a completed call's rendered content, or `undefined`
28
+ * when there is none. Failure results are never summarized — the presenter
29
+ * declines the card and the UI shows the raw error.
30
+ */
31
+ export function renderedText(result: { content?: unknown; isError?: unknown }): string | undefined {
32
+ if (!result || result.isError === true || !Array.isArray(result.content)) return undefined
33
+ const parts: string[] = []
34
+ for (const block of result.content as RenderedBlock[]) {
35
+ if (block && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string') {
36
+ parts.push(block.text)
37
+ }
38
+ }
39
+ return parts.length > 0 ? parts.join('\n') : undefined
40
+ }
41
+
42
+ /**
43
+ * First non-empty line of the rendered content — the card headline for a
44
+ * `presentResult` (#12). Long lines are clamped so a card header stays a
45
+ * header. `undefined` (failure / no text) means "decline the card".
46
+ */
47
+ export function resultHeadline(
48
+ result: { content?: unknown; isError?: unknown },
49
+ maxLength = 160,
50
+ ): string | undefined {
51
+ const text = renderedText(result)
52
+ if (!text) return undefined
53
+ const line = text.split('\n').find((l) => l.trim().length > 0)
54
+ if (!line) return undefined
55
+ const trimmed = line.trim()
56
+ return trimmed.length > maxLength ? `${trimmed.slice(0, maxLength - 1)}…` : trimmed
57
+ }
58
+
59
+ /**
60
+ * Parse a tool's rendered content as JSON — for tools whose `render` emits
61
+ * `JSON.stringify(value, null, 2)` verbatim (review, checkpoint). Returns a
62
+ * plain record, or `undefined` when the content is not an object-shaped JSON
63
+ * document (the presenter then declines the card rather than guess).
64
+ */
65
+ export function parseRenderedJson(result: { content?: unknown; isError?: unknown }): Record<string, unknown> | undefined {
66
+ const text = renderedText(result)
67
+ if (!text) return undefined
68
+ let parsed: unknown
69
+ try {
70
+ parsed = JSON.parse(text)
71
+ } catch {
72
+ return undefined
73
+ }
74
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
75
+ ? (parsed as Record<string, unknown>)
76
+ : undefined
77
+ }
78
+
79
+ /** Narrow an unknown value to a plain record (arrays/null/primitives → undefined). */
80
+ export function asRecord(value: unknown): Record<string, unknown> | undefined {
81
+ return value && typeof value === 'object' && !Array.isArray(value)
82
+ ? (value as Record<string, unknown>)
83
+ : undefined
84
+ }
85
+
86
+ /** Finite-number narrow for defensively reading fields off a parsed result. */
87
+ export function asNumber(value: unknown): number | undefined {
88
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined
89
+ }