@oh-my-pi/pi-coding-agent 12.12.3 → 12.13.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.
@@ -4,12 +4,12 @@
4
4
  * Each line in a file is identified by its 1-indexed line number and a short
5
5
  * base36 hash derived from the normalized line content (xxHash32, truncated to 4
6
6
  * base36 chars).
7
- * The combined `LINE:HASH` reference acts as both an address and a staleness check:
7
+ * The combined `LINE#ID` reference acts as both an address and a staleness check:
8
8
  * if the file has changed since the caller last read it, hash mismatches are caught
9
9
  * before any mutation occurs.
10
10
  *
11
- * Displayed format: `LINENUM:HASH|CONTENT`
12
- * Reference format: `"LINENUM:HASH"` (e.g. `"5:a3f2"`)
11
+ * Displayed format: `LINENUM#HASH|CONTENT`
12
+ * Reference format: `"LINENUM#HASH"` (e.g. `"5#a3f2"`)
13
13
  */
14
14
 
15
15
  import type { HashlineEdit } from "./index";
@@ -18,45 +18,84 @@ import type { HashMismatch } from "./types";
18
18
  type ParsedRefs =
19
19
  | { kind: "single"; ref: { line: number; hash: string } }
20
20
  | { kind: "range"; start: { line: number; hash: string }; end: { line: number; hash: string } }
21
- | { kind: "insertAfter"; after: { line: number; hash: string } };
21
+ | { kind: "insertAfter"; after: { line: number; hash: string } }
22
+ | { kind: "insertBefore"; before: { line: number; hash: string } }
23
+ | { kind: "insertBetween"; after: { line: number; hash: string }; before: { line: number; hash: string } }
24
+ | { kind: "insertAtEof" };
22
25
 
