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