iterate-plugin 3.5.6 → 3.6.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 (75) hide show
  1. package/README.md +64 -20
  2. package/README.zh-CN.md +53 -17
  3. package/dist/approval-gate.js +143 -23
  4. package/dist/atomic-fs.js +148 -17
  5. package/dist/config-loader.js +509 -34
  6. package/dist/config-write.js +83 -22
  7. package/dist/evidence.js +141 -87
  8. package/dist/file-lock.js +165 -0
  9. package/dist/index.js +54 -28
  10. package/dist/jobs.js +56 -11
  11. package/dist/live.js +52 -13
  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 +55 -13
  17. package/dist/skill-prompt.js +417 -67
  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 +122 -27
  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 +156 -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/lib/client.js +945 -181
  38. package/lib/parse.js +760 -57
  39. package/package.json +15 -12
  40. package/src/approval-gate.ts +160 -23
  41. package/src/atomic-fs.ts +152 -17
  42. package/src/client/index.ts +600 -129
  43. package/src/config-loader.ts +542 -35
  44. package/src/config-write.ts +108 -26
  45. package/src/evidence.ts +194 -92
  46. package/src/file-lock.ts +184 -0
  47. package/src/index.ts +53 -29
  48. package/src/jobs.ts +63 -12
  49. package/src/live.ts +48 -11
  50. package/src/meta-review.ts +151 -34
  51. package/src/method-scope.ts +31 -8
  52. package/src/review-scope.ts +33 -10
  53. package/src/review.ts +96 -17
  54. package/src/session-hooks.ts +54 -12
  55. package/src/skill-prompt.ts +417 -67
  56. package/src/tools/checkpoint.ts +81 -19
  57. package/src/tools/config.ts +44 -2
  58. package/src/tools/context.ts +25 -5
  59. package/src/tools/decision-log.ts +127 -22
  60. package/src/tools/defense-events.ts +87 -31
  61. package/src/tools/defense-store.ts +30 -3
  62. package/src/tools/experience-bank.ts +112 -44
  63. package/src/tools/experience-store.ts +54 -5
  64. package/src/tools/fix.ts +258 -20
  65. package/src/tools/history.ts +74 -15
  66. package/src/tools/present.ts +89 -0
  67. package/src/tools/prune.ts +160 -51
  68. package/src/tools/quality-gate.ts +57 -2
  69. package/src/tools/quality-store.ts +18 -1
  70. package/src/tools/review.ts +155 -6
  71. package/src/tools/transcript.ts +178 -36
  72. package/src/tools/triage.ts +70 -13
  73. package/src/tools/validate.ts +138 -32
  74. package/src/transcript.ts +279 -4
  75. package/src/types.ts +29 -1
package/dist/tools/fix.js CHANGED
@@ -18,9 +18,10 @@
18
18
  */
19
19
  import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs';
20
20
  import { writeJsonAtomic, writeTextAtomic } from "../atomic-fs.js";
21
- import { dirname, join, sep } from 'node:path';
21
+ import { dirname, isAbsolute, join, normalize, sep } from 'node:path';
22
22
  import { defineTool } from '@deepseek-ai/dsh-tools';
23
23
  import { loadEffectiveConfig, resolveProjectRootForExec } from "../config-loader.js";
24
+ import { withProjectLock } from "../file-lock.js";
24
25
  import { runWithJob } from "../jobs.js";
25
26
  import { countTouchedMethods } from "../method-scope.js";
26
27
  import { fixBackupPath, fixRegistryPath, fixesDir } from "../paths.js";
@@ -47,11 +48,27 @@ export function fixId(finding) {
47
48
  const key = `${finding.file}|${finding.dimension}|${finding.line ?? 0}|${finding.summary}`;
48
49
  return `fix-${hashString(key)}`;
49
50
  }
51
+ /**
52
+ * Exact-alignment budget for the changed middle block: a dynamic-programming
53
+ * LCS pass over `m × n` cells. Beyond these bounds diffLines falls back to the
54
+ * legacy single-hunk report (coarser counts, but never a hang), so a
55
+ * pathological multi-thousand-line input cannot stall the tool loop.
56
+ */
57
+ export const DIFF_MAX_REGION_CELLS = 20_000;
58
+ export const DIFF_MAX_REGION_LINES = 5_000;
50
59
  /**
51
60
  * Compute a minimal line diff between two texts.
52
- * Returns an array of hunks (empty when unchanged). Uses common-prefix/suffix
53
- * trimming then reports the changed middle block — sufficient and deterministic
54
- * for the small atomic edits this toolchain produces.
61
+ * Returns an array of hunks (empty when unchanged).
62
+ *
63
+ * Common prefix/suffix are trimmed first, then the changed middle block is
64
+ * aligned EXACTLY (suffix-LCS dynamic programming, bounded by
65
+ * DIFF_MAX_REGION_CELLS / DIFF_MAX_REGION_LINES) and split into one hunk per
66
+ * run of changed lines separated by unchanged lines. This keeps counts honest:
67
+ * changing line 1 AND line 100 of a 200-line file reports {added:2, removed:2}
68
+ * across two hunks, not {100,100} for the whole span — the atomic `max_lines`
69
+ * gate and the persisted FixRecord line counts are derived from these numbers.
70
+ * When the region exceeds the budget, the old single-hunk behavior is kept so
71
+ * huge inputs degrade to coarse-but-correct counts instead of hanging.
55
72
  */
56
73
  export function diffLines(before, after) {
57
74
  const a = before.split('\n');
@@ -69,6 +86,13 @@ export function diffLines(before, after) {
69
86
  const added = b.slice(start, endB);
70
87
  if (removed.length === 0 && added.length === 0)
71
88
  return [];
89
+ return alignRegion(removed, added, start) ?? singleRegionHunk(removed, added, start);
90
+ }
91
+ /**
92
+ * Legacy single-hunk report for the trimmed changed block (also the bounded
93
+ * fallback when `alignRegion` refuses an over-budget region).
94
+ */
95
+ function singleRegionHunk(removed, added, start) {
72
96
  const contentLines = [];
73
97
  for (const line of removed)
74
98
  contentLines.push(`- ${line}`);
@@ -84,6 +108,81 @@ export function diffLines(before, after) {
84
108
  },
85
109
  ];
86
110
  }
111
+ /**
112
+ * Exactly align the trimmed changed block and split it into per-spot hunks
113
+ * (each maximal run of changed lines, separated by at least one unchanged
114
+ * line). Returns `null` when the region exceeds the DP budget so the caller
115
+ * can fall back to {@link singleRegionHunk}.
116
+ *
117
+ * Hunk coordinates follow GNU `diff -U0` conventions: for a pure insertion
118
+ * `oldStart` is the line BEFORE the insertion point (oldLines = 0), for a pure
119
+ * deletion `newStart` is the line before the deletion point (newLines = 0),
120
+ * and for a replacement both point at the replaced line.
121
+ */
122
+ function alignRegion(removed, added, start) {
123
+ const m = removed.length;
124
+ const n = added.length;
125
+ if (m > DIFF_MAX_REGION_LINES || n > DIFF_MAX_REGION_LINES || m * n > DIFF_MAX_REGION_CELLS)
126
+ return null;
127
+ // dp[i][j] = LCS length of removed[i..] / added[j..] (suffix table).
128
+ const stride = n + 1;
129
+ const dp = new Int32Array((m + 1) * stride);
130
+ for (let i = m - 1; i >= 0; i--) {
131
+ for (let j = n - 1; j >= 0; j--) {
132
+ dp[i * stride + j] = removed[i] === added[j]
133
+ ? dp[(i + 1) * stride + j + 1] + 1
134
+ : Math.max(dp[(i + 1) * stride + j], dp[i * stride + j + 1]);
135
+ }
136
+ }
137
+ const hunks = [];
138
+ let open = null;
139
+ const closeHunk = () => {
140
+ if (!open)
141
+ return;
142
+ hunks.push({
143
+ // oldLines > 0 → first removed line (1-based); pure insertion → the
144
+ // line before the insertion point (= its 0-based index).
145
+ oldStart: start + open.iOpen + (open.oldLines > 0 ? 1 : 0),
146
+ oldLines: open.oldLines,
147
+ newStart: start + open.jOpen + (open.newLines > 0 ? 1 : 0),
148
+ newLines: open.newLines,
149
+ content: open.content.join('\n'),
150
+ });
151
+ open = null;
152
+ };
153
+ let i = 0; // region index into removed
154
+ let j = 0; // region index into added
155
+ while (i < m || j < n) {
156
+ // A matching pair always belongs to SOME optimal alignment (CLRS
157
+ // x_i = y_j ⇒ LCS(i,j) = 1 + LCS(i+1,j+1)), so greedily closing the
158
+ // current hunk here is exact, not heuristic.
159
+ if (i < m && j < n && removed[i] === added[j]) {
160
+ closeHunk();
161
+ i++;
162
+ j++;
163
+ continue;
164
+ }
165
+ // Diverged: consume whichever side the suffix table says costs less.
166
+ // Ties prefer removal so a plain replacement reads `- old / + new`.
167
+ const takeRemoved = i >= m ? false : j >= n ? true : dp[(i + 1) * stride + j] >= dp[i * stride + j + 1];
168
+ if (takeRemoved) {
169
+ if (!open)
170
+ open = { iOpen: i, jOpen: j, oldLines: 0, newLines: 0, content: [] };
171
+ open.oldLines++;
172
+ open.content.push(`- ${removed[i]}`);
173
+ i++;
174
+ }
175
+ else {
176
+ if (!open)
177
+ open = { iOpen: i, jOpen: j, oldLines: 0, newLines: 0, content: [] };
178
+ open.newLines++;
179
+ open.content.push(`+ ${added[j]}`);
180
+ j++;
181
+ }
182
+ }
183
+ closeHunk();
184
+ return hunks;
185
+ }
87
186
  /** Added/removed line counts for a change (derived from diffLines). */
88
187
  export function countChangedLines(before, after) {
89
188
  const hunks = diffLines(before, after);
@@ -124,11 +223,17 @@ export function readRegistry(projectRoot) {
124
223
  // contain a round without a `records` array, or records that are missing
125
224
  // their id / finding object — all of which would make readers
126
225
  // (findFixRecord / recordsForFile / iterate_diff) throw or sum NaN.
226
+ // Numeric counters are also floored to non-negative integers: the status
227
+ // output schema declares them `type: 'integer'`, so a `fixedCount: 1.5`
228
+ // must never reach iterate_status.
229
+ const intCount = (v) => typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0;
127
230
  parsed.rounds = parsed.rounds
128
231
  .filter((r) => r && typeof r === 'object' && Array.isArray(r.records))
129
232
  .map((r) => ({
130
233
  ...r,
131
234
  round: typeof r.round === 'number' && Number.isFinite(r.round) ? Math.floor(r.round) : 0,
235
+ fixedCount: intCount(r.fixedCount),
236
+ failedCount: intCount(r.failedCount),
132
237
  records: r.records.filter((rec) => !!rec &&
133
238
  typeof rec === 'object' &&
134
239
  typeof rec.id === 'string' &&
@@ -151,12 +256,18 @@ export function findFixRecord(registry, id) {
151
256
  }
152
257
  return undefined;
153
258
  }
154
- /** All fix records for a file, in chronological order. */
259
+ /**
260
+ * All fix records for a file, in chronological order.
261
+ * Both sides are canonicalized with {@link normalizeProjectPath} so a record
262
+ * persisted as `./README.md` (pre-normalization builds) still matches a
263
+ * query for `README.md` and vice versa.
264
+ */
155
265
  export function recordsForFile(registry, file) {
266
+ const want = normalizeProjectPath(file);
156
267
  const out = [];
157
268
  for (const round of registry.rounds) {
158
269
  for (const r of round.records) {
159
- if (r.finding.file === file && r.success)
270
+ if (normalizeProjectPath(r.finding.file) === want && r.success)
160
271
  out.push(r);
161
272
  }
162
273
  }
@@ -242,6 +353,63 @@ export function resolveProjectFile(projectRoot, file) {
242
353
  }
243
354
  return { ok: true, resolved };
244
355
  }
356
+ /**
357
+ * Canonicalize a model-supplied relative path ONCE at argument intake.
358
+ *
359
+ * Strips a leading `./`, collapses duplicate separators, and resolves `.` /
360
+ * `..` segments lexically so the containment check, the protected-path glob,
361
+ * the persisted FixRecord, and every later diff/rollback lookup all compare
362
+ * the SAME string — a raw `./README.md` must not dodge a `README.md` veto
363
+ * glob, and `src/../README.md` must record as `README.md`.
364
+ * Absolute paths and `..` escapes survive normalization and are rejected by
365
+ * {@link resolveProjectFile} afterwards. Pure, exported for unit tests.
366
+ */
367
+ export function normalizeProjectPath(file) {
368
+ // An empty intake must stay empty (node's normalize('') === '.') so
369
+ // resolveProjectFile still rejects it instead of resolving to the root.
370
+ if (typeof file !== 'string' || file === '')
371
+ return '';
372
+ return normalize(file);
373
+ }
374
+ /**
375
+ * Validate a registry-recorded backup path before it is read (iterate_diff)
376
+ * or used to overwrite a project file (iterate_rollback).
377
+ *
378
+ * The registry is a plain JSON file on disk: a hand-edited/corrupt record can
379
+ * point `backupPath` anywhere, turning diff into an arbitrary-file read and
380
+ * rollback into arbitrary-content injection. A backup must stay inside the
381
+ * project's fixes directory — lexically AND via realpath when it exists (so a
382
+ * symlink planted inside `.iterate/fixes` cannot smuggle content either).
383
+ */
384
+ export function resolveBackupPath(projectRoot, backupPath) {
385
+ if (typeof backupPath !== 'string' || backupPath.trim().length === 0) {
386
+ return { ok: false, reason: 'backup path is missing from the fix record' };
387
+ }
388
+ const fixes = fixesDir(projectRoot);
389
+ // Relative entries are anchored at the project root (never the process cwd)
390
+ // so `../../etc/passwd` fails the containment check below.
391
+ const resolved = isAbsolute(backupPath) ? normalize(backupPath) : join(projectRoot, backupPath);
392
+ const prefix = fixes.endsWith(sep) ? fixes : fixes + sep;
393
+ if (resolved !== fixes && !resolved.startsWith(prefix)) {
394
+ return { ok: false, reason: `backup path escapes the fixes directory: ${backupPath}` };
395
+ }
396
+ if (existsSync(resolved)) {
397
+ let realFixes;
398
+ let real;
399
+ try {
400
+ realFixes = realpathSync(fixes);
401
+ real = realpathSync(resolved);
402
+ }
403
+ catch {
404
+ return { ok: false, reason: `failed to resolve real path for backup containment check: ${backupPath}` };
405
+ }
406
+ const realPrefix = realFixes.endsWith(sep) ? realFixes : realFixes + sep;
407
+ if (real !== realFixes && !real.startsWith(realPrefix)) {
408
+ return { ok: false, reason: `backup path escapes the fixes directory (symlink): ${backupPath}` };
409
+ }
410
+ }
411
+ return { ok: true, resolved };
412
+ }
245
413
  // ─── Shared execute helpers ──────────────────────────────────────────────────
246
414
  /**
247
415
  * Minimal glob matcher for personalization.protected_paths.
@@ -381,9 +549,15 @@ export function registerFixTool(ctx) {
381
549
  const { config } = loadEffectiveConfig(projectRoot);
382
550
  const maxLines = config.atomic?.max_lines ?? 20;
383
551
  const maxAdjacentMethods = config.atomic?.max_adjacent_methods ?? 3;
384
- const file = typeof args.file === 'string' ? args.file : '';
385
- if (!file)
552
+ // Canonicalize ONCE at intake: `./README.md` and `src/../README.md`
553
+ // must behave exactly like `README.md` downstream — the protected-path
554
+ // glob, the fix id, the persisted FixRecord, and later diff/rollback
555
+ // lookups all compare this normalized form (escapes/absolute paths
556
+ // survive normalization and are rejected by resolveProjectFile).
557
+ const rawFile = typeof args.file === 'string' ? args.file : '';
558
+ if (!rawFile)
386
559
  return { ok: false, error: 'file is required' };
560
+ const file = normalizeProjectPath(rawFile);
387
561
  if (typeof args.content !== 'string')
388
562
  return { ok: false, error: 'content must be a string' };
389
563
  if (args.content.length > MAX_FIX_CONTENT_CHARS) {
@@ -395,21 +569,23 @@ export function registerFixTool(ctx) {
395
569
  if (typeof args.round !== 'number' || !Number.isInteger(args.round) || args.round < 1) {
396
570
  return { ok: false, error: 'round must be a positive integer' };
397
571
  }
398
- const finding = args.finding;
399
- if (!finding || typeof finding !== 'object') {
572
+ const rawFinding = args.finding;
573
+ if (!rawFinding || typeof rawFinding !== 'object') {
400
574
  return { ok: false, error: 'finding must be an object' };
401
575
  }
402
- if (typeof finding.file !== 'string' || finding.file.trim().length === 0) {
576
+ if (typeof rawFinding.file !== 'string' || rawFinding.file.trim().length === 0) {
403
577
  return { ok: false, error: 'finding.file must be a non-empty string' };
404
578
  }
405
- if (typeof finding.dimension !== 'string' || finding.dimension.trim().length === 0) {
579
+ if (typeof rawFinding.dimension !== 'string' || rawFinding.dimension.trim().length === 0) {
406
580
  return { ok: false, error: 'finding.dimension must be a non-empty string' };
407
581
  }
408
582
  // The finding must reference the file being fixed — the fix id and the
409
583
  // rollback/diff target are derived from finding.file, so a mismatch
410
- // would back up/restore the WRONG file.
584
+ // would back up/restore the WRONG file. Both sides are compared in
585
+ // their NORMALIZED form (`./src/a.ts` and `src/a.ts` are the same fix).
586
+ const finding = { ...rawFinding, file: normalizeProjectPath(rawFinding.file) };
411
587
  if (finding.file !== file) {
412
- return { ok: false, error: `finding.file ("${finding.file}") must match the file being fixed ("${file}")` };
588
+ return { ok: false, error: `finding.file ("${rawFinding.file}") must match the file being fixed ("${rawFile}")` };
413
589
  }
414
590
  // Full finding validation, mirroring the review schema: malformed
415
591
  // findings would produce lossy registry/log entries and a degraded id.
@@ -448,90 +624,102 @@ export function registerFixTool(ctx) {
448
624
  };
449
625
  }
450
626
  const id = fixId(finding);
451
- const registry = readRegistry(projectRoot);
452
- if (findFixRecord(registry, id)) {
453
- return { ok: false, error: `finding already fixed this run (id: ${id})`, id };
454
- }
455
- // No-op guard: content-identical "fixes" (e.g. a fixer that re-sent the
456
- // file unchanged) must never burn a backup, a write, or a registry/success
457
- // record. Placed after the registry check so a re-sent fix of an id that
458
- // was ALREADY fixed is still reported as "already fixed this run".
459
- if (added === 0 && removed === 0) {
460
- return {
461
- ok: false,
462
- error: `no changes: the supplied content for ${file} is identical to the current content — apply a real edit`,
463
- };
464
- }
465
- const target = resolveProjectFile(projectRoot, file);
466
- if (!target.ok)
467
- return { ok: false, error: target.reason };
468
- // Personalization guards (SKILL.md Phase 2): protected_paths veto the
469
- // fix outright; forbidden_fixes veto fix approaches appearing in the
470
- // new content. Both are security-relevant, so they are enforced here
471
- // in the tool, not left to the model.
472
- const pers = config.personalization;
473
- const protectedPaths = Array.isArray(pers?.protected_paths)
474
- ? pers.protected_paths.filter((p) => typeof p === 'string' && p.length > 0)
475
- : [];
476
- for (const pattern of protectedPaths) {
477
- if (globMatch(file, pattern)) {
478
- return { ok: false, error: `skipped: ${file} matches protected path "${pattern}" (personalization.protected_paths forbids modifying it)` };
479
- }
480
- }
481
- const forbiddenFixes = Array.isArray(pers?.forbidden_fixes)
482
- ? pers.forbidden_fixes.filter((f) => typeof f === 'string' && f.length > 0)
483
- : [];
484
- for (const forbidden of forbiddenFixes) {
485
- if (args.content.includes(forbidden)) {
486
- return { ok: false, error: `fix uses a forbidden approach: "${forbidden}" appears in the new content (personalization.forbidden_fixes)` };
487
- }
488
- }
489
627
  const timestamp = new Date().toISOString();
490
- const backupPath = fixBackupPath(projectRoot, id, timestamp);
491
- try {
492
- mkdirSync(fixesDir(projectRoot), { recursive: true });
493
- copyFileSync(target.resolved, backupPath);
494
- }
495
- catch (err) {
496
- return { ok: false, error: `failed to create backup: ${String(err)}` };
497
- }
498
- try {
499
- writeTextAtomic(target.resolved, args.content);
500
- }
501
- catch (err) {
502
- return { ok: false, error: `failed to write file: ${String(err)}` };
503
- }
504
- const record = {
505
- id,
506
- timestamp,
507
- round: args.round,
508
- finding,
509
- backupPath,
510
- diffSummary: buildDiffSummary(hunks),
511
- linesAdded: added,
512
- linesRemoved: removed,
513
- success: true,
514
- };
515
- const nextRegistry = upsertRecord(registry, record);
516
- try {
517
- writeJsonAtomic(fixRegistryPath(projectRoot), nextRegistry);
518
- }
519
- catch (err) {
520
- // Registry write failed → the file was already modified but no record
521
- // exists, so a later rollback/diff could never see it and a retry would
522
- // back up the already-fixed content as "original". Restore the file
523
- // from the backup atomically to leave the tree exactly as it was.
524
- try {
525
- writeTextAtomic(target.resolved, readFileSync(backupPath, 'utf-8'));
628
+ // Cross-process read-modify-write guard: the registry read (dup check)
629
+ // and the record write form ONE window — two plugin processes racing
630
+ // here could each read the old registry and silently drop the other's
631
+ // update. The shared advisory lock (src/file-lock.ts) serializes the
632
+ // window; the decision-log append runs after it is released.
633
+ const applied = withProjectLock(projectRoot, 'fix-registry', () => {
634
+ const registry = readRegistry(projectRoot);
635
+ if (findFixRecord(registry, id)) {
636
+ return { ok: false, error: `finding already fixed this run (id: ${id})`, id };
526
637
  }
527
- catch (restoreErr) {
638
+ // No-op guard: content-identical "fixes" (e.g. a fixer that re-sent the
639
+ // file unchanged) must never burn a backup, a write, or a registry/success
640
+ // record. Placed after the registry check so a re-sent fix of an id that
641
+ // was ALREADY fixed is still reported as "already fixed this run".
642
+ if (added === 0 && removed === 0) {
528
643
  return {
529
644
  ok: false,
530
- error: `failed to write fix registry: ${String(err)}; additionally failed to restore ${file} from backup: ${String(restoreErr)}`,
645
+ error: `no changes: the supplied content for ${file} is identical to the current content — apply a real edit`,
531
646
  };
532
647
  }
533
- return { ok: false, error: `failed to write fix registry: ${String(err)} (file restored from backup)` };
534
- }
648
+ const target = resolveProjectFile(projectRoot, file);
649
+ if (!target.ok)
650
+ return { ok: false, error: target.reason };
651
+ // Personalization guards (SKILL.md Phase 2): protected_paths veto the
652
+ // fix outright; forbidden_fixes veto fix approaches appearing in the
653
+ // new content. Both are security-relevant, so they are enforced here
654
+ // in the tool, not left to the model. The glob runs against the
655
+ // NORMALIZED `file`, so `./README.md` cannot dodge a `README.md` veto.
656
+ const pers = config.personalization;
657
+ const protectedPaths = Array.isArray(pers?.protected_paths)
658
+ ? pers.protected_paths.filter((p) => typeof p === 'string' && p.length > 0)
659
+ : [];
660
+ for (const pattern of protectedPaths) {
661
+ if (globMatch(file, pattern)) {
662
+ return { ok: false, error: `skipped: ${file} matches protected path "${pattern}" (personalization.protected_paths forbids modifying it)` };
663
+ }
664
+ }
665
+ const forbiddenFixes = Array.isArray(pers?.forbidden_fixes)
666
+ ? pers.forbidden_fixes.filter((f) => typeof f === 'string' && f.length > 0)
667
+ : [];
668
+ for (const forbidden of forbiddenFixes) {
669
+ if (args.content.includes(forbidden)) {
670
+ return { ok: false, error: `fix uses a forbidden approach: "${forbidden}" appears in the new content (personalization.forbidden_fixes)` };
671
+ }
672
+ }
673
+ const backupPath = fixBackupPath(projectRoot, id, timestamp);
674
+ try {
675
+ mkdirSync(fixesDir(projectRoot), { recursive: true });
676
+ copyFileSync(target.resolved, backupPath);
677
+ }
678
+ catch (err) {
679
+ return { ok: false, error: `failed to create backup: ${String(err)}` };
680
+ }
681
+ try {
682
+ writeTextAtomic(target.resolved, args.content);
683
+ }
684
+ catch (err) {
685
+ return { ok: false, error: `failed to write file: ${String(err)}` };
686
+ }
687
+ const record = {
688
+ id,
689
+ timestamp,
690
+ round: args.round,
691
+ finding,
692
+ backupPath,
693
+ diffSummary: buildDiffSummary(hunks),
694
+ linesAdded: added,
695
+ linesRemoved: removed,
696
+ success: true,
697
+ };
698
+ const nextRegistry = upsertRecord(registry, record);
699
+ try {
700
+ writeJsonAtomic(fixRegistryPath(projectRoot), nextRegistry);
701
+ }
702
+ catch (err) {
703
+ // Registry write failed → the file was already modified but no record
704
+ // exists, so a later rollback/diff could never see it and a retry would
705
+ // back up the already-fixed content as "original". Restore the file
706
+ // from the backup atomically to leave the tree exactly as it was.
707
+ try {
708
+ writeTextAtomic(target.resolved, readFileSync(backupPath, 'utf-8'));
709
+ }
710
+ catch (restoreErr) {
711
+ return {
712
+ ok: false,
713
+ error: `failed to write fix registry: ${String(err)}; additionally failed to restore ${file} from backup: ${String(restoreErr)}`,
714
+ };
715
+ }
716
+ return { ok: false, error: `failed to write fix registry: ${String(err)} (file restored from backup)` };
717
+ }
718
+ return { ok: true, record, backupPath };
719
+ });
720
+ if (!applied.ok)
721
+ return applied;
722
+ const { record, backupPath } = applied;
535
723
  const logRes = appendDecisionEntry(projectRoot, {
536
724
  timestamp,
537
725
  round: args.round,
@@ -615,18 +803,26 @@ export function registerDiffTool(ctx) {
615
803
  return { ok: false, error: resolved.reason };
616
804
  const projectRoot = resolved.root;
617
805
  const registry = readRegistry(projectRoot);
618
- const file = typeof args.file === 'string' && args.file.trim() ? args.file : undefined;
806
+ const file = typeof args.file === 'string' && args.file.trim()
807
+ ? normalizeProjectPath(args.file)
808
+ : undefined;
619
809
  if (file) {
620
810
  const records = recordsForFile(registry, file);
621
811
  const first = records[0];
622
812
  if (!first)
623
813
  return { ok: false, error: `no fixes recorded for ${file}` };
814
+ // Containment: the registry is on-disk JSON that a hand edit can
815
+ // point anywhere — a tampered backupPath must never turn this
816
+ // read-only diff into an arbitrary-file read.
817
+ const backup = resolveBackupPath(projectRoot, first.backupPath);
818
+ if (!backup.ok)
819
+ return { ok: false, error: `invalid backup for ${file}: ${backup.reason}` };
624
820
  const current = readProjectFile(projectRoot, file);
625
821
  if (!current.ok)
626
822
  return { ok: false, error: current.reason };
627
823
  let original = '';
628
824
  try {
629
- original = readFileSync(first.backupPath, 'utf-8');
825
+ original = readFileSync(backup.resolved, 'utf-8');
630
826
  }
631
827
  catch (err) {
632
828
  return { ok: false, error: `backup missing for ${file}: ${String(err)}` };
@@ -727,31 +923,63 @@ export function registerRollbackTool(ctx) {
727
923
  const id = typeof args.id === 'string' ? args.id : '';
728
924
  if (!id)
729
925
  return { ok: false, error: 'id is required' };
730
- const registry = readRegistry(projectRoot);
731
- const record = findFixRecord(registry, id);
732
- if (!record)
733
- return { ok: false, error: `fix not found: ${id}` };
734
- if (!existsSync(record.backupPath)) {
735
- return { ok: false, error: `backup missing for fix ${id}` };
736
- }
737
- const target = resolveProjectFile(projectRoot, record.finding.file);
738
- if (!target.ok)
739
- return { ok: false, error: target.reason };
740
- try {
741
- // Atomic restore: never leave a truncated source file if we crash
742
- // mid-restore (matches the writeTextAtomic guarantee used by apply).
743
- writeTextAtomic(target.resolved, readFileSync(record.backupPath, 'utf-8'));
744
- }
745
- catch (err) {
746
- return { ok: false, error: `failed to restore backup: ${String(err)}` };
747
- }
748
- const nextRegistry = removeRecord(registry, id);
749
- try {
750
- writeJsonAtomic(fixRegistryPath(projectRoot), nextRegistry);
751
- }
752
- catch (err) {
753
- return { ok: false, error: `failed to update fix registry: ${String(err)}` };
754
- }
926
+ // One locked read-modify-write window (see src/file-lock.ts): the
927
+ // registry read, the file restore, and the record removal must not
928
+ // interleave with another process's fix/rollback or the update is lost.
929
+ const restored = withProjectLock(projectRoot, 'fix-registry', () => {
930
+ const registry = readRegistry(projectRoot);
931
+ const record = findFixRecord(registry, id);
932
+ if (!record)
933
+ return { ok: false, error: `fix not found: ${id}` };
934
+ // LIFO safety: restoring an OLDER backup would silently destroy every
935
+ // NEWER successful fix to the same file — their records would stay
936
+ // success:true while the bytes they wrote are gone. Refuse and name
937
+ // the clobbered ids so the caller can roll back in reverse order first.
938
+ const fileRecords = recordsForFile(registry, record.finding.file);
939
+ const targetIdx = fileRecords.findIndex((r) => r.id === id);
940
+ const newer = targetIdx >= 0
941
+ ? fileRecords.slice(targetIdx + 1)
942
+ : fileRecords.filter((r) => r.id !== id && r.timestamp > record.timestamp);
943
+ if (newer.length > 0) {
944
+ const clobbered = newer.map((r) => r.id).join(', ');
945
+ return {
946
+ ok: false,
947
+ error: `rollback refused: fix ${id} is older than fix(es) ${clobbered} on ${record.finding.file} — ` +
948
+ 'restoring its backup would destroy them. Roll back the later fix(es) first (LIFO order).',
949
+ };
950
+ }
951
+ // Containment: the registry is on-disk JSON that a hand edit can
952
+ // point anywhere — a tampered backupPath must never inject arbitrary
953
+ // content into the project file.
954
+ const backup = resolveBackupPath(projectRoot, record.backupPath);
955
+ if (!backup.ok)
956
+ return { ok: false, error: `invalid backup for fix ${id}: ${backup.reason}` };
957
+ if (!existsSync(backup.resolved)) {
958
+ return { ok: false, error: `backup missing for fix ${id}` };
959
+ }
960
+ const target = resolveProjectFile(projectRoot, record.finding.file);
961
+ if (!target.ok)
962
+ return { ok: false, error: target.reason };
963
+ try {
964
+ // Atomic restore: never leave a truncated source file if we crash
965
+ // mid-restore (matches the writeTextAtomic guarantee used by apply).
966
+ writeTextAtomic(target.resolved, readFileSync(backup.resolved, 'utf-8'));
967
+ }
968
+ catch (err) {
969
+ return { ok: false, error: `failed to restore backup: ${String(err)}` };
970
+ }
971
+ const nextRegistry = removeRecord(registry, id);
972
+ try {
973
+ writeJsonAtomic(fixRegistryPath(projectRoot), nextRegistry);
974
+ }
975
+ catch (err) {
976
+ return { ok: false, error: `failed to update fix registry: ${String(err)}` };
977
+ }
978
+ return { ok: true, record };
979
+ });
980
+ if (!restored.ok)
981
+ return restored;
982
+ const record = restored.record;
755
983
  const logRes = appendDecisionEntry(projectRoot, {
756
984
  timestamp: new Date().toISOString(),
757
985
  round: record.round,