23
- function parseHashlineEdit(edit: HashlineEdit): { spec: ParsedRefs; dst: string } {
24
- if ("set_line" in edit) {
26
+ function parseHashlineEdit(edit: HashlineEdit): { spec: ParsedRefs; dstLines: string[] } {
27
+ if ("set" in edit) {
25
28
  return {
26
- spec: { kind: "single", ref: parseLineRef(edit.set_line.anchor) },
27
- dst: edit.set_line.new_text,
29
+ spec: { kind: "single", ref: parseLineRef(edit.set.ref) },
30
+ dstLines: edit.set.body,
28
31
  };
29
32
  }
30
- if ("replace_lines" in edit) {
31
- const r = edit.replace_lines as Record<string, string>;
32
- const start = parseLineRef(r.start_anchor);
33
- if (!r.end_anchor) {
33
+ if ("set_range" in edit) {
34
+ const r = edit.set_range as Record<string, unknown>;
35
+ const start = parseLineRef(r.beg as string);
36
+ if (!r.end) {
34
37
  return {
35
38
  spec: { kind: "single", ref: start },
36
- dst: r.new_text ?? "",
39
+ dstLines: Array.isArray(r.body) ? (r.body as string[]) : splitDstLines(String(r.body ?? "")),
37
40
  };
38
41
  }
39
- const end = parseLineRef(r.end_anchor);
42
+ const end = parseLineRef(r.end as string);
40
43
  return {
41
44
  spec: start.line === end.line ? { kind: "single", ref: start } : { kind: "range", start, end },
42
- dst: r.new_text ?? "",
45
+ dstLines: Array.isArray(r.body) ? (r.body as string[]) : splitDstLines(String(r.body ?? "")),
43
46
  };
44
47
  }
48
+ if ("insert" in edit) {
49
+ const r = edit.insert as Record<string, unknown>;
50
+ const dstLines = Array.isArray(r.body) ? (r.body as string[]) : splitDstLines(String(r.text ?? r.content ?? ""));
51
+ const hasAfterField = "after" in r;
52
+ const hasBeforeField = "before" in r;
53
+ const afterRef = r.after;
54
+ const beforeRef = r.before;
55
+ if (hasAfterField && (typeof afterRef !== "string" || afterRef.trim().length === 0)) {
56
+ throw new Error('insert.after must be a non-empty "LINE#ID" string when provided');
57
+ }
58
+ if (hasBeforeField && (typeof beforeRef !== "string" || beforeRef.trim().length === 0)) {
59
+ throw new Error('insert.before must be a non-empty "LINE#ID" string when provided');
60
+ }
61
+ const hasAfter = hasAfterField && typeof afterRef === "string";
62
+ const hasBefore = hasBeforeField && typeof beforeRef === "string";
63
+ if (hasAfter && hasBefore) {
64
+ return {
65
+ spec: {
66
+ kind: "insertBetween",
67
+ after: parseLineRef(afterRef),
68
+ before: parseLineRef(beforeRef),
69
+ },
70
+ dstLines,
71
+ };
72
+ }
73
+ if (hasAfter) {
74
+ return {
75
+ spec: { kind: "insertAfter", after: parseLineRef(afterRef) },
76
+ dstLines,
77
+ };
78
+ }
79
+ if (hasBefore) {
80
+ return {
81
+ spec: { kind: "insertBefore", before: parseLineRef(beforeRef) },
82
+ dstLines,
83
+ };
84
+ }
85
+ return { spec: { kind: "insertAtEof" }, dstLines };
86
+ }
45
87
  if ("replace" in edit) {
46
88
  throw new Error("replace edits are applied separately; do not pass them to applyHashlineEdits");
47
89
  }
48
- return {
49
- spec: { kind: "insertAfter", after: parseLineRef(edit.insert_after.anchor) },
50
- dst: edit.insert_after.text ?? (edit.insert_after as Record<string, string>).content ?? "",
51
- };
90
+ throw new Error("Unknown hashline edit operation");
52
91
  }
53
92
  /** Split dst into lines; empty string means delete (no lines). */
54
93
  function splitDstLines(dst: string): string[] {
55
94
  return dst === "" ? [] : dst.split("\n");
56
95
  }
57
96
 
58
- /** Pattern matching hashline display format: `LINE:HASH|CONTENT` */
59
- const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+:[0-9a-zA-Z]{1,16}\|/;
97
+ /** Pattern matching hashline display format: `LINE#ID|CONTENT` */
98
+ const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+#[0-9a-zA-Z]{1,16}\|/;
60
99
 
61
100
  /** Pattern matching a unified-diff `+` prefix (but not `++`) */
62
101
  const DIFF_PLUS_RE = /^\+(?!\+)/;
@@ -179,6 +218,25 @@ function stripInsertAnchorEchoAfter(anchorLine: string, dstLines: string[]): str
179
218
  return dstLines;
180
219
  }
181
220
 
221
+ function stripInsertAnchorEchoBefore(anchorLine: string, dstLines: string[]): string[] {
222
+ if (dstLines.length <= 1) return dstLines;
223
+ if (equalsIgnoringWhitespace(dstLines[dstLines.length - 1], anchorLine)) {
224
+ return dstLines.slice(0, -1);
225
+ }
226
+ return dstLines;
227
+ }
228
+
229
+ function stripInsertBoundaryEcho(afterLine: string, beforeLine: string, dstLines: string[]): string[] {
230
+ let out = dstLines;
231
+ if (out.length > 1 && equalsIgnoringWhitespace(out[0], afterLine)) {
232
+ out = out.slice(1);
233
+ }
234
+ if (out.length > 1 && equalsIgnoringWhitespace(out[out.length - 1], beforeLine)) {
235
+ out = out.slice(0, -1);
236
+ }
237
+ return out;
238
+ }
239
+
182
240
  function stripRangeBoundaryEcho(fileLines: string[], startLine: number, endLine: number, dstLines: string[]): string[] {
183
241
  // Only strip when the model replaced with multiple lines and grew the edit.
184
242
  // This avoids turning a single-line replacement into a deletion.
@@ -206,7 +264,7 @@ function stripRangeBoundaryEcho(fileLines: string[], startLine: number, endLine:
206
264
  /**
207
265
  * Strip hashline display prefixes and diff `+` markers from replacement lines.
208
266
  *
209
- * Models frequently copy the `LINE:HASH ` prefix from read output into their
267
+ * Models frequently copy the `LINE#ID ` prefix from read output into their
210
268
  * replacement content, or include unified-diff `+` prefixes. Both corrupt the
211
269
  * output file. This strips them heuristically before application.
212
270
  */
@@ -262,7 +320,7 @@ export function computeLineHash(idx: number, line: string): string {
262
320
  /**
263
321
  * Format file content with hashline prefixes for display.
264
322
  *
265
- * Each line becomes `LINENUM:HASH|CONTENT` where LINENUM is 1-indexed.
323
+ * Each line becomes `LINENUM#HASH|CONTENT` where LINENUM is 1-indexed.
266
324
  *
267
325
  * @param content - Raw file content string
268
326
  * @param startLine - First line number (1-indexed, defaults to 1)
@@ -271,7 +329,7 @@ export function computeLineHash(idx: number, line: string): string {
271
329
  * @example
272
330
  * ```
273
331
  * formatHashLines("function hi() {\n return;\n}")
274
- * // "1:HH|function hi() {\n2:HH| return;\n3:HH|}"
332
+ * // "1#HH|function hi() {\n2#HH| return;\n3#HH|}"
275
333
  * ```
276
334
  */
277
335
  export function formatHashLines(content: string, startLine = 1): string {
@@ -280,7 +338,7 @@ export function formatHashLines(content: string, startLine = 1): string {
280
338
  .map((line, i) => {
281
339
  const num = startLine + i;
282
340
  const hash = computeLineHash(num, line);
283
- return `${num}:${hash}|${line}`;
341
+ return `${num}#${hash}|${line}`;
284
342
  })
285
343
  .join("\n");
286
344
  }
@@ -352,7 +410,7 @@ export async function* streamHashLinesFromUtf8(
352
410
  };
353
411
 
354
412
  const pushLine = (line: string): string[] => {
355
- const formatted = `${lineNum}:${computeLineHash(lineNum, line)}|${line}`;
413
+ const formatted = `${lineNum}#${computeLineHash(lineNum, line)}|${line}`;
356
414
  lineNum++;
357
415
 
358
416
  const chunksToYield: string[] = [];
@@ -446,7 +504,7 @@ export async function* streamHashLinesFromLines(
446
504
 
447
505
  const pushLine = (line: string): string[] => {
448
506
  sawAnyLine = true;
449
- const formatted = `${lineNum}:${computeLineHash(lineNum, line)}|${line}`;
507
+ const formatted = `${lineNum}#${computeLineHash(lineNum, line)}|${line}`;
450
508
  lineNum++;
451
509
 
452
510
  const chunksToYield: string[] = [];
@@ -498,24 +556,24 @@ export async function* streamHashLinesFromLines(
498
556
  }
499
557
 
500
558
  /**
501
- * Parse a line reference string like `"5:abcd"` into structured form.
559
+ * Parse a line reference string like `"5#abcd"` into structured form.
502
560
  *
503
- * @throws Error if the format is invalid (not `NUMBER:HEXHASH`)
561
+ * @throws Error if the format is invalid (not `NUMBER#HEXHASH`)
504
562
  */
505
563
  export function parseLineRef(ref: string): { line: number; hash: string } {
506
- // Strip display-format suffix: "5:ab|some content" → "5:ab", or legacy "5:ab some content" → "5:ab"
564
+ // Strip display-format suffix: "5#ab|some content" → "5#ab", or legacy "5#ab some content" → "5#ab"
507
565
  // Models often copy the full display format from read output.
508
566
  const cleaned = ref
509
567
  .replace(/\|.*$/, "")
510
568
  .replace(/ {2}.*$/, "")
511
569
  .replace(/^>+\s*/, "")
512
570
  .trim();
513
- const normalized = cleaned.replace(/\s*:\s*/, ":");
514
- const strictMatch = normalized.match(/^(\d+):([0-9a-zA-Z]{1,16})$/);
515
- const prefixMatch = strictMatch ? null : normalized.match(new RegExp(`^(\\d+):([0-9a-zA-Z]{${HASH_LEN}})`));
571
+ const normalized = cleaned.replace(/\s*#\s*/, "#");
572
+ const strictMatch = normalized.match(/^(\d+)#([0-9a-zA-Z]{1,16})$/);
573
+ const prefixMatch = strictMatch ? null : normalized.match(new RegExp(`^(\\d+)#([0-9a-zA-Z]{${HASH_LEN}})`));
516
574
  const match = strictMatch ?? prefixMatch;
517
575
  if (!match) {
518
- throw new Error(`Invalid line reference "${ref}". Expected format "LINE:HASH" (e.g. "5:aa").`);
576
+ throw new Error(`Invalid line reference "${ref}". Expected format "LINE#ID" (e.g. "5#aa").`);
519
577
  }
520
578
  const line = Number.parseInt(match[1], 10);
521
579
  if (line < 1) {
@@ -535,7 +593,7 @@ const MISMATCH_CONTEXT = 2;
535
593
  * Error thrown when one or more hashline references have stale hashes.
536
594
  *
537
595
  * Displays grep-style output with `>>>` markers on mismatched lines,
538
- * showing the correct `LINE:HASH` so the caller can fix all refs at once.
596
+ * showing the correct `LINE#ID` so the caller can fix all refs at once.
539
597
  */
540
598
  export class HashlineMismatchError extends Error {
541
599
  readonly remaps: ReadonlyMap<string, string>;
@@ -548,7 +606,7 @@ export class HashlineMismatchError extends Error {
548
606
  const remaps = new Map<string, string>();
549
607
  for (const m of mismatches) {
550
608
  const actual = computeLineHash(m.line, fileLines[m.line - 1]);
551
- remaps.set(`${m.line}:${m.expected}`, `${m.line}:${actual}`);
609
+ remaps.set(`${m.line}#${m.expected}`, `${m.line}#${actual}`);
552
610
  }
553
611
  this.remaps = remaps;
554
612
  }
@@ -573,7 +631,7 @@ export class HashlineMismatchError extends Error {
573
631
  const lines: string[] = [];
574
632
 
575
633
  lines.push(
576
- `${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since last read. Use the updated LINE:HASH references shown below (>>> marks changed lines).`,
634
+ `${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since last read. Use the updated LINE#ID references shown below (>>> marks changed lines).`,
577
635
  );
578
636
  lines.push("");
579
637
 
@@ -587,7 +645,7 @@ export class HashlineMismatchError extends Error {
587
645
 
588
646
  const content = fileLines[lineNum - 1];
589
647
  const hash = computeLineHash(lineNum, content);
590
- const prefix = `${lineNum}:${hash}`;
648
+ const prefix = `${lineNum}#${hash}`;
591
649
 
592
650
  if (mismatchSet.has(lineNum)) {
593
651
  lines.push(`>>> ${prefix}|${content}`);
@@ -624,8 +682,8 @@ export function validateLineRef(ref: { line: number; hash: string }, fileLines:
624
682
  /**
625
683
  * Apply an array of hashline edits to file content.
626
684
  *
627
- * Each edit operation identifies target lines directly (`set_line`, `replace_lines`,
628
- * `insert_after`). Line references are resolved via {@link parseLineRef}
685
+ * Each edit operation identifies target lines directly (`set`, `set_range`,
686
+ * `insert`). Line references are resolved via {@link parseLineRef}
629
687
  * and hashes validated before any mutation.
630
688
  *
631
689
  * Edits are sorted bottom-up (highest effective line first) so earlier
@@ -651,12 +709,14 @@ export function applyHashlineEdits(
651
709
  let firstChangedLine: number | undefined;
652
710
  const noopEdits: Array<{ editIndex: number; loc: string; currentContent: string }> = [];
653
711
 
712
+ const autocorrect = Bun.env.PI_HL_AUTOCORRECT === "1";
713
+
654
714
  // Parse src specs and dst lines up front
655
715
  const parsed = edits.map(edit => {
656
716
  const parsedEdit = parseHashlineEdit(edit);
657
717
  return {
658
718
  spec: parsedEdit.spec,
659
- dstLines: stripNewLinePrefixes(splitDstLines(parsedEdit.dst)),
719
+ dstLines: stripNewLinePrefixes(parsedEdit.dstLines),
660
720
  };
661
721
  });
662
722
 
@@ -673,71 +733,72 @@ export function applyHashlineEdits(
673
733
  case "insertAfter":
674
734
  touched.add(spec.after.line);
675
735
  break;
736
+ case "insertBefore":
737
+ touched.add(spec.before.line);
738
+ break;
739
+ case "insertBetween":
740
+ touched.add(spec.after.line);
741
+ touched.add(spec.before.line);
742
+ break;
743
+ case "insertAtEof":
744
+ break;
676
745
  }
677
746
  }
678
747
  return touched;
679
748
  }
680
749
 
681
- let explicitlyTouchedLines = collectExplicitlyTouchedLines();
682
-
750
+ const explicitlyTouchedLines = collectExplicitlyTouchedLines();
683
751
  // Pre-validate: collect all hash mismatches before mutating
684
752
  const mismatches: HashMismatch[] = [];
685
- const uniqueLineByHash = new Map<string, number>();
686
- const seenDuplicateHashes = new Set<string>();
687
- for (let i = 0; i < fileLines.length; i++) {
688
- const lineNo = i + 1;
689
- const hash = computeLineHash(lineNo, fileLines[i]);
690
- if (seenDuplicateHashes.has(hash)) continue;
691
- if (uniqueLineByHash.has(hash)) {
692
- uniqueLineByHash.delete(hash);
693
- seenDuplicateHashes.add(hash);
694
- continue;
695
- }
696
- uniqueLineByHash.set(hash, lineNo);
697
- }
698
-
699
- function buildMismatch(ref: { line: number; hash: string }, line = ref.line): HashMismatch {
700
- return {
701
- line,
702
- expected: ref.hash,
703
- actual: computeLineHash(line, fileLines[line - 1]),
704
- };
705
- }
706
-
707
- function validateOrRelocateRef(ref: {
708
- line: number;
709
- hash: string;
710
- }): { ok: true; relocated: boolean } | { ok: false } {
753
+ function validateRef(ref: { line: number; hash: string }): boolean {
711
754
  if (ref.line < 1 || ref.line > fileLines.length) {
712
755
  throw new Error(`Line ${ref.line} does not exist (file has ${fileLines.length} lines)`);
713
756
  }
714
- const expected = ref.hash.toLowerCase();
715
757
  const actualHash = computeLineHash(ref.line, fileLines[ref.line - 1]);
716
- if (actualHash === expected) {
717
- return { ok: true, relocated: false };
758
+ if (actualHash === ref.hash.toLowerCase()) {
759
+ return true;
718
760
  }
719
-
720
- const relocated = uniqueLineByHash.get(expected);
721
- if (relocated === undefined) {
722
- mismatches.push({ line: ref.line, expected: ref.hash, actual: actualHash });
723
- return { ok: false };
724
- }
725
- ref.line = relocated;
726
- return { ok: true, relocated: true };
761
+ mismatches.push({ line: ref.line, expected: ref.hash, actual: actualHash });
762
+ return false;
727
763
  }
728
764
  for (const { spec, dstLines } of parsed) {
729
765
  switch (spec.kind) {
730
766
  case "single": {
731
- const status = validateOrRelocateRef(spec.ref);
732
- if (!status.ok) continue;
767
+ if (!validateRef(spec.ref)) continue;
733
768
  break;
734
769
  }
735
770
  case "insertAfter": {
736
771
  if (dstLines.length === 0) {
737
- throw new Error('Insert-after edit (src "N:HH..") requires non-empty dst');
772
+ throw new Error('Insert-after edit (src "N#HH..") requires non-empty dst');
773
+ }
774
+ if (!validateRef(spec.after)) continue;
775
+ break;
776
+ }
777
+ case "insertBefore": {
778
+ if (dstLines.length === 0) {
779
+ throw new Error('Insert-before edit (src "N#HH..") requires non-empty dst');
780
+ }
781
+ if (!validateRef(spec.before)) continue;
782
+ break;
783
+ }
784
+ case "insertBetween": {
785
+ if (dstLines.length === 0) {
786
+ throw new Error('Insert-between edit (src "A#HH.. B#HH..") requires non-empty dst');
787
+ }
788
+ if (spec.before.line !== spec.after.line + 1) {
789
+ throw new Error(
790
+ `insert requires adjacent anchors (after ${spec.after.line}, before ${spec.before.line})`,
791
+ );
792
+ }
793
+ const afterValid = validateRef(spec.after);
794
+ const beforeValid = validateRef(spec.before);
795
+ if (!afterValid || !beforeValid) continue;
796
+ break;
797
+ }
798
+ case "insertAtEof": {
799
+ if (dstLines.length === 0) {
800
+ throw new Error("Insert-at-EOF edit requires non-empty dst");
738
801
  }
739
- const status = validateOrRelocateRef(spec.after);
740
- if (!status.ok) continue;
741
802
  break;
742
803
  }
743
804
  case "range": {
@@ -745,38 +806,16 @@ export function applyHashlineEdits(
745
806
  throw new Error(`Range start line ${spec.start.line} must be <= end line ${spec.end.line}`);
746
807
  }
747
808
 
748
- const originalStart = spec.start.line;
749
- const originalEnd = spec.end.line;
750
- const originalCount = originalEnd - originalStart + 1;
751
-
752
- const startStatus = validateOrRelocateRef(spec.start);
753
- const endStatus = validateOrRelocateRef(spec.end);
754
- if (!startStatus.ok || !endStatus.ok) continue;
755
-
756
- const relocatedCount = spec.end.line - spec.start.line + 1;
757
- const changedByRelocation = startStatus.relocated || endStatus.relocated;
758
- const invalidRange = spec.start.line > spec.end.line;
759
- const scopeChanged = relocatedCount !== originalCount;
760
-
761
- if (changedByRelocation && (invalidRange || scopeChanged)) {
762
- spec.start.line = originalStart;
763
- spec.end.line = originalEnd;
764
- mismatches.push(buildMismatch(spec.start, originalStart), buildMismatch(spec.end, originalEnd));
765
- }
809
+ const startValid = validateRef(spec.start);
810
+ const endValid = validateRef(spec.end);
811
+ if (!startValid || !endValid) continue;
766
812
  break;
767
813
  }
768
814
  }
769
815
  }
770
-
771
816
  if (mismatches.length > 0) {
772
817
  throw new HashlineMismatchError(mismatches, fileLines);
773
818
  }
774
-
775
- // Hash relocation may have rewritten reference line numbers.
776
- // Recompute touched lines so merge heuristics don't treat now-targeted
777
- // adjacent lines as safe merge candidates.
778
- explicitlyTouchedLines = collectExplicitlyTouchedLines();
779
-
780
819
  // Deduplicate identical edits targeting the same line(s)
781
820
  const seenEditKeys = new Map<string, number>();
782
821
  const dedupIndices = new Set<number>();
@@ -793,6 +832,15 @@ export function applyHashlineEdits(
793
832
  case "insertAfter":
794
833
  lineKey = `i:${p.spec.after.line}`;
795
834
  break;
835
+ case "insertBefore":
836
+ lineKey = `ib:${p.spec.before.line}`;
837
+ break;
838
+ case "insertBetween":
839
+ lineKey = `ix:${p.spec.after.line}:${p.spec.before.line}`;
840
+ break;
841
+ case "insertAtEof":
842
+ lineKey = "ieof";
843
+ break;
796
844
  }
797
845
  const dstKey = `${lineKey}|${p.dstLines.join("\n")}`;
798
846
  if (seenEditKeys.has(dstKey)) {
@@ -824,6 +872,18 @@ export function applyHashlineEdits(
824
872
  sortLine = p.spec.after.line;
825
873
  precedence = 1;
826
874
  break;
875
+ case "insertBefore":
876
+ sortLine = p.spec.before.line;
877
+ precedence = 2;
878
+ break;
879
+ case "insertBetween":
880
+ sortLine = p.spec.before.line;
881
+ precedence = 3;
882
+ break;
883
+ case "insertAtEof":
884
+ sortLine = fileLines.length + 1;
885
+ precedence = 4;
886
+ break;
827
887
  }
828
888
  return { ...p, idx, sortLine, precedence };
829
889
  });
@@ -834,7 +894,7 @@ export function applyHashlineEdits(
834
894
  for (const { spec, dstLines, idx } of annotated) {
835
895
  switch (spec.kind) {
836
896
  case "single": {
837
- const merged = maybeExpandSingleLineMerge(spec.ref.line, dstLines);
897
+ const merged = autocorrect ? maybeExpandSingleLineMerge(spec.ref.line, dstLines) : null;
838
898
  if (merged) {
839
899
  const origLines = originalFileLines.slice(
840
900
  merged.startLine - 1,
@@ -851,7 +911,7 @@ export function applyHashlineEdits(
851
911
  if (origLines.join("\n") === nextLines.join("\n")) {
852
912
  noopEdits.push({
853
913
  editIndex: idx,
854
- loc: `${spec.ref.line}:${spec.ref.hash}`,
914
+ loc: `${spec.ref.line}#${spec.ref.hash}`,
855
915
  currentContent: origLines.join("\n"),
856
916
  });
857
917
  break;
@@ -863,16 +923,22 @@ export function applyHashlineEdits(
863
923
 
864
924
  const count = 1;
865
925
  const origLines = originalFileLines.slice(spec.ref.line - 1, spec.ref.line);
866
- let stripped = stripRangeBoundaryEcho(originalFileLines, spec.ref.line, spec.ref.line, dstLines);
867
- stripped = restoreOldWrappedLines(origLines, stripped);
868
- let newLines = restoreIndentForPairedReplacement(origLines, stripped);
869
- if (origLines.join("\n") === newLines.join("\n") && origLines.some(l => CONFUSABLE_HYPHENS_RE.test(l))) {
926
+ let stripped = autocorrect
927
+ ? stripRangeBoundaryEcho(originalFileLines, spec.ref.line, spec.ref.line, dstLines)
928
+ : dstLines;
929
+ stripped = autocorrect ? restoreOldWrappedLines(origLines, stripped) : stripped;
930
+ let newLines = autocorrect ? restoreIndentForPairedReplacement(origLines, stripped) : stripped;
931
+ if (
932
+ autocorrect &&
933
+ origLines.join("\n") === newLines.join("\n") &&
934
+ origLines.some(l => CONFUSABLE_HYPHENS_RE.test(l))
935
+ ) {
870
936
  newLines = normalizeConfusableHyphensInLines(newLines);
871
937
  }
872
938
  if (origLines.join("\n") === newLines.join("\n")) {
873
939
  noopEdits.push({
874
940
  editIndex: idx,
875
- loc: `${spec.ref.line}:${spec.ref.hash}`,
941
+ loc: `${spec.ref.line}#${spec.ref.hash}`,
876
942
  currentContent: origLines.join("\n"),
877
943
  });
878
944
  break;
@@ -884,16 +950,22 @@ export function applyHashlineEdits(
884
950
  case "range": {
885
951
  const count = spec.end.line - spec.start.line + 1;
886
952
  const origLines = originalFileLines.slice(spec.start.line - 1, spec.start.line - 1 + count);
887
- let stripped = stripRangeBoundaryEcho(originalFileLines, spec.start.line, spec.end.line, dstLines);
888
- stripped = restoreOldWrappedLines(origLines, stripped);
889
- let newLines = restoreIndentForPairedReplacement(origLines, stripped);
890
- if (origLines.join("\n") === newLines.join("\n") && origLines.some(l => CONFUSABLE_HYPHENS_RE.test(l))) {
953
+ let stripped = autocorrect
954
+ ? stripRangeBoundaryEcho(originalFileLines, spec.start.line, spec.end.line, dstLines)
955
+ : dstLines;
956
+ stripped = autocorrect ? restoreOldWrappedLines(origLines, stripped) : stripped;
957
+ let newLines = autocorrect ? restoreIndentForPairedReplacement(origLines, stripped) : stripped;
958
+ if (
959
+ autocorrect &&
960
+ origLines.join("\n") === newLines.join("\n") &&
961
+ origLines.some(l => CONFUSABLE_HYPHENS_RE.test(l))
962
+ ) {
891
963
  newLines = normalizeConfusableHyphensInLines(newLines);
892
964
  }
893
965
  if (origLines.join("\n") === newLines.join("\n")) {
894
966
  noopEdits.push({
895
967
  editIndex: idx,
896
- loc: `${spec.start.line}:${spec.start.hash}`,
968
+ loc: `${spec.start.line}#${spec.start.hash}`,
897
969
  currentContent: origLines.join("\n"),
898
970
  });
899
971
  break;
@@ -904,11 +976,11 @@ export function applyHashlineEdits(
904
976
  }
905
977
  case "insertAfter": {
906
978
  const anchorLine = originalFileLines[spec.after.line - 1];
907
- const inserted = stripInsertAnchorEchoAfter(anchorLine, dstLines);
979
+ const inserted = autocorrect ? stripInsertAnchorEchoAfter(anchorLine, dstLines) : dstLines;
908
980
  if (inserted.length === 0) {
909
981
  noopEdits.push({
910
982
  editIndex: idx,
911
- loc: `${spec.after.line}:${spec.after.hash}`,
983
+ loc: `${spec.after.line}#${spec.after.hash}`,
912
984
  currentContent: originalFileLines[spec.after.line - 1],
913
985
  });
914
986
  break;
@@ -917,23 +989,53 @@ export function applyHashlineEdits(
917
989
  trackFirstChanged(spec.after.line + 1);
918
990
  break;
919
991
  }
992
+ case "insertBefore": {
993
+ const anchorLine = originalFileLines[spec.before.line - 1];
994
+ const inserted = autocorrect ? stripInsertAnchorEchoBefore(anchorLine, dstLines) : dstLines;
995
+ if (inserted.length === 0) {
996
+ noopEdits.push({
997
+ editIndex: idx,
998
+ loc: `${spec.before.line}#${spec.before.hash}`,
999
+ currentContent: originalFileLines[spec.before.line - 1],
1000
+ });
1001
+ break;
1002
+ }
1003
+ fileLines.splice(spec.before.line - 1, 0, ...inserted);
1004
+ trackFirstChanged(spec.before.line);
1005
+ break;
1006
+ }
1007
+ case "insertBetween": {
1008
+ const afterLine = originalFileLines[spec.after.line - 1];
1009
+ const beforeLine = originalFileLines[spec.before.line - 1];
1010
+ const inserted = autocorrect ? stripInsertBoundaryEcho(afterLine, beforeLine, dstLines) : dstLines;
1011
+ if (inserted.length === 0) {
1012
+ noopEdits.push({
1013
+ editIndex: idx,
1014
+ loc: `${spec.after.line}#${spec.after.hash}..${spec.before.line}#${spec.before.hash}`,
1015
+ currentContent: `${afterLine}\n${beforeLine}`,
1016
+ });
1017
+ break;
1018
+ }
1019
+ fileLines.splice(spec.before.line - 1, 0, ...inserted);
1020
+ trackFirstChanged(spec.before.line);
1021
+ break;
1022
+ }
1023
+ case "insertAtEof": {
1024
+ if (fileLines.length === 1 && fileLines[0] === "") {
1025
+ fileLines.splice(0, 1, ...dstLines);
1026
+ trackFirstChanged(1);
1027
+ break;
1028
+ }
1029
+ fileLines.splice(fileLines.length, 0, ...dstLines);
1030
+ trackFirstChanged(fileLines.length - dstLines.length + 1);
1031
+ break;
1032
+ }
920
1033
  }
921
1034
  }
922
1035
 
923
- const warnings: string[] = [];
924
- let diffLineCount = Math.abs(fileLines.length - originalFileLines.length);
925
- for (let i = 0; i < Math.min(fileLines.length, originalFileLines.length); i++) {
926
- if (fileLines[i] !== originalFileLines[i]) diffLineCount++;
927
- }
928
- if (diffLineCount > edits.length * 4) {
929
- warnings.push(
930
- `Edit changed ${diffLineCount} lines across ${edits.length} operations — verify no unintended reformatting.`,
931
- );
932
- }
933
1036
  return {
934
1037
  content: fileLines.join("\n"),
935
1038
  firstChangedLine,
936
- ...(warnings.length > 0 ? { warnings } : {}),
937
1039
  ...(noopEdits.length > 0 ? { noopEdits } : {}),
938
1040
  };
939
1041