@prestyj/cli 5.29.0 → 5.30.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 (137) hide show
  1. package/assets/motion/skills/app-walkthrough/SKILL.md +4 -0
  2. package/assets/motion/skills/before-after/SKILL.md +4 -0
  3. package/assets/motion/skills/dev-tool-video/SKILL.md +4 -0
  4. package/assets/motion/skills/launch-video/SKILL.md +4 -0
  5. package/assets/motion/skills/match-reference/SKILL.md +4 -0
  6. package/assets/motion/skills/motion/SKILL.md +78 -21
  7. package/assets/motion/skills/website-video/SKILL.md +47 -30
  8. package/assets/skills/bulletproof/SKILL.md +1 -1
  9. package/assets/skills/compliance-guard/references/us.md +1 -1
  10. package/assets/skills/lean/SKILL.md +1 -1
  11. package/assets/skills/refactoring/SKILL.md +3 -1
  12. package/dist/app-error.d.ts +21 -0
  13. package/dist/app-error.js +39 -0
  14. package/dist/app-error.js.map +1 -0
  15. package/dist/app-sidecar.js +42 -35
  16. package/dist/app-sidecar.js.map +1 -1
  17. package/dist/cli/thinking-arg.d.ts +6 -0
  18. package/dist/cli/thinking-arg.js +13 -0
  19. package/dist/cli/thinking-arg.js.map +1 -0
  20. package/dist/cli.d.ts +1 -2
  21. package/dist/cli.js +5 -8
  22. package/dist/cli.js.map +1 -1
  23. package/dist/core/agent-session.d.ts +3 -2
  24. package/dist/core/agent-session.js +34 -10
  25. package/dist/core/agent-session.js.map +1 -1
  26. package/dist/core/checklist-detection.d.ts +11 -0
  27. package/dist/core/checklist-detection.js +238 -0
  28. package/dist/core/checklist-detection.js.map +1 -0
  29. package/dist/core/checklist-items.d.ts +180 -0
  30. package/dist/core/checklist-items.js +204 -0
  31. package/dist/core/checklist-items.js.map +1 -0
  32. package/dist/core/checklist-prompt.d.ts +3 -0
  33. package/dist/core/checklist-prompt.js +34 -0
  34. package/dist/core/checklist-prompt.js.map +1 -0
  35. package/dist/core/checklist-snapshot.d.ts +13 -0
  36. package/dist/core/checklist-snapshot.js +29 -0
  37. package/dist/core/checklist-snapshot.js.map +1 -0
  38. package/dist/core/checklist-store.d.ts +59 -0
  39. package/dist/core/checklist-store.js +242 -0
  40. package/dist/core/checklist-store.js.map +1 -0
  41. package/dist/core/codex-request-shape.d.ts +12 -0
  42. package/dist/core/codex-request-shape.js +31 -0
  43. package/dist/core/codex-request-shape.js.map +1 -0
  44. package/dist/core/compaction/compactor.js +6 -0
  45. package/dist/core/compaction/compactor.js.map +1 -1
  46. package/dist/core/lsp/client.d.ts +4 -0
  47. package/dist/core/lsp/client.js +71 -3
  48. package/dist/core/lsp/client.js.map +1 -1
  49. package/dist/core/lsp/manager.d.ts +1 -0
  50. package/dist/core/lsp/manager.js +9 -3
  51. package/dist/core/lsp/manager.js.map +1 -1
  52. package/dist/core/lsp/project-root.d.ts +9 -0
  53. package/dist/core/lsp/project-root.js +75 -0
  54. package/dist/core/lsp/project-root.js.map +1 -0
  55. package/dist/core/lsp/reference-project.d.ts +6 -0
  56. package/dist/core/lsp/reference-project.js +110 -0
  57. package/dist/core/lsp/reference-project.js.map +1 -0
  58. package/dist/core/lsp/servers.js +1 -0
  59. package/dist/core/lsp/servers.js.map +1 -1
  60. package/dist/core/nolan-context.js +7 -3
  61. package/dist/core/nolan-context.js.map +1 -1
  62. package/dist/core/prompt-commands.js +95 -0
  63. package/dist/core/prompt-commands.js.map +1 -1
  64. package/dist/core/settings-manager.d.ts +10 -0
  65. package/dist/core/settings-manager.js +10 -0
  66. package/dist/core/settings-manager.js.map +1 -1
  67. package/dist/core/subagent-manager.d.ts +2 -1
  68. package/dist/core/subagent-manager.js +8 -5
  69. package/dist/core/subagent-manager.js.map +1 -1
  70. package/dist/core/subagent-receipt.js +3 -1
  71. package/dist/core/subagent-receipt.js.map +1 -1
  72. package/dist/modes/acp-mode.d.ts +2 -1
  73. package/dist/modes/acp-mode.js +4 -1
  74. package/dist/modes/acp-mode.js.map +1 -1
  75. package/dist/modes/subagent-worker-mode.d.ts +2 -0
  76. package/dist/modes/subagent-worker-mode.js +1 -3
  77. package/dist/modes/subagent-worker-mode.js.map +1 -1
  78. package/dist/motion-agent/motion-prompt.d.ts +1 -1
  79. package/dist/motion-agent/motion-prompt.js +4 -3
  80. package/dist/motion-agent/motion-prompt.js.map +1 -1
  81. package/dist/motion-agent/motion-review-session.js +7 -3
  82. package/dist/motion-agent/motion-review-session.js.map +1 -1
  83. package/dist/system-prompt.js +15 -13
  84. package/dist/system-prompt.js.map +1 -1
  85. package/dist/tools/bash.d.ts +4 -1
  86. package/dist/tools/bash.js +32 -7
  87. package/dist/tools/bash.js.map +1 -1
  88. package/dist/tools/checklist.d.ts +56 -0
  89. package/dist/tools/checklist.js +178 -0
  90. package/dist/tools/checklist.js.map +1 -0
  91. package/dist/tools/code-nav.d.ts +1 -1
  92. package/dist/tools/code-nav.js +3 -2
  93. package/dist/tools/code-nav.js.map +1 -1
  94. package/dist/tools/edit-targets.d.ts +10 -0
  95. package/dist/tools/edit-targets.js +31 -0
  96. package/dist/tools/edit-targets.js.map +1 -0
  97. package/dist/tools/edit.d.ts +11 -3
  98. package/dist/tools/edit.js +386 -312
  99. package/dist/tools/edit.js.map +1 -1
  100. package/dist/tools/exit-plan.d.ts +2 -2
  101. package/dist/tools/exit-plan.js +7 -1
  102. package/dist/tools/exit-plan.js.map +1 -1
  103. package/dist/tools/index.d.ts +5 -3
  104. package/dist/tools/index.js +5 -1
  105. package/dist/tools/index.js.map +1 -1
  106. package/dist/tools/prompt-hints.js +6 -1
  107. package/dist/tools/prompt-hints.js.map +1 -1
  108. package/dist/tools/skill.js +6 -3
  109. package/dist/tools/skill.js.map +1 -1
  110. package/dist/tools/subagent-shared.d.ts +6 -8
  111. package/dist/tools/subagent-shared.js +17 -11
  112. package/dist/tools/subagent-shared.js.map +1 -1
  113. package/dist/tools/subagent.d.ts +8 -1
  114. package/dist/tools/subagent.js +21 -9
  115. package/dist/tools/subagent.js.map +1 -1
  116. package/dist/tools/tasks.d.ts +1 -1
  117. package/dist/tools/tool-tiers.d.ts +6 -0
  118. package/dist/tools/tool-tiers.js +8 -1
  119. package/dist/tools/tool-tiers.js.map +1 -1
  120. package/dist/tools/verification-review.d.ts +2 -0
  121. package/dist/tools/verification-review.js +41 -0
  122. package/dist/tools/verification-review.js.map +1 -0
  123. package/dist/ui/App.js +8 -3
  124. package/dist/ui/App.js.map +1 -1
  125. package/dist/ui/components/ToolExecution.js +3 -2
  126. package/dist/ui/components/ToolExecution.js.map +1 -1
  127. package/dist/ui/hooks/useModeState.js +15 -0
  128. package/dist/ui/hooks/useModeState.js.map +1 -1
  129. package/dist/ui/terminal-history.js +3 -1
  130. package/dist/ui/terminal-history.js.map +1 -1
  131. package/dist/ui/tool-line-summary.js +14 -2
  132. package/dist/ui/tool-line-summary.js.map +1 -1
  133. package/dist/ui/transcript/tool-presentation.js +1 -1
  134. package/dist/ui/transcript/tool-presentation.js.map +1 -1
  135. package/dist/utils/format.js +5 -1
  136. package/dist/utils/format.js.map +1 -1
  137. package/package.json +4 -4
@@ -85,27 +85,48 @@ const STRINGIFIED_EDITS_ERROR = "`edits` arrived as a JSON-encoded string and co
85
85
  "Send `edits` as a real JSON array of objects, never as a string. " +
86
86
  "Re-sending the same large payload usually breaks the same way: split the work into " +
87
87
  "several `edit` calls carrying one or two smaller edits each.";
88
- const EditParams = z.object({
88
+ const EditList = z
89
+ .preprocess(coerceStringifiedEdits, z
90
+ .array(EditItem, {
91
+ // Narrow to "an array was expected, a string arrived" so a nested
92
+ // string-typed mistake (`anchor: "x"`, `replace_all: "true"`) keeps
93
+ // its own accurate message. Any non-matching issue returns undefined
94
+ // and falls through to Zod's default.
95
+ error: (issue) => issue.code === "invalid_type" &&
96
+ issue.expected === "array" &&
97
+ typeof issue.input === "string"
98
+ ? STRINGIFIED_EDITS_ERROR
99
+ : undefined,
100
+ })
101
+ .min(1))
102
+ .describe("One or more edits applied in order. Each edit operates on the result of the previous one.");
103
+ // Multi-file entries take the text form only: a replay test batched all 7
104
+ // files 8/8 times either way, and dropping the anchor/span forms here keeps
105
+ // ~2k chars of duplicated schema out of every request.
106
+ const TextEditItem = EditItem.pick({ old_text: true, new_text: true, replace_all: true });
107
+ const FileEdits = z.object({
89
108
  file_path: z.string().describe("The file path to edit"),
90
109
  edits: z
91
- .preprocess(coerceStringifiedEdits, z
92
- .array(EditItem, {
93
- // Narrow to "an array was expected, a string arrived" so a nested
94
- // string-typed mistake (`anchor: "x"`, `replace_all: "true"`) keeps
95
- // its own accurate message. Any non-matching issue returns undefined
96
- // and falls through to Zod's default.
97
- error: (issue) => issue.code === "invalid_type" &&
98
- issue.expected === "array" &&
99
- typeof issue.input === "string"
100
- ? STRINGIFIED_EDITS_ERROR
101
- : undefined,
102
- })
103
- .min(1))
104
- .describe("One or more edits applied in order. Each edit operates on the result of the previous one."),
110
+ .preprocess(coerceStringifiedEdits, z.array(TextEditItem).min(1))
111
+ .describe("Text-form edits for this file, applied in order."),
112
+ });
113
+ // `files` lets one call cover a multi-file change. Measured on gpt-6-astra
114
+ // (replay of a 7-file refactor at the point where every file had been read):
115
+ // with it the model sent all 7 files in one call 8/8 times; without it 5/8
116
+ // responses carried every edit and the rest fell back to one file per turn.
117
+ const EditParams = z.object({
118
+ file_path: z.string().optional().describe("The file path to edit (single-file form)"),
119
+ edits: EditList.optional(),
120
+ files: z
121
+ .array(FileEdits)
122
+ .min(1)
123
+ .optional()
124
+ .describe("Several files in ONE call: one entry per file, each with text-form edits (old_text/new_text). " +
125
+ "Use instead of file_path/edits when a change spans files."),
105
126
  atomic: z
106
127
  .boolean()
107
128
  .optional()
108
- .describe("If true, fail the whole batch when any edit fails — no changes written. " +
129
+ .describe("If true, fail a file's whole batch when any of its edits fails — that file is left unchanged. " +
109
130
  "Default false: partial-apply, keep every successful edit and report failures " +
110
131
  "for retry. Use atomic only when later edits depend on earlier ones in a way " +
111
132
  "where a half-applied state would be worse than nothing."),
@@ -175,10 +196,11 @@ export function createEditTool(cwd, readFiles, ops = localOperations, planModeRe
175
196
  "text edits then run on the result.\n" +
176
197
  "Partial-apply by default: failed edits are listed for retry, successful ones are still written — " +
177
198
  "re-issue ONLY the listed failures, not the whole batch. " +
178
- "Returns a unified diff.",
199
+ "Returns a unified diff.\n" +
200
+ "Multi-file: pass `files` (one {file_path, edits} entry per file) to change several files in a single call.",
179
201
  parameters: EditParams,
180
202
  executionMode: "sequential",
181
- async execute({ file_path, edits, atomic = false }) {
203
+ async execute({ file_path, edits, files, atomic = false }) {
182
204
  const goalMode = getActiveGoalMode(goalModeRef);
183
205
  if (goalMode !== "off") {
184
206
  return goalModeMutationRestriction("edit", goalMode);
@@ -186,333 +208,385 @@ export function createEditTool(cwd, readFiles, ops = localOperations, planModeRe
186
208
  if (isPlanModeActive(planModeRef)) {
187
209
  return planModeRestriction("edit");
188
210
  }
189
- const resolved = resolvePath(cwd, file_path);
190
- await rejectSymlink(resolved);
191
- // Workspace write guard: outside cwd/tmp/~/.ezcoder requires user approval.
192
- const guard = resolveWriteGuard(cwd, resolved, getWriteGuardSettings?.());
193
- if (!guard.allowed) {
194
- return `Error: ${guard.reason}`;
195
- }
196
- // No prior read is required: every old_text, span and anchor is matched
197
- // against the live content below, which proves the model knows the text
198
- // it replaces (it may have seen it via `cat`, or changed the file itself
199
- // with a script). A read that no longer matches disk is forgotten, so the
200
- // file counts as unread again: the edit still validates against live
201
- // content, but a later full-file `write` needs a fresh read.
202
- if (readFiles?.has(resolved) && !(await isFresh(readFiles, resolved, ops))) {
203
- readFiles.delete(resolved);
211
+ const targets = resolveEditTargets(file_path, edits, files);
212
+ if (!targets.ok)
213
+ throw new Error(targets.error);
214
+ if (targets.value.length === 1) {
215
+ const only = targets.value[0];
216
+ return editOneFile(only.file_path, only.edits, atomic);
204
217
  }
205
- const original = await ops.readFile(resolved);
206
- const hasCRLF = original.includes("\r\n");
207
- const originalNormalized = hasCRLF ? original.replace(/\r\n/g, "\n") : original;
208
- // Anchors pin lines in the file AS READ, so they always verify against the
209
- // original (pre-edit) line array — earlier edits in the batch don't shift
210
- // what an anchor refers to.
211
- const originalLines = originalNormalized.split("\n");
212
- const fileName = path.basename(resolved);
213
- const outcomes = new Array(edits.length);
214
- // ── Phase 1: span-form edits (hash-anchored replacement). Spans resolve
215
- // against the file AS READ and apply bottom-up so indices stay valid.
216
- // Text-form edits then run on the result in phase 2.
217
- const isSpanForm = (e) => e.span !== undefined || e.lines !== undefined;
218
- const spanResolved = [];
219
- for (let i = 0; i < edits.length; i++) {
220
- const e = edits[i];
221
- if (!isSpanForm(e)) {
222
- if (e.old_text === undefined || e.new_text === undefined) {
223
- outcomes[i] = {
224
- ok: false,
225
- failure: {
226
- reason: "invalid",
227
- detail: "provide either old_text+new_text, or span+lines — this edit has neither",
228
- },
229
- };
230
- }
231
- continue;
218
+ // Files apply in order, each independently: one file's failure never
219
+ // rolls back another, and the result says exactly which to re-issue.
220
+ const parts = [];
221
+ const diffs = [];
222
+ let failed = 0;
223
+ for (const target of targets.value) {
224
+ try {
225
+ const result = await editOneFile(target.file_path, target.edits, atomic);
226
+ const content = typeof result === "string" ? result : result.content;
227
+ if (typeof result !== "string" && result.details.diff)
228
+ diffs.push(result.details.diff);
229
+ if (typeof result === "string" && result.startsWith("Error:"))
230
+ failed++;
231
+ parts.push(`### ${target.file_path}\n${content}`);
232
+ }
233
+ catch (error) {
234
+ failed++;
235
+ parts.push(`### ${target.file_path}\nFAILED — ${error instanceof Error ? error.message : String(error)}`);
232
236
  }
233
- if (!e.span || !e.lines || e.old_text !== undefined || e.new_text !== undefined) {
237
+ }
238
+ const total = targets.value.length;
239
+ if (failed === total)
240
+ throw new Error(`All ${total} files failed; no changes written.\n\n${parts.join("\n\n")}`);
241
+ const header = failed === 0
242
+ ? `Edited ${total} files.`
243
+ : `Edited ${total - failed} of ${total} files. ${failed} failed — re-issue ONLY the failed files (the rest are already done).`;
244
+ return { content: `${header}\n\n${parts.join("\n\n")}`, details: { diff: diffs.join("\n") } };
245
+ },
246
+ };
247
+ async function editOneFile(file_path, edits, atomic) {
248
+ const resolved = resolvePath(cwd, file_path);
249
+ await rejectSymlink(resolved);
250
+ // Workspace write guard: outside cwd/tmp/~/.ezcoder requires user approval.
251
+ const guard = resolveWriteGuard(cwd, resolved, getWriteGuardSettings?.());
252
+ if (!guard.allowed) {
253
+ return `Error: ${guard.reason}`;
254
+ }
255
+ // No prior read is required: every old_text, span and anchor is matched
256
+ // against the live content below, which proves the model knows the text
257
+ // it replaces (it may have seen it via `cat`, or changed the file itself
258
+ // with a script). A read that no longer matches disk is forgotten, so the
259
+ // file counts as unread again: the edit still validates against live
260
+ // content, but a later full-file `write` needs a fresh read.
261
+ if (readFiles?.has(resolved) && !(await isFresh(readFiles, resolved, ops))) {
262
+ readFiles.delete(resolved);
263
+ }
264
+ const original = await ops.readFile(resolved);
265
+ const hasCRLF = original.includes("\r\n");
266
+ const originalNormalized = hasCRLF ? original.replace(/\r\n/g, "\n") : original;
267
+ // Anchors pin lines in the file AS READ, so they always verify against the
268
+ // original (pre-edit) line array — earlier edits in the batch don't shift
269
+ // what an anchor refers to.
270
+ const originalLines = originalNormalized.split("\n");
271
+ const fileName = path.basename(resolved);
272
+ const outcomes = new Array(edits.length);
273
+ // ── Phase 1: span-form edits (hash-anchored replacement). Spans resolve
274
+ // against the file AS READ and apply bottom-up so indices stay valid.
275
+ // Text-form edits then run on the result in phase 2.
276
+ const isSpanForm = (e) => e.span !== undefined || e.lines !== undefined;
277
+ const spanResolved = [];
278
+ for (let i = 0; i < edits.length; i++) {
279
+ const e = edits[i];
280
+ if (!isSpanForm(e)) {
281
+ if (e.old_text === undefined || e.new_text === undefined) {
234
282
  outcomes[i] = {
235
283
  ok: false,
236
284
  failure: {
237
285
  reason: "invalid",
238
- detail: "span form requires BOTH span and lines, and must not mix with old_text/new_text",
286
+ detail: "provide either old_text+new_text, or span+lines — this edit has neither",
239
287
  },
240
288
  };
241
- continue;
242
289
  }
243
- const res = resolveAnchoredEdit(originalLines, e.span);
290
+ continue;
291
+ }
292
+ if (!e.span || !e.lines || e.old_text !== undefined || e.new_text !== undefined) {
293
+ outcomes[i] = {
294
+ ok: false,
295
+ failure: {
296
+ reason: "invalid",
297
+ detail: "span form requires BOTH span and lines, and must not mix with old_text/new_text",
298
+ },
299
+ };
300
+ continue;
301
+ }
302
+ const res = resolveAnchoredEdit(originalLines, e.span);
303
+ if (!res.ok) {
304
+ outcomes[i] = { ok: false, failure: { reason: "stale_anchor" } };
305
+ continue;
306
+ }
307
+ spanResolved.push({ index: i, start: res.startIndex, end: res.endIndex, lines: e.lines });
308
+ }
309
+ // Reject overlapping spans (keep the first, fail the rest) — overlap means
310
+ // the model double-addressed the same region and the result is undefined.
311
+ spanResolved.sort((a, b) => a.start - b.start || a.index - b.index);
312
+ const spanApplied = [];
313
+ let lastEnd = -1;
314
+ for (const s of spanResolved) {
315
+ if (s.start <= lastEnd) {
316
+ outcomes[s.index] = { ok: false, failure: { reason: "overlap" } };
317
+ continue;
318
+ }
319
+ spanApplied.push(s);
320
+ lastEnd = s.end;
321
+ }
322
+ const workingLines = [...originalLines];
323
+ for (let i = spanApplied.length - 1; i >= 0; i--) {
324
+ const s = spanApplied[i];
325
+ workingLines.splice(s.start, s.end - s.start + 1, ...s.lines);
326
+ outcomes[s.index] = { ok: true, source: "span" };
327
+ }
328
+ let working = workingLines.join("\n");
329
+ // ── Phase 2: text-form edits, sequential on the working buffer.
330
+ for (let i = 0; i < edits.length; i++) {
331
+ if (outcomes[i] !== undefined)
332
+ continue; // span-form or invalid, already settled
333
+ const { old_text, new_text, replace_all, anchor } = edits[i];
334
+ if (old_text === undefined || new_text === undefined)
335
+ continue; // settled above
336
+ // Optional staleness guard (opt-in). Runs BEFORE the fuzzy match ladder:
337
+ // if the model supplied an anchor and the file drifted since it read it,
338
+ // reject this edit instead of risking a misplaced fuzzy match. The fuzzy
339
+ // path below is byte-identical to today when `anchor` is absent.
340
+ if (anchor) {
341
+ const res = resolveAnchoredEdit(originalLines, anchor);
244
342
  if (!res.ok) {
245
343
  outcomes[i] = { ok: false, failure: { reason: "stale_anchor" } };
246
344
  continue;
247
345
  }
248
- spanResolved.push({ index: i, start: res.startIndex, end: res.endIndex, lines: e.lines });
249
346
  }
250
- // Reject overlapping spans (keep the first, fail the rest) — overlap means
251
- // the model double-addressed the same region and the result is undefined.
252
- spanResolved.sort((a, b) => a.start - b.start || a.index - b.index);
253
- const spanApplied = [];
254
- let lastEnd = -1;
255
- for (const s of spanResolved) {
256
- if (s.start <= lastEnd) {
257
- outcomes[s.index] = { ok: false, failure: { reason: "overlap" } };
258
- continue;
259
- }
260
- spanApplied.push(s);
261
- lastEnd = s.end;
347
+ const normalizedOld = hasCRLF ? old_text.replace(/\r\n/g, "\n") : old_text;
348
+ const normalizedNew = hasCRLF ? new_text.replace(/\r\n/g, "\n") : new_text;
349
+ const replaceAll = replace_all ?? false;
350
+ // Identical replacements are explicit no-op successes. They should not
351
+ // block atomic batches that contain real edits, and all-no-op batches
352
+ // should report success without writing.
353
+ if (normalizedOld === normalizedNew) {
354
+ outcomes[i] = { ok: true };
355
+ continue;
262
356
  }
263
- const workingLines = [...originalLines];
264
- for (let i = spanApplied.length - 1; i >= 0; i--) {
265
- const s = spanApplied[i];
266
- workingLines.splice(s.start, s.end - s.start + 1, ...s.lines);
267
- outcomes[s.index] = { ok: true, source: "span" };
357
+ if (normalizedOld.length === 0 ||
358
+ (!working.includes(normalizedOld) && normalizeForFuzzyMatch(normalizedOld).length === 0)) {
359
+ outcomes[i] = {
360
+ ok: false,
361
+ failure: {
362
+ reason: "invalid",
363
+ detail: "old_text is empty after normalization; provide visible surrounding context.",
364
+ },
365
+ };
366
+ continue;
268
367
  }
269
- let working = workingLines.join("\n");
270
- // ── Phase 2: text-form edits, sequential on the working buffer.
271
- for (let i = 0; i < edits.length; i++) {
272
- if (outcomes[i] !== undefined)
273
- continue; // span-form or invalid, already settled
274
- const { old_text, new_text, replace_all, anchor } = edits[i];
275
- if (old_text === undefined || new_text === undefined)
276
- continue; // settled above
277
- // Optional staleness guard (opt-in). Runs BEFORE the fuzzy match ladder:
278
- // if the model supplied an anchor and the file drifted since it read it,
279
- // reject this edit instead of risking a misplaced fuzzy match. The fuzzy
280
- // path below is byte-identical to today when `anchor` is absent.
281
- if (anchor) {
282
- const res = resolveAnchoredEdit(originalLines, anchor);
283
- if (!res.ok) {
284
- outcomes[i] = { ok: false, failure: { reason: "stale_anchor" } };
285
- continue;
286
- }
368
+ // Aider's full fallback ladder, run only when the primary match
369
+ // returns "not_found". Ambiguous matches deliberately don't fall
370
+ // through — the model needs to add context, not paraphrase further.
371
+ // Order mirrors aider/coders/editblock_coder.py:
372
+ // 1. exact + smart-quote/dash fuzzy (in tryMatch)
373
+ // 2. indent-flex (model omitted/shortened leading whitespace)
374
+ // 3. drop spurious leading/trailing blank lines, retry 1+2
375
+ // 4. dotdotdots (`...` elision with preserved middle)
376
+ let source = "text";
377
+ let result = tryMatch(working, normalizedOld, normalizedNew, replaceAll);
378
+ const tryFallbacks = (oldText) => {
379
+ const flexed = applyMissingLeadingWhitespace(working, oldText, normalizedNew);
380
+ if (flexed !== null)
381
+ return flexed;
382
+ // Re-run primary matcher on the stripped variant as a cheap retry.
383
+ const exact = tryMatch(working, oldText, normalizedNew, replaceAll);
384
+ if (exact.ok)
385
+ return exact.newWorking;
386
+ return null;
387
+ };
388
+ if (!result.ok && result.reason === "not_found") {
389
+ const indentFlexed = applyMissingLeadingWhitespace(working, normalizedOld, normalizedNew);
390
+ if (indentFlexed !== null) {
391
+ result = { ok: true, newWorking: indentFlexed };
392
+ source = "indent_flex";
287
393
  }
288
- const normalizedOld = hasCRLF ? old_text.replace(/\r\n/g, "\n") : old_text;
289
- const normalizedNew = hasCRLF ? new_text.replace(/\r\n/g, "\n") : new_text;
290
- const replaceAll = replace_all ?? false;
291
- // Identical replacements are explicit no-op successes. They should not
292
- // block atomic batches that contain real edits, and all-no-op batches
293
- // should report success without writing.
294
- if (normalizedOld === normalizedNew) {
295
- outcomes[i] = { ok: true };
296
- continue;
394
+ }
395
+ if (!result.ok && result.reason === "not_found") {
396
+ const stripped = stripBlankEdges(normalizedOld);
397
+ if (stripped !== null) {
398
+ const candidate = tryFallbacks(stripped);
399
+ if (candidate !== null) {
400
+ result = { ok: true, newWorking: candidate };
401
+ source = "blank_edges";
402
+ }
297
403
  }
298
- if (normalizedOld.length === 0 ||
299
- (!working.includes(normalizedOld) && normalizeForFuzzyMatch(normalizedOld).length === 0)) {
404
+ }
405
+ if (!result.ok && result.reason === "not_found") {
406
+ if (replaceAll && /^[ \t]*\.\.\.[ \t]*$/m.test(normalizedOld)) {
300
407
  outcomes[i] = {
301
408
  ok: false,
302
409
  failure: {
303
410
  reason: "invalid",
304
- detail: "old_text is empty after normalization; provide visible surrounding context.",
411
+ detail: "replace_all does not support ... elision; use complete matching text or separate uniquely anchored edits.",
305
412
  },
306
413
  };
307
414
  continue;
308
415
  }
309
- // Aider's full fallback ladder, run only when the primary match
310
- // returns "not_found". Ambiguous matches deliberately don't fall
311
- // through — the model needs to add context, not paraphrase further.
312
- // Order mirrors aider/coders/editblock_coder.py:
313
- // 1. exact + smart-quote/dash fuzzy (in tryMatch)
314
- // 2. indent-flex (model omitted/shortened leading whitespace)
315
- // 3. drop spurious leading/trailing blank lines, retry 1+2
316
- // 4. dotdotdots (`...` elision with preserved middle)
317
- let source = "text";
318
- let result = tryMatch(working, normalizedOld, normalizedNew, replaceAll);
319
- const tryFallbacks = (oldText) => {
320
- const flexed = applyMissingLeadingWhitespace(working, oldText, normalizedNew);
321
- if (flexed !== null)
322
- return flexed;
323
- // Re-run primary matcher on the stripped variant as a cheap retry.
324
- const exact = tryMatch(working, oldText, normalizedNew, replaceAll);
325
- if (exact.ok)
326
- return exact.newWorking;
327
- return null;
328
- };
329
- if (!result.ok && result.reason === "not_found") {
330
- const indentFlexed = applyMissingLeadingWhitespace(working, normalizedOld, normalizedNew);
331
- if (indentFlexed !== null) {
332
- result = { ok: true, newWorking: indentFlexed };
333
- source = "indent_flex";
334
- }
335
- }
336
- if (!result.ok && result.reason === "not_found") {
337
- const stripped = stripBlankEdges(normalizedOld);
338
- if (stripped !== null) {
339
- const candidate = tryFallbacks(stripped);
340
- if (candidate !== null) {
341
- result = { ok: true, newWorking: candidate };
342
- source = "blank_edges";
343
- }
344
- }
345
- }
346
- if (!result.ok && result.reason === "not_found") {
347
- if (replaceAll && /^[ \t]*\.\.\.[ \t]*$/m.test(normalizedOld)) {
348
- outcomes[i] = {
349
- ok: false,
350
- failure: {
351
- reason: "invalid",
352
- detail: "replace_all does not support ... elision; use complete matching text or separate uniquely anchored edits.",
353
- },
354
- };
355
- continue;
356
- }
357
- const elided = applyDotdotdots(working, normalizedOld, normalizedNew);
358
- if (elided !== null) {
359
- result = { ok: true, newWorking: elided };
360
- source = "dotdotdot";
361
- }
362
- }
363
- if (result.ok) {
364
- working = result.newWorking;
365
- outcomes[i] = { ok: true, source };
366
- continue;
367
- }
368
- if (result.reason === "not_found") {
369
- // Capture the closest-match snippet eagerly against the current
370
- // working buffer; we'll decide whether to render it post-loop based
371
- // on whether other edits in this batch succeeded.
372
- const closest = findClosestSnippet(working, normalizedOld);
373
- outcomes[i] = {
374
- ok: false,
375
- failure: {
376
- reason: "not_found",
377
- closestSnippet: closest?.snippet ?? null,
378
- closestLine: closest?.topLine ?? null,
379
- redactedHint: redactedOldTextHint(normalizedOld),
380
- },
381
- };
382
- }
383
- else {
384
- const occurrences = result.occurrences ?? 0;
385
- const matches = findOccurrenceLines(working, normalizedOld);
386
- const matchLines = matches.map((m) => ` line ${m.line}: ${m.preview}`).join("\n");
387
- const more = occurrences > matches.length ? `\n …and ${occurrences - matches.length} more` : "";
388
- outcomes[i] = {
389
- ok: false,
390
- failure: { reason: "ambiguous", occurrences, matchLines, more },
391
- };
416
+ const elided = applyDotdotdots(working, normalizedOld, normalizedNew);
417
+ if (elided !== null) {
418
+ result = { ok: true, newWorking: elided };
419
+ source = "dotdotdot";
392
420
  }
393
421
  }
394
- const failures = outcomes
395
- .map((o, i) => (o.ok || !o.failure ? null : { index: i, failure: o.failure }))
396
- .filter((x) => x !== null);
397
- const successCount = outcomes.length - failures.length;
398
- // Closest-match snippets only get suppressed when successes will ACTUALLY
399
- // be persisted (partial-apply with at least one win). In atomic mode we
400
- // throw before writing, so the model retries against an unchanged file
401
- // and the snippet is its only guidance — keep it.
402
- const willPersistSuccesses = successCount > 0 && !atomic;
403
- const formatFailureMessage = (f) => {
404
- if (f.reason === "stale_anchor") {
405
- return `the file changed since you read it (anchor mismatch) — re-read \`${file_path}\` and retry`;
406
- }
407
- if (f.reason === "noop") {
408
- return `old_text and new_text are identical in ${fileName} — this edit would be a no-op. Either fix new_text or drop this edit.`;
409
- }
410
- if (f.reason === "invalid") {
411
- return `invalid edit: ${f.detail}.`;
412
- }
413
- if (f.reason === "overlap") {
414
- return (`span overlaps another span edit in this batch — the overlapping region was addressed twice. ` +
415
- `Merge the overlapping spans into one edit and retry.`);
416
- }
417
- if (f.reason === "ambiguous") {
418
- return (`old_text found ${f.occurrences} times in ${fileName}. ` +
419
- "Include more surrounding context to make the match unique, " +
420
- "or set replace_all: true to swap every occurrence.\n" +
421
- "Matches at:\n" +
422
- f.matchLines +
423
- f.more);
424
- }
425
- const base = `old_text not found in ${fileName}. ` +
426
- "Text must match verbatim — do not paraphrase. " +
427
- "Fix this edit's old_text to match the file exactly (re-read the region below if unsure); " +
428
- "the file is unchanged, so successful edits and prior reads are still valid." +
429
- f.redactedHint;
430
- // Build a bounded read suggestion around the closest-match line so the
431
- // model can re-read just that region (e.g. ±25 lines) instead of the
432
- // whole file. Skipped when willPersistSuccesses — see comment above.
433
- const readHint = f.closestLine !== null && !willPersistSuccesses
434
- ? `\nSuggested re-read: \`read file_path="${file_path}" offset=${Math.max(1, f.closestLine - 25)} limit=50\``
422
+ if (result.ok) {
423
+ working = result.newWorking;
424
+ outcomes[i] = { ok: true, source };
425
+ continue;
426
+ }
427
+ if (result.reason === "not_found") {
428
+ // Capture the closest-match snippet eagerly against the current
429
+ // working buffer; we'll decide whether to render it post-loop based
430
+ // on whether other edits in this batch succeeded.
431
+ const closest = findClosestSnippet(working, normalizedOld);
432
+ outcomes[i] = {
433
+ ok: false,
434
+ failure: {
435
+ reason: "not_found",
436
+ closestSnippet: closest?.snippet ?? null,
437
+ closestLine: closest?.topLine ?? null,
438
+ redactedHint: redactedOldTextHint(normalizedOld),
439
+ },
440
+ };
441
+ }
442
+ else {
443
+ const occurrences = result.occurrences ?? 0;
444
+ const matches = findOccurrenceLines(working, normalizedOld);
445
+ const matchLines = matches.map((m) => ` line ${m.line}: ${m.preview}`).join("\n");
446
+ const more = occurrences > matches.length ? `\n …and ${occurrences - matches.length} more` : "";
447
+ outcomes[i] = {
448
+ ok: false,
449
+ failure: { reason: "ambiguous", occurrences, matchLines, more },
450
+ };
451
+ }
452
+ }
453
+ const failures = outcomes
454
+ .map((o, i) => (o.ok || !o.failure ? null : { index: i, failure: o.failure }))
455
+ .filter((x) => x !== null);
456
+ const successCount = outcomes.length - failures.length;
457
+ // Closest-match snippets only get suppressed when successes will ACTUALLY
458
+ // be persisted (partial-apply with at least one win). In atomic mode we
459
+ // throw before writing, so the model retries against an unchanged file
460
+ // and the snippet is its only guidance — keep it.
461
+ const willPersistSuccesses = successCount > 0 && !atomic;
462
+ const formatFailureMessage = (f) => {
463
+ if (f.reason === "stale_anchor") {
464
+ return `the file changed since you read it (anchor mismatch) — re-read \`${file_path}\` and retry`;
465
+ }
466
+ if (f.reason === "noop") {
467
+ return `old_text and new_text are identical in ${fileName} — this edit would be a no-op. Either fix new_text or drop this edit.`;
468
+ }
469
+ if (f.reason === "invalid") {
470
+ return `invalid edit: ${f.detail}.`;
471
+ }
472
+ if (f.reason === "overlap") {
473
+ return (`span overlaps another span edit in this batch — the overlapping region was addressed twice. ` +
474
+ `Merge the overlapping spans into one edit and retry.`);
475
+ }
476
+ if (f.reason === "ambiguous") {
477
+ return (`old_text found ${f.occurrences} times in ${fileName}. ` +
478
+ "Include more surrounding context to make the match unique, " +
479
+ "or set replace_all: true to swap every occurrence.\n" +
480
+ "Matches at:\n" +
481
+ f.matchLines +
482
+ f.more);
483
+ }
484
+ const base = `old_text not found in ${fileName}. ` +
485
+ "Text must match verbatim — do not paraphrase. " +
486
+ "Fix this edit's old_text to match the file exactly (re-read the region below if unsure); " +
487
+ "the file is unchanged, so successful edits and prior reads are still valid." +
488
+ f.redactedHint;
489
+ // Build a bounded read suggestion around the closest-match line so the
490
+ // model can re-read just that region (e.g. ±25 lines) instead of the
491
+ // whole file. Skipped when willPersistSuccesses — see comment above.
492
+ const readHint = f.closestLine !== null && !willPersistSuccesses
493
+ ? `\nSuggested re-read: \`read file_path="${file_path}" offset=${Math.max(1, f.closestLine - 25)} limit=50\``
494
+ : "";
495
+ if (willPersistSuccesses || !f.closestSnippet)
496
+ return base + readHint;
497
+ return `${base}${readHint}\nClosest match in file:\n${f.closestSnippet}`;
498
+ };
499
+ const formatFailures = () => {
500
+ if (failures.length === 1 && edits.length === 1) {
501
+ return formatFailureMessage(failures[0].failure);
502
+ }
503
+ return failures
504
+ .map((f) => `[edit ${f.index + 1}/${edits.length}] ${formatFailureMessage(f.failure)}`)
505
+ .join("\n\n");
506
+ };
507
+ // Atomic-mode failure, OR partial-mode failure where literally nothing
508
+ // succeeded. Either way nothing should be written and we throw to make
509
+ // the model retry the whole batch.
510
+ if (failures.length > 0 && (atomic || successCount === 0)) {
511
+ // Nothing was written — the file is byte-identical to the last read, so
512
+ // the read tracker stays valid. We deliberately do NOT invalidate it
513
+ // here: doing so turned a precise "old_text not found (closest match
514
+ // below)" error into a misleading "File must be read first" on every
515
+ // following edit, hiding the real fix from the model. A genuine on-disk
516
+ // change (formatter/external edit) is caught by the freshness check.
517
+ const header = atomic && failures.length > 0
518
+ ? `${failures.length} of ${edits.length} edit${edits.length === 1 ? "" : "s"} failed; no changes written (atomic).\n\n`
519
+ : edits.length > 1
520
+ ? `${failures.length} of ${edits.length} edits failed; no changes written.\n\n`
435
521
  : "";
436
- if (willPersistSuccesses || !f.closestSnippet)
437
- return base + readHint;
438
- return `${base}${readHint}\nClosest match in file:\n${f.closestSnippet}`;
439
- };
440
- const formatFailures = () => {
441
- if (failures.length === 1 && edits.length === 1) {
442
- return formatFailureMessage(failures[0].failure);
522
+ throw new Error(header + formatFailures());
523
+ }
524
+ // Never replace real secrets with the placeholder the model was shown.
525
+ const lossError = redactionLossError(originalNormalized, working, fileName);
526
+ if (lossError)
527
+ throw new Error(lossError);
528
+ const relPath = path.relative(cwd, resolved);
529
+ const diff = generateDiff(originalNormalized, working, relPath);
530
+ const changed = working !== originalNormalized;
531
+ // LSP diagnostics for the just-written content. Best-effort enhancement:
532
+ // any failure (or an opted-out provider) leaves output identical to today.
533
+ let diagnosticsNote = "";
534
+ if (changed) {
535
+ const finalContent = hasCRLF ? working.replace(/\n/g, "\r\n") : working;
536
+ // Snapshot the pre-mutation on-disk state for /rewind before writing.
537
+ await onPreFileMutation?.(resolved);
538
+ await ops.writeFile(resolved, finalContent);
539
+ await recordWrite(readFiles, resolved, finalContent, ops, "edit");
540
+ await mutationCallback?.(resolved);
541
+ // recordWrite refreshed the tracker to the just-written content, so the
542
+ // next edit (including retries of the skipped ones) validates against an
543
+ // accurate snapshot. No invalidation — the failure message already
544
+ // carries the closest match and a bounded re-read hint.
545
+ if (getDiagnostics) {
546
+ try {
547
+ diagnosticsNote = await getDiagnostics(resolved, finalContent, riskiestSource(outcomes));
443
548
  }
444
- return failures
445
- .map((f) => `[edit ${f.index + 1}/${edits.length}] ${formatFailureMessage(f.failure)}`)
446
- .join("\n\n");
447
- };
448
- // Atomic-mode failure, OR partial-mode failure where literally nothing
449
- // succeeded. Either way nothing should be written and we throw to make
450
- // the model retry the whole batch.
451
- if (failures.length > 0 && (atomic || successCount === 0)) {
452
- // Nothing was written — the file is byte-identical to the last read, so
453
- // the read tracker stays valid. We deliberately do NOT invalidate it
454
- // here: doing so turned a precise "old_text not found (closest match
455
- // below)" error into a misleading "File must be read first" on every
456
- // following edit, hiding the real fix from the model. A genuine on-disk
457
- // change (formatter/external edit) is caught by the freshness check.
458
- const header = atomic && failures.length > 0
459
- ? `${failures.length} of ${edits.length} edit${edits.length === 1 ? "" : "s"} failed; no changes written (atomic).\n\n`
460
- : edits.length > 1
461
- ? `${failures.length} of ${edits.length} edits failed; no changes written.\n\n`
462
- : "";
463
- throw new Error(header + formatFailures());
464
- }
465
- // Never replace real secrets with the placeholder the model was shown.
466
- const lossError = redactionLossError(originalNormalized, working, fileName);
467
- if (lossError)
468
- throw new Error(lossError);
469
- const relPath = path.relative(cwd, resolved);
470
- const diff = generateDiff(originalNormalized, working, relPath);
471
- const changed = working !== originalNormalized;
472
- // LSP diagnostics for the just-written content. Best-effort enhancement:
473
- // any failure (or an opted-out provider) leaves output identical to today.
474
- let diagnosticsNote = "";
475
- if (changed) {
476
- const finalContent = hasCRLF ? working.replace(/\n/g, "\r\n") : working;
477
- // Snapshot the pre-mutation on-disk state for /rewind before writing.
478
- await onPreFileMutation?.(resolved);
479
- await ops.writeFile(resolved, finalContent);
480
- await recordWrite(readFiles, resolved, finalContent, ops, "edit");
481
- await mutationCallback?.(resolved);
482
- // recordWrite refreshed the tracker to the just-written content, so the
483
- // next edit (including retries of the skipped ones) validates against an
484
- // accurate snapshot. No invalidation — the failure message already
485
- // carries the closest match and a bounded re-read hint.
486
- if (getDiagnostics) {
487
- try {
488
- diagnosticsNote = await getDiagnostics(resolved, finalContent, riskiestSource(outcomes));
489
- }
490
- catch {
491
- // Diagnostics must never break a successful edit.
492
- }
549
+ catch {
550
+ // Diagnostics must never break a successful edit.
493
551
  }
494
552
  }
495
- if (failures.length === 0) {
496
- if (!changed) {
497
- const summary = edits.length > 1
498
- ? `No changes needed in ${relPath}; ${edits.length} edits were no-ops.`
499
- : `No changes needed in ${relPath}; edit was a no-op.`;
500
- return { content: summary, details: { diff } };
501
- }
553
+ }
554
+ if (failures.length === 0) {
555
+ if (!changed) {
502
556
  const summary = edits.length > 1
503
- ? `Successfully applied ${edits.length} edits to ${relPath}.`
504
- : `Successfully replaced text in ${relPath}.`;
505
- return { content: summary + diagnosticsNote, details: { diff } };
557
+ ? `No changes needed in ${relPath}; ${edits.length} edits were no-ops.`
558
+ : `No changes needed in ${relPath}; edit was a no-op.`;
559
+ return { content: summary, details: { diff } };
506
560
  }
507
- // Partial success — the loud header is deliberate: the model has to know
508
- // that work was saved AND that only the listed edits need to be retried.
509
- const noun = failures.length === 1 ? "edit" : "edits";
510
- const content = `Applied ${successCount} of ${edits.length} edits to ${relPath}.\n` +
511
- `${failures.length} ${noun} skipped — re-issue ONLY these (the rest are already done, do not redo them):\n\n` +
512
- formatFailures() +
513
- diagnosticsNote;
514
- return { content, details: { diff } };
515
- },
516
- };
561
+ const summary = edits.length > 1
562
+ ? `Successfully applied ${edits.length} edits to ${relPath}.`
563
+ : `Successfully replaced text in ${relPath}.`;
564
+ return { content: summary + diagnosticsNote, details: { diff } };
565
+ }
566
+ // Partial success — the loud header is deliberate: the model has to know
567
+ // that work was saved AND that only the listed edits need to be retried.
568
+ const noun = failures.length === 1 ? "edit" : "edits";
569
+ const content = `Applied ${successCount} of ${edits.length} edits to ${relPath}.\n` +
570
+ `${failures.length} ${noun} skipped — re-issue ONLY these (the rest are already done, do not redo them):\n\n` +
571
+ formatFailures() +
572
+ diagnosticsNote;
573
+ return { content, details: { diff } };
574
+ }
575
+ }
576
+ /** Exactly one form: `file_path`+`edits`, or `files`. */
577
+ function resolveEditTargets(file_path, edits, files) {
578
+ const single = file_path !== undefined || edits !== undefined;
579
+ if (files !== undefined && single) {
580
+ return { ok: false, error: "Use either file_path+edits or files, not both." };
581
+ }
582
+ if (files !== undefined)
583
+ return { ok: true, value: files };
584
+ if (file_path === undefined || edits === undefined) {
585
+ return {
586
+ ok: false,
587
+ error: "Provide file_path and edits (one file), or files (several files).",
588
+ };
589
+ }
590
+ return { ok: true, value: [{ file_path, edits }] };
517
591
  }
518
592
  //# sourceMappingURL=edit.js.map