pi-hashline-edit-pro 6.3.1 → 6.4.1

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 (45) hide show
  1. package/README.md +33 -32
  2. package/index.ts +32 -16
  3. package/package.json +1 -1
  4. package/src/batch.ts +30 -9
  5. package/src/commit.ts +17 -3
  6. package/src/config-ui.ts +1 -1
  7. package/src/constants.ts +3 -0
  8. package/src/copy-move.ts +100 -68
  9. package/src/edit-common.ts +86 -54
  10. package/src/edit-fidelity.ts +16 -6
  11. package/src/fs-write.ts +14 -2
  12. package/src/grep.ts +22 -11
  13. package/src/insert.ts +5 -4
  14. package/src/line-endings.ts +6 -2
  15. package/src/model-gate.ts +1 -1
  16. package/src/payload-contract.ts +13 -9
  17. package/src/pre-edit-snapshot.ts +19 -0
  18. package/src/prompts.ts +32 -0
  19. package/src/read.ts +5 -10
  20. package/src/replace-diff.ts +5 -3
  21. package/src/replace-match.ts +2 -2
  22. package/src/replace-render.ts +4 -4
  23. package/src/replace-response.ts +1 -0
  24. package/src/replace-undo.ts +4 -4
  25. package/src/replace.ts +2 -2
  26. package/src/structured.ts +2 -1
  27. package/src/utils.ts +29 -0
  28. package/src/write-hook.ts +1 -1
  29. package/tool-prompts/copy-snippet.md +1 -1
  30. package/tool-prompts/copy.md +2 -2
  31. package/tool-prompts/grep-guidelines.md +2 -1
  32. package/tool-prompts/grep-snippet.md +1 -1
  33. package/tool-prompts/grep.md +1 -1
  34. package/tool-prompts/insert.md +1 -1
  35. package/tool-prompts/move-snippet.md +1 -1
  36. package/tool-prompts/move.md +2 -2
  37. package/tool-prompts/read-guidelines.md +2 -2
  38. package/tool-prompts/read.md +1 -3
  39. package/tool-prompts/replace-match-guidelines.md +1 -0
  40. package/tool-prompts/replace-match-snippet.md +1 -1
  41. package/tool-prompts/replace-match.md +1 -1
  42. package/tool-prompts/replace.md +3 -2
  43. package/tool-prompts/transfer-guidelines.md +3 -3
  44. package/tool-prompts/undo-last-change-snippet.md +1 -1
  45. package/tool-prompts/undo-last-change.md +1 -1
package/src/copy-move.ts CHANGED
@@ -5,6 +5,7 @@ import { constants } from "node:fs";
5
5
  import { execPipeline, noteAnchorError, previewFromPipe, previewError, type PipelineResult, type ReplaceDetails } from "./replace";
6
6
  import { commitEdit } from "./commit";
7
7
  import { readNormFile, safeSnapId, type NormFile } from "./file-reader";
8
+ import { preEditSnapshotFor } from "./pre-edit-snapshot";
8
9
  import {
9
10
  lineHashes,
10
11
  MAX_HASH_LINES,
@@ -20,11 +21,11 @@ import {
20
21
  } from "./hashline";
21
22
  import { formatAnchorReclaimNotice, servedForPath, takeReclaimedPaths, withAnchorSession } from "./anchor-registry";
22
23
  import { batchMemberFor, batchServedFor, ensureBatchBase, executeBatchMember, noteBatchFailure, pendingBatchMemberFor, registerBatchPlanner, type BatchBase, type BatchMemberPlanner, type PlannedMember } from "./batch";
23
- import { loadP, loadGuide } from "./prompts";
24
+ import { loadP, loadGuidelines } from "./prompts";
24
25
  import { assertTransferReq, normReq, type TransferReq } from "./payload-contract";
25
26
  import { abortIf, assertByteLimit, isRec, splitLines } from "./utils";
26
27
  import { toDisplayPath } from "./paths";
27
- import { getDiffContextLines, readConfig } from "./config";
28
+ import { getDiffContextLines } from "./config";
28
29
  import { resolveInCwd, writeAtomic } from "./fs-write";
29
30
  import { restoreEndings, type LineEnding } from "./normalize";
30
31
  import { applyEndingOverrides, endingsForRange, joinSeparators, separatorsForSpans } from "./line-endings";
@@ -33,6 +34,7 @@ import { buildChanged, buildMetrics, type TResult } from "./replace-response";
33
34
  import { servedHashesFromDiff, serveRows } from "./served";
34
35
  import {
35
36
  assertBoundaryLinesServed,
37
+ assertPathOption,
36
38
  DEFAULT_EDIT_FLAGS,
37
39
  editRenderResultWrapper,
38
40
  editToolBase,
@@ -52,7 +54,8 @@ export type TransferKind = "copy" | "move";
52
54
  interface TransferRefs {
53
55
  sourceFrom: Anchor;
54
56
  sourceTo: Anchor;
55
- insertAfter: Anchor;
57
+ anchor: Anchor;
58
+ direction: "before" | "after";
56
59
  }
57
60
 
58
61
  export interface TransferPlan {
@@ -69,6 +72,7 @@ interface PairFileCommit {
69
72
  absolutePath: string;
70
73
  mutationTargetPath: string;
71
74
  foldedAnchorLines: number;
75
+ anchorCarry?: number;
72
76
  endingOverrides?: (LineEnding | undefined)[];
73
77
  }
74
78
 
@@ -87,12 +91,13 @@ export function parseTransferAnchors(req: TransferReq): { refs: TransferRefs; wa
87
91
  const warnings: string[] = [];
88
92
  const from = stripAnchorRow(req.source_from.trim(), "source_from entry", warnings);
89
93
  const to = stripAnchorRow(req.source_to.trim(), "source_to entry", warnings);
90
- const after = stripAnchorRow(req.insert_after.trim(), "insert_after entry", warnings);
94
+ const destination = stripAnchorRow(req.anchor.trim(), "anchor entry", warnings);
91
95
  return {
92
96
  refs: {
93
97
  sourceFrom: parseHashRef(from),
94
98
  sourceTo: parseHashRef(to),
95
- insertAfter: parseHashRef(after),
99
+ anchor: parseHashRef(destination),
100
+ direction: req.direction,
96
101
  },
97
102
  warnings,
98
103
  };
@@ -118,13 +123,14 @@ export function buildTransferEdit(input: {
118
123
  const toLine = resolveAnchorLine(refs.sourceTo, fileLines, preload.fileHashes, displayPath);
119
124
  const sourceStart = Math.min(fromLine, toLine);
120
125
  const sourceEnd = Math.max(fromLine, toLine);
121
- const insertLine = resolveAnchorLine(refs.insertAfter, fileLines, preload.fileHashes, displayPath);
126
+ const anchorLine = resolveAnchorLine(refs.anchor, fileLines, preload.fileHashes, displayPath);
127
+ const insertLine = refs.direction === "after" ? anchorLine : anchorLine - 1;
122
128
  if (insertLine >= sourceStart && insertLine < sourceEnd) {
123
129
  throw new Error(
124
- `[E_BAD_SHAPE] "insert_after" resolves to line ${insertLine}, inside the source range (lines ${sourceStart}-${sourceEnd}). Use a line before source_from, or source_to to place the block right after itself.`,
130
+ `[E_BAD_SHAPE] "anchor" resolves to line ${anchorLine}, which places the block inside the source range (lines ${sourceStart}-${sourceEnd}). Use a line outside the source range.`,
125
131
  );
126
132
  }
127
- assertBoundaryLinesServed(fileLines, preload.fileHashes, served, insertLine, insertLine, displayPath);
133
+ assertBoundaryLinesServed(fileLines, preload.fileHashes, served, anchorLine, anchorLine, displayPath);
128
134
  assertBoundaryLinesServed(fileLines, preload.fileHashes, served, sourceStart, sourceStart, displayPath);
129
135
  assertBoundaryLinesServed(fileLines, preload.fileHashes, served, sourceEnd, sourceEnd, displayPath);
130
136
  const sourceLines = fileLines.slice(sourceStart - 1, sourceEnd);
@@ -132,13 +138,17 @@ export function buildTransferEdit(input: {
132
138
  if (kind === "copy") {
133
139
  return {
134
140
  editParams: {
135
- remove_from: preload.fileHashes[insertLine - 1]!,
136
- remove_to: preload.fileHashes[insertLine - 1]!,
137
- text: [fileLines[insertLine - 1]!, ...sourceLines],
141
+ remove_from: preload.fileHashes[anchorLine - 1]!,
142
+ remove_to: preload.fileHashes[anchorLine - 1]!,
143
+ text: refs.direction === "after"
144
+ ? [fileLines[anchorLine - 1]!, ...sourceLines]
145
+ : [...sourceLines, fileLines[anchorLine - 1]!],
138
146
  },
139
147
  foldedAnchorLines: 1,
140
- anchorCarry: 0,
141
- endingOverrides: [undefined, ...sourceEndings],
148
+ anchorCarry: refs.direction === "after" ? 0 : sourceLines.length,
149
+ endingOverrides: refs.direction === "after"
150
+ ? [undefined, ...sourceEndings]
151
+ : [...sourceEndings, undefined],
142
152
  };
143
153
  }
144
154
  if (insertLine < sourceStart) {
@@ -169,40 +179,34 @@ export function buildTransferEdit(input: {
169
179
  };
170
180
  }
171
181
 
182
+ function describeTransferTargets(sourcePath: string, destinationPath: string): string {
183
+ return sourcePath === destinationPath
184
+ ? `the file "${sourcePath}"`
185
+ : `the source file "${sourcePath}" or the destination file "${destinationPath}"`;
186
+ }
187
+
172
188
  async function resolveTransferTargets(req: TransferReq, cwd: string): Promise<{ sourcePath: string; destinationPath: string }> {
173
- const { requirePath } = await readConfig();
174
- if (!requirePath && req.path !== undefined) {
175
- throw new Error("[E_BAD_SHAPE] Edit request contains unknown or unsupported fields: path. Path resolution is anchor-only; retry without `path`.");
176
- }
177
- if (requirePath && (typeof req.path !== "string" || req.path.length === 0)) {
178
- throw new Error('[E_BAD_SHAPE] Copy/move request requires a non-empty "path" string when require-path mode is on. Provide `path` matching the source or destination file the anchors were served for.');
179
- }
180
189
  const sourcePath = resolveEditTarget(req.source_from, req.source_to);
181
- const destinationPath = resolveEditTarget(req.insert_after);
182
- if (requirePath) {
183
- const { resolved } = await resolveInCwd(req.path as string, cwd);
184
- if (resolved !== sourcePath && resolved !== destinationPath) {
185
- throw new Error(`[E_BAD_SHAPE] Provided "path" "${req.path}" does not match the source file "${sourcePath}" or the destination file "${destinationPath}".`);
186
- }
187
- }
190
+ const destinationPath = resolveEditTarget(req.anchor);
191
+ await assertPathOption({
192
+ providedPath: req.path,
193
+ cwd,
194
+ targets: [sourcePath, destinationPath],
195
+ expected: describeTransferTargets(sourcePath, destinationPath),
196
+ requestLabel: "Copy/move request",
197
+ });
188
198
  return { sourcePath, destinationPath };
189
199
  }
190
200
 
191
201
  async function assertTransferPathOption(req: TransferReq, cwd: string, member: PlannedMember): Promise<void> {
192
- const { requirePath } = await readConfig();
193
- if (!requirePath && req.path !== undefined) {
194
- throw new Error("[E_BAD_SHAPE] Edit request contains unknown or unsupported fields: path. Path resolution is anchor-only; retry without `path`.");
195
- }
196
- if (requirePath && (typeof req.path !== "string" || req.path.length === 0)) {
197
- throw new Error('[E_BAD_SHAPE] Copy/move request requires a non-empty "path" string when require-path mode is on. Provide `path` matching the source or destination file the anchors were served for.');
198
- }
199
- if (requirePath) {
200
- const sourceTarget = member.sourceTarget ?? member.target;
201
- const { resolved } = await resolveInCwd(req.path as string, cwd);
202
- if (resolved !== sourceTarget && resolved !== member.target) {
203
- throw new Error(`[E_BAD_SHAPE] Provided "path" "${req.path}" does not match the source file "${sourceTarget}" or the destination file "${member.target}".`);
204
- }
205
- }
202
+ const sourceTarget = member.sourceTarget ?? member.target;
203
+ await assertPathOption({
204
+ providedPath: req.path,
205
+ cwd,
206
+ targets: [sourceTarget, member.target],
207
+ expected: describeTransferTargets(sourceTarget, member.target),
208
+ requestLabel: "Copy/move request",
209
+ });
206
210
  }
207
211
 
208
212
  interface CrossTransferPreparation {
@@ -212,6 +216,7 @@ interface CrossTransferPreparation {
212
216
  destinationDisplay: string;
213
217
  destinationEdit: HTEdit;
214
218
  destinationFolded: number;
219
+ anchorCarry?: number;
215
220
  endingOverrides: (LineEnding | undefined)[];
216
221
  sourceEdit?: HTEdit;
217
222
  }
@@ -232,11 +237,17 @@ async function prepareCrossTransfer(input: {
232
237
  const { kind, refs, sourcePath, destinationPath, cwd, signal, noPersist } = input;
233
238
  const common = { signal, maxLines: MAX_HASH_LINES };
234
239
  const sourceAccessMode = kind === "move" ? constants.R_OK | constants.W_OK : constants.R_OK;
235
- const sourcePreload = input.sourcePreload ?? (await readNormFile(sourcePath, cwd, noPersist ? { ...common, accessMode: constants.R_OK, noPersist: true, allocation: "shadow" } : { ...common, accessMode: sourceAccessMode }));
240
+ const candidate = kind === "copy" && input.sourcePreload === undefined ? preEditSnapshotFor(sourcePath) : undefined;
241
+ const snapshotPreload = candidate !== undefined
242
+ && candidate.fileHashes.includes(refs.sourceFrom.hash)
243
+ && candidate.fileHashes.includes(refs.sourceTo.hash)
244
+ ? candidate
245
+ : undefined;
246
+ const sourcePreload = input.sourcePreload ?? snapshotPreload ?? (await readNormFile(sourcePath, cwd, noPersist ? { ...common, accessMode: constants.R_OK, noPersist: true, allocation: "shadow" } : { ...common, accessMode: sourceAccessMode }));
236
247
  const destinationPreload = input.destinationPreload ?? (await readNormFile(destinationPath, cwd, noPersist ? { ...common, accessMode: constants.R_OK, noPersist: true, allocation: "shadow" } : { ...common, accessMode: constants.R_OK | constants.W_OK }));
237
248
  const sourceDisplay = toDisplayPath(cwd, sourcePreload.absolutePath, sourcePath);
238
249
  const destinationDisplay = toDisplayPath(cwd, destinationPreload.absolutePath, destinationPath);
239
- const sourceServed = input.sourceServed ?? servedForPath(sourcePreload.absolutePath);
250
+ const sourceServed = input.sourceServed ?? (snapshotPreload !== undefined ? snapshotPreload.served : servedForPath(sourcePreload.absolutePath));
240
251
  const destinationServed = input.destinationServed ?? servedForPath(destinationPreload.absolutePath);
241
252
  const sourceLines = splitLines(sourcePreload.normalized);
242
253
  const destinationLines = splitLines(destinationPreload.normalized);
@@ -268,15 +279,15 @@ async function prepareCrossTransfer(input: {
268
279
  throw error;
269
280
  }
270
281
 
271
- let insertLine = 0;
282
+ let anchorLine = 0;
272
283
  try {
273
- insertLine = resolveAnchorLine(refs.insertAfter, destinationLines, destinationPreload.fileHashes, destinationDisplay);
284
+ anchorLine = resolveAnchorLine(refs.anchor, destinationLines, destinationPreload.fileHashes, destinationDisplay);
274
285
  } catch (error) {
275
286
  await adopt(destinationPreload.absolutePath, error);
276
287
  throw error;
277
288
  }
278
289
  try {
279
- assertBoundaryLinesServed(destinationLines, destinationPreload.fileHashes, destinationServed, insertLine, insertLine, destinationDisplay);
290
+ assertBoundaryLinesServed(destinationLines, destinationPreload.fileHashes, destinationServed, anchorLine, anchorLine, destinationDisplay);
280
291
  } catch (error) {
281
292
  await adopt(destinationPreload.absolutePath, error);
282
293
  throw error;
@@ -289,23 +300,33 @@ async function prepareCrossTransfer(input: {
289
300
  remove_to: destinationPreload.fileHashes[0]!,
290
301
  text: [...moved],
291
302
  }
292
- : {
293
- remove_from: destinationPreload.fileHashes[insertLine - 1]!,
294
- remove_to: destinationPreload.fileHashes[insertLine - 1]!,
295
- text: [destinationLines[insertLine - 1]!, ...moved],
296
- };
303
+ : refs.direction === "after"
304
+ ? {
305
+ remove_from: destinationPreload.fileHashes[anchorLine - 1]!,
306
+ remove_to: destinationPreload.fileHashes[anchorLine - 1]!,
307
+ text: [destinationLines[anchorLine - 1]!, ...moved],
308
+ }
309
+ : {
310
+ remove_from: destinationPreload.fileHashes[anchorLine - 1]!,
311
+ remove_to: destinationPreload.fileHashes[anchorLine - 1]!,
312
+ text: [...moved, destinationLines[anchorLine - 1]!],
313
+ };
297
314
  const destinationFolded = destinationPreload.normalized.length === 0 ? 0 : 1;
298
315
  const sourceEndings = endingsForRange(sourcePreload.endingSeparators, sourceStart, sourceEnd);
299
- const endingOverrides = destinationFolded === 1 ? offsetEndings(1, sourceEndings) : sourceEndings;
316
+ const endingOverrides = destinationFolded === 0
317
+ ? sourceEndings
318
+ : refs.direction === "after"
319
+ ? offsetEndings(1, sourceEndings)
320
+ : sourceEndings;
300
321
  if (kind === "copy") {
301
- return { sourcePreload, destinationPreload, sourceDisplay, destinationDisplay, destinationEdit, destinationFolded, endingOverrides };
322
+ return { sourcePreload, destinationPreload, sourceDisplay, destinationDisplay, destinationEdit, destinationFolded, ...(destinationFolded === 1 ? { anchorCarry: refs.direction === "after" ? 0 : moved.length } : {}), endingOverrides };
302
323
  }
303
324
  const sourceEdit: HTEdit = {
304
325
  remove_from: sourcePreload.fileHashes[sourceStart - 1]!,
305
326
  remove_to: sourcePreload.fileHashes[sourceEnd - 1]!,
306
327
  text: [],
307
328
  };
308
- return { sourcePreload, destinationPreload, sourceDisplay, destinationDisplay, destinationEdit, destinationFolded, sourceEdit, endingOverrides };
329
+ return { sourcePreload, destinationPreload, sourceDisplay, destinationDisplay, destinationEdit, destinationFolded, ...(destinationFolded === 1 ? { anchorCarry: refs.direction === "after" ? 0 : moved.length } : {}), sourceEdit, endingOverrides };
309
330
  }
310
331
 
311
332
  function pairEntry(pipe: PipelineResult, separators: LineEnding[] | undefined): UndoEntry {
@@ -333,6 +354,7 @@ async function commitMovePair(input: {
333
354
  ]);
334
355
  const destinationSpan = input.destination.pipe.spans?.[0];
335
356
  const sourceSpan = input.source.pipe.spans?.[0];
357
+ if (destinationSpan && input.destination.anchorCarry !== undefined) destinationSpan.carry = input.destination.anchorCarry;
336
358
  const destinationSeparators = destinationSpan
337
359
  ? separatorsForSpans(input.destination.pipe.originalSeparators, input.destination.pipe.originalHashes.length, [destinationSpan], input.destination.pipe.result, input.destination.pipe.originalEnding, splitLines(input.destination.pipe.originalNormalized))
338
360
  : undefined;
@@ -558,7 +580,7 @@ async function executeCrossFile(
558
580
  absolutePath: destination.absolute,
559
581
  mutationTargetPath: destination.resolved,
560
582
  editAnchors: [prepared.destinationEdit.remove_from, prepared.destinationEdit.remove_to],
561
- ...(prepared.destinationFolded === 1 ? { anchorCarry: 0 } : {}),
583
+ ...(prepared.anchorCarry !== undefined ? { anchorCarry: prepared.anchorCarry } : {}),
562
584
  endingOverrides: prepared.endingOverrides,
563
585
  signal,
564
586
  verb: "copied",
@@ -581,6 +603,7 @@ async function executeCrossFile(
581
603
  absolutePath: destination.absolute,
582
604
  mutationTargetPath: destination.resolved,
583
605
  foldedAnchorLines: prepared.destinationFolded,
606
+ ...(prepared.anchorCarry !== undefined ? { anchorCarry: prepared.anchorCarry } : {}),
584
607
  endingOverrides: prepared.endingOverrides,
585
608
  },
586
609
  signal,
@@ -624,6 +647,7 @@ function planTransferBatchMember(kind: TransferKind): BatchMemberPlanner {
624
647
  warnings: [...warnings, ...resWarnings],
625
648
  planned,
626
649
  foldedLines: transferPlan.foldedAnchorLines,
650
+ direction: refs.direction,
627
651
  ...(transferPlan.anchorCarry !== undefined ? { carryIndex: transferPlan.anchorCarry } : {}),
628
652
  ...(transferPlan.servedOverride !== undefined ? { servedOverride: transferPlan.servedOverride } : {}),
629
653
  ...(transferPlan.endingOverrides !== undefined ? { contentSeparators: transferPlan.endingOverrides } : {}),
@@ -650,7 +674,7 @@ async function executeBatchCrossCopy(
650
674
  const sourceMember = pendingBatchMemberFor(sourcePath);
651
675
  const sourcePreload = sourceMember
652
676
  ? preloadFromBase(await ensureBatchBase({ member: sourceMember, targetPath: sourcePath, mutationTargetPath: sourceMember.target, cwd, signal }))
653
- : await readNormFile(sourcePath, cwd, { signal, accessMode: constants.R_OK, maxLines: MAX_HASH_LINES });
677
+ : undefined;
654
678
  prepared = await prepareCrossTransfer({
655
679
  kind: "copy",
656
680
  refs,
@@ -686,7 +710,7 @@ async function executeBatchCrossCopy(
686
710
  hedit,
687
711
  warnings: [...warnings, ...resWarnings],
688
712
  foldedLines: prepared.destinationFolded,
689
- ...(prepared.destinationFolded === 1 ? { carryIndex: 0 } : {}),
713
+ ...(prepared.anchorCarry !== undefined ? { carryIndex: prepared.anchorCarry } : {}),
690
714
  contentSeparators: prepared.endingOverrides,
691
715
  },
692
716
  });
@@ -751,7 +775,7 @@ async function executeBatchCrossMove(
751
775
  hedit,
752
776
  warnings: [...warnings, ...resWarnings, ...sourceWarnings],
753
777
  foldedLines: prepared.destinationFolded,
754
- ...(prepared.destinationFolded === 1 ? { carryIndex: 0 } : {}),
778
+ ...(prepared.anchorCarry !== undefined ? { carryIndex: prepared.anchorCarry } : {}),
755
779
  contentSeparators: prepared.endingOverrides,
756
780
  sourceMove: {
757
781
  displayPath: prepared.sourceDisplay,
@@ -825,7 +849,7 @@ export async function transferPreview(kind: TransferKind, request: unknown, cwd:
825
849
  }
826
850
  }
827
851
 
828
- function getTransferInput(args: unknown): { path?: string; source_from?: string; source_to?: string; insert_after?: string } | null {
852
+ function getTransferInput(args: unknown): { path?: string; source_from?: string; source_to?: string; anchor?: string; direction?: string } | null {
829
853
  let normalized: unknown;
830
854
  try {
831
855
  normalized = normReq(args);
@@ -836,7 +860,8 @@ function getTransferInput(args: unknown): { path?: string; source_from?: string;
836
860
  if (
837
861
  typeof normalized.source_from !== "string" ||
838
862
  typeof normalized.source_to !== "string" ||
839
- typeof normalized.insert_after !== "string"
863
+ typeof normalized.anchor !== "string" ||
864
+ typeof normalized.direction !== "string"
840
865
  ) {
841
866
  return null;
842
867
  }
@@ -844,22 +869,27 @@ function getTransferInput(args: unknown): { path?: string; source_from?: string;
844
869
  ...(typeof normalized.path === "string" ? { path: normalized.path } : {}),
845
870
  source_from: normalized.source_from,
846
871
  source_to: normalized.source_to,
847
- insert_after: normalized.insert_after,
872
+ anchor: normalized.anchor,
873
+ direction: normalized.direction,
848
874
  };
849
875
  }
850
876
 
851
877
  const transferSourceFromSchema = Type.String({
852
878
  description:
853
- "4-char anchor of the FIRST source line to copy (never the row content).",
879
+ "4-char anchor of the FIRST source line to copy.",
854
880
  });
855
881
  const transferSourceToSchema = Type.String({
856
882
  description:
857
883
  "4-char anchor of the LAST source line to copy.",
858
884
  });
859
- const transferInsertAfterSchema = Type.String({
885
+ const transferAnchorSchema = Type.String({
860
886
  description:
861
- "4-char anchor of the destination line; the block goes after it and may live in another file.",
887
+ "4-char anchor of the destination line; the block lands before or after it and it may live in another file.",
862
888
  });
889
+ const transferDirectionSchema = Type.Union(
890
+ [Type.Literal("after"), Type.Literal("before")],
891
+ { description: '"after" places the block below the anchor line, "before" above it' },
892
+ );
863
893
  const transferPathRequiredSchema = Type.String({
864
894
  description:
865
895
  "Path to the source or destination file the anchors were served for; required and must match anchor ownership.",
@@ -869,7 +899,8 @@ const transferToolSchema = Type.Object(
869
899
  {
870
900
  source_from: transferSourceFromSchema,
871
901
  source_to: transferSourceToSchema,
872
- insert_after: transferInsertAfterSchema,
902
+ anchor: transferAnchorSchema,
903
+ direction: transferDirectionSchema,
873
904
  },
874
905
  { additionalProperties: true },
875
906
  );
@@ -881,7 +912,8 @@ export function buildTransferToolSchema(requirePath: boolean): typeof transferTo
881
912
  path: transferPathRequiredSchema,
882
913
  source_from: transferSourceFromSchema,
883
914
  source_to: transferSourceToSchema,
884
- insert_after: transferInsertAfterSchema,
915
+ anchor: transferAnchorSchema,
916
+ direction: transferDirectionSchema,
885
917
  },
886
918
  { additionalProperties: true },
887
919
  ) as typeof transferToolSchema;
@@ -895,8 +927,8 @@ export function buildTransferToolDef(kind: TransferKind, flags: EditToolFlags =
895
927
  description: loadP(`../tool-prompts/${kind}.md`),
896
928
  snippet: loadP(`../tool-prompts/${kind}-snippet.md`),
897
929
  guidelines: [
898
- ...loadGuide(`../tool-prompts/${kind}-guidelines.md`),
899
- ...loadGuide("../tool-prompts/transfer-guidelines.md"),
930
+ ...loadGuidelines(`../tool-prompts/${kind}-guidelines.md`),
931
+ ...loadGuidelines("../tool-prompts/transfer-guidelines.md"),
900
932
  ],
901
933
  },
902
934
  flags,
@@ -6,6 +6,7 @@ import { assertRangeServed, lineChecksum, parseHashRef, stripAnchorRow } from ".
6
6
  import { readConfig } from "./config";
7
7
  import { makeRenderCall, renderEditResult, type RPreview, type FgT } from "./replace-render";
8
8
  import type { ReplaceDetails } from "./replace";
9
+ import type { Guideline } from "./prompts";
9
10
  export const editPrepare = makePrepareArguments();
10
11
 
11
12
  export interface EditToolFlags {
@@ -46,7 +47,7 @@ export async function currentEditFlags(codemode = false): Promise<EditToolFlags>
46
47
 
47
48
  function preferenceGuideline(flags: EditToolFlags): string {
48
49
  const tools = gatedEditOps(["read", "replace", "replace_match", "insert", "copy", "move", "undo_last_change"], flags);
49
- return `Prefer the hashline tools for anything that touches files: ${joinOps(tools, { backtick: true })}.`;
50
+ return `Prefer the hashline tools for every file read and edit: ${joinOps(tools, { backtick: true })}.`;
50
51
  }
51
52
 
52
53
  const SHARED_EDIT_OPS = ["replace", "replace_match", "insert", "copy", "move"];
@@ -61,6 +62,17 @@ const SCRIPT_TRANSFER_GUIDELINE =
61
62
  const SCRIPT_UNDO_GUIDELINE =
62
63
  "`undo_last_change`: each edit from one codemode script takes the undo slot, so only the most recent is undoable.";
63
64
 
65
+ const READ_REREAD_ID = "read-reread";
66
+ const READ_OUTLINE_ID = "read-outline";
67
+ const READ_MATCH_ID = "read-match-served";
68
+ const UNDO_WRITE_CLEARS_ID = "undo-write-clears";
69
+ const UNDO_CROSS_FILE_MOVE_ID = "undo-cross-file-move";
70
+ const NEUTRAL_READ_REREAD = "`read`: call again after an edit when you need anchors you lack.";
71
+ const NEUTRAL_UNDO_WRITE_CLEARS = "`undo_last_change`: only the last `replace`/`replace_match`/`insert`/`copy`/`move` per file is undoable; a `write` clears it, so undo before any other edit or write.";
72
+ const GREP_OPS_PHRASE = "replace, insert, copy, or move";
73
+ const UNDO_OPS_PHRASE = "replace, replace_match, insert, copy, or move";
74
+ const UNDO_GUIDELINE_OPS_PHRASE = "`replace`/`replace_match`/`insert`/`copy`/`move`";
75
+
64
76
  function operationNames(ops: string[], flags: EditToolFlags): string {
65
77
  return joinOps(gatedEditOps(ops, flags), { backtick: true, separator: "/" });
66
78
  }
@@ -69,24 +81,24 @@ function batchGuideline(flags: EditToolFlags): string {
69
81
  const tools = operationNames(SHARED_EDIT_OPS, flags);
70
82
  const outcome = flags.autoRead ? "diff" : "result";
71
83
  const script = flags.codemode ? ` ${SCRIPT_BATCH_GUIDELINE}` : "";
72
- return `${tools}: same-file calls in one message are grouped into one batch; earlier calls reply \`In batch N (queued)\` and the last applied call shows the combined ${outcome}, with one undo for the whole batch. A call whose own request is invalid (anchors, shape, or \`old_string\`) fails on its own; any failure once the batch runs aborts the whole batch.${script}`;
84
+ return `${tools}: same-file calls in one message commit as one batch; earlier calls reply \`In batch N (queued)\`, the last shows the combined ${outcome}, and one undo reverts all. A request-invalid call (anchors, shape, or \`old_string\`) fails alone; a failure while the batch runs aborts it.${script}`;
73
85
  }
74
86
 
75
87
  function diffGuideline(flags: EditToolFlags): string {
76
88
  const tools = operationNames(SHARED_DIFF_OPS, flags);
77
- return `${tools}: in the post-edit diff, \`-anchor│\` rows are dead anchors; \`+anchor│\` and \` anchor│\` rows are live anchors for the next edit.`;
89
+ return `${tools}: in the post-edit diff, \`-anchor│\` rows are dead anchors; \`+anchor│\` and \` anchor│\` rows carry live anchors for the next edit.`;
78
90
  }
79
91
 
80
92
  function pathGuideline(flags: EditToolFlags): string {
81
93
  const tools = operationNames(SHARED_EDIT_OPS, flags);
82
94
  return flags.requirePath
83
95
  ? `${tools}: pass \`path\` matching the file the anchors were served for; it is required and must match anchor ownership.`
84
- : `${tools}: path resolution is anchor-only; do not pass \`path\`.`;
96
+ : `${tools}: anchors resolve the file; \`path\` is optional but must match anchor ownership when passed.`;
85
97
  }
86
98
 
87
99
  function payloadGuideline(flags: EditToolFlags): string {
88
100
  const tools = operationNames(SHARED_PAYLOAD_OPS, flags);
89
- return `${tools}: write the character itself, not an escape; the tool writes \`text\` verbatim and never decodes escapes — \`\\uXXXX\` is the character, \`\\\\uXXXX\` the literal text.`;
101
+ return `${tools}: \`text\` is written exactly as received; escapes are decoded once when the call is parsed, and the tool never decodes again — \`\\uXXXX\` is the character, \`\\\\uXXXX\` the literal text.`;
90
102
  }
91
103
 
92
104
  function strictInputGuideline(flags: EditToolFlags): string {
@@ -109,43 +121,45 @@ function finalizePrompts(
109
121
  return { description, snippet, guidelines: [...guidelines, ...shared] };
110
122
  }
111
123
 
112
- export function withReplacePrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
124
+ export function withReplacePrompts(base: { description: string; snippet: string; guidelines: Guideline[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
113
125
  let description = base.description;
114
- const guidelines = [preferenceGuideline(flags), ...base.guidelines];
126
+ const guidelines = [preferenceGuideline(flags), ...base.guidelines.map((guideline) => guideline.text)];
115
127
  if (!flags.replaceMatchEnabled) {
116
- description = description.replace(/\n?To change only part of a line without retyping the rest, use `replace_match` instead; it preserves every character the request does not name\./, "");
128
+ description = description
129
+ .split(/(?<=\.)\s+/)
130
+ .filter((sentence) => !sentence.includes("`replace_match`"))
131
+ .join(" ");
117
132
  }
118
133
  return finalizePrompts(description, base.snippet, guidelines, flags);
119
134
  }
120
135
 
121
- function readGuidelineApplies(guideline: string, flags: EditToolFlags): boolean {
122
- if (!flags.autoReadAllOutline && guideline.includes("[limit N]")) return false;
123
- if (!flags.replaceMatchEnabled && guideline.includes("`replace_match`")) return false;
136
+ function readGuidelineApplies(guideline: Guideline, flags: EditToolFlags): boolean {
137
+ if (guideline.id === READ_OUTLINE_ID) return flags.autoReadAllOutline;
138
+ if (guideline.id === READ_MATCH_ID) return flags.replaceMatchEnabled;
124
139
  return true;
125
140
  }
126
141
 
127
- export function withReadPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
142
+ export function withReadPrompts(base: { description: string; snippet: string; guidelines: Guideline[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
128
143
  const preference = preferenceGuideline(flags);
129
144
  const script = flags.codemode ? [RESULT_CONTRACT_GUIDELINE] : [];
130
- const baseGuidelines = base.guidelines.filter((guideline) => readGuidelineApplies(guideline, flags));
145
+ const applicable = base.guidelines.filter((guideline) => readGuidelineApplies(guideline, flags));
131
146
  if (flags.autoReadAllActive) {
132
- const rewritten = baseGuidelines
133
- .filter((guideline) => !guideline.includes("call again after an edit"))
147
+ const rewritten = applicable.filter((guideline) => guideline.id !== READ_REREAD_ID).map((guideline) => guideline.text);
134
148
  return { description: base.description, snippet: base.snippet, guidelines: [preference, ...rewritten, ...script] };
135
149
  }
136
- if (flags.autoRead) return { description: base.description, snippet: base.snippet, guidelines: [preference, ...baseGuidelines, ...script] };
137
- const guidelines = [preference, ...baseGuidelines, ...script];
138
- const mapped = guidelines.map((guideline) => guideline.startsWith("`read`: call again after an edit") ? "`read`: call again after an edit when you need anchors you lack." : guideline);
139
- return { description: base.description, snippet: base.snippet, guidelines: mapped };
150
+ const guidelines = applicable.map((guideline) =>
151
+ guideline.id === READ_REREAD_ID && !flags.autoRead ? NEUTRAL_READ_REREAD : guideline.text,
152
+ );
153
+ return { description: base.description, snippet: base.snippet, guidelines: [preference, ...guidelines, ...script] };
140
154
  }
141
155
 
142
- export function withInsertPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
143
- const guidelines = [preferenceGuideline(flags), ...base.guidelines];
156
+ export function withInsertPrompts(base: { description: string; snippet: string; guidelines: Guideline[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
157
+ const guidelines = [preferenceGuideline(flags), ...base.guidelines.map((guideline) => guideline.text)];
144
158
  return finalizePrompts(base.description, base.snippet, guidelines, flags);
145
159
  }
146
160
 
147
- export function withReplaceMatchPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
148
- const guidelines = [preferenceGuideline(flags), ...base.guidelines];
161
+ export function withReplaceMatchPrompts(base: { description: string; snippet: string; guidelines: Guideline[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
162
+ const guidelines = [preferenceGuideline(flags), ...base.guidelines.map((guideline) => guideline.text)];
149
163
  return finalizePrompts(base.description, base.snippet, guidelines, flags);
150
164
  }
151
165
 
@@ -166,35 +180,34 @@ function joinOps(ops: string[], options?: { backtick?: boolean; separator?: "/"
166
180
  return formatted.length === 2 ? `${head} or ${last}` : `${head}, or ${last}`;
167
181
  }
168
182
 
169
- export function withGrepPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
170
- const guidelines = [preferenceGuideline(flags), ...base.guidelines];
183
+ export function withGrepPrompts(base: { description: string; snippet: string; guidelines: Guideline[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
184
+ const guidelines = [preferenceGuideline(flags), ...base.guidelines.map((guideline) => guideline.text)];
171
185
  if (flags.codemode) guidelines.push(RESULT_CONTRACT_GUIDELINE);
172
- if (flags.copyMoveEnabled) return { ...base, guidelines };
173
- return { ...base, guidelines, description: base.description.replaceAll("replace, insert, copy, or move", joinOps(gatedEditOps(["replace", "insert", "copy", "move"], flags))) };
186
+ if (flags.copyMoveEnabled) return { description: base.description, snippet: base.snippet, guidelines };
187
+ const description = base.description.replaceAll(GREP_OPS_PHRASE, joinOps(gatedEditOps(["replace", "insert", "copy", "move"], flags)));
188
+ return { description, snippet: base.snippet, guidelines };
174
189
  }
175
190
 
176
- export function withUndoPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
191
+ export function withUndoPrompts(base: { description: string; snippet: string; guidelines: Guideline[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
177
192
  const ops = gatedEditOps(["replace", "replace_match", "insert", "copy", "move"], flags);
178
- let description = base.description;
179
- let snippet = base.snippet;
180
193
  const script = flags.codemode ? [RESULT_CONTRACT_GUIDELINE, SCRIPT_UNDO_GUIDELINE] : [];
181
- let guidelines = [preferenceGuideline(flags), ...base.guidelines, ...script];
182
- if (!flags.autoRead) {
183
- guidelines = guidelines.map((guideline) => guideline.includes("a `write` clears the history") ? "`undo_last_change`: only the last `replace`/`replace_match`/`insert`/`copy`/`move` per file is undoable; a `write` clears it, so undo before any other edit or write." : guideline);
184
- }
194
+ let description = base.description;
195
+ let guidelines = [
196
+ preferenceGuideline(flags),
197
+ ...base.guidelines
198
+ .filter((guideline) => flags.copyMoveEnabled || guideline.id !== UNDO_CROSS_FILE_MOVE_ID)
199
+ .map((guideline) => (guideline.id === UNDO_WRITE_CLEARS_ID && !flags.autoRead ? NEUTRAL_UNDO_WRITE_CLEARS : guideline.text)),
200
+ ...script,
201
+ ];
185
202
  if (ops.length !== 5) {
186
- description = description.replaceAll("replace, replace_match, insert, copy, or move", joinOps(ops));
187
- snippet = snippet.replaceAll("`replace`, `replace_match`, `insert`, `copy`, or `move`", joinOps(ops, { backtick: true }));
188
- guidelines = guidelines.map((guideline) => guideline.replaceAll("`replace`/`replace_match`/`insert`/`copy`/`move`", joinOps(ops, { backtick: true, separator: "/" })));
203
+ description = description.replaceAll(UNDO_OPS_PHRASE, joinOps(ops));
204
+ guidelines = guidelines.map((guideline) => guideline.replaceAll(UNDO_GUIDELINE_OPS_PHRASE, joinOps(ops, { backtick: true, separator: "/" })));
189
205
  }
190
- if (!flags.copyMoveEnabled) {
191
- guidelines = guidelines.filter((guideline) => !guideline.includes("cross-file `move`"));
192
- }
193
- return { description, snippet, guidelines };
206
+ return { description, snippet: base.snippet, guidelines };
194
207
  }
195
208
 
196
- export function withTransferPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
197
- const guidelines = [preferenceGuideline(flags), ...base.guidelines, ...(flags.codemode ? [SCRIPT_TRANSFER_GUIDELINE] : [])];
209
+ export function withTransferPrompts(base: { description: string; snippet: string; guidelines: Guideline[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
210
+ const guidelines = [preferenceGuideline(flags), ...base.guidelines.map((guideline) => guideline.text), ...(flags.codemode ? [SCRIPT_TRANSFER_GUIDELINE] : [])];
198
211
  return finalizePrompts(base.description, base.snippet, guidelines, flags, { stringPayload: false });
199
212
  }
200
213
 
@@ -246,24 +259,43 @@ export interface PathRequirementInput {
246
259
  anchorTarget?: string;
247
260
  }
248
261
 
249
- export async function resolveEditTargetWithRequirement(input: PathRequirementInput): Promise<string> {
262
+ export interface PathOptionInput {
263
+ providedPath: unknown;
264
+ cwd: string;
265
+ targets: readonly string[];
266
+ expected: string;
267
+ requestLabel: string;
268
+ }
269
+
270
+ export async function assertPathOption(input: PathOptionInput): Promise<void> {
250
271
  const { requirePath } = await readConfig();
251
- if (!requirePath && input.providedPath !== undefined) {
252
- throw new Error("[E_BAD_SHAPE] Edit request contains unknown or unsupported fields: path. Path resolution is anchor-only; retry without `path`.");
272
+ if (input.providedPath === undefined || (requirePath && input.providedPath === "")) {
273
+ if (requirePath) {
274
+ throw new Error(`[E_BAD_SHAPE] ${input.requestLabel} requires a non-empty "path" string when require-path mode is on. Provide \`path\` matching ${input.expected}.`);
275
+ }
276
+ return;
277
+ }
278
+ if (typeof input.providedPath !== "string" || input.providedPath.length === 0) {
279
+ throw new Error(`[E_BAD_SHAPE] ${input.requestLabel} field "path" must be a non-empty string when provided.`);
253
280
  }
254
- if (requirePath && (typeof input.providedPath !== "string" || input.providedPath.length === 0)) {
255
- throw new Error('[E_BAD_SHAPE] Edit request requires a non-empty "path" string when require-path mode is on. Provide `path` matching the file the anchors were served for.');
281
+ const { resolved } = await resolveInCwd(input.providedPath, input.cwd);
282
+ if (!input.targets.includes(resolved)) {
283
+ throw new Error(`[E_BAD_SHAPE] Provided "path" "${input.providedPath}" does not match ${input.expected}.`);
256
284
  }
285
+ }
286
+
287
+ export async function resolveEditTargetWithRequirement(input: PathRequirementInput): Promise<string> {
257
288
  const anchorTarget = input.anchorTarget
258
289
  ?? (typeof input.anchor === "string"
259
290
  ? resolveEditTarget(input.anchor)
260
291
  : resolveEditTarget(input.removeFrom as string, input.removeTo));
261
- if (requirePath) {
262
- const { resolved } = await resolveInCwd(input.providedPath as string, input.cwd);
263
- if (resolved !== anchorTarget) {
264
- throw new Error(`[E_BAD_SHAPE] Provided "path" "${input.providedPath}" does not match anchor ownership "${anchorTarget}".`);
265
- }
266
- }
292
+ await assertPathOption({
293
+ providedPath: input.providedPath,
294
+ cwd: input.cwd,
295
+ targets: [anchorTarget],
296
+ expected: `anchor ownership "${anchorTarget}"`,
297
+ requestLabel: "Edit request",
298
+ });
267
299
  return anchorTarget;
268
300
  }
269
301