@oh-my-pi/hashline 17.1.7 → 17.2.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.
package/src/patcher.ts CHANGED
@@ -25,6 +25,7 @@
25
25
  import * as path from "node:path";
26
26
  import { applyEdits } from "./apply";
27
27
  import { hasBlockEdit, resolveBlockEdits } from "./block";
28
+ import { commitClipboard, forkClipboard, validateClipboardSequence } from "./clipboard";
28
29
  import { computeFileHash, formatHashlineHeader } from "./format";
29
30
  import type { Filesystem, WriteResult } from "./fs";
30
31
  import { isNotFound } from "./fs";
@@ -35,13 +36,14 @@ import {
35
36
  pathRecoveredFromTagMessage,
36
37
  type RevealedLine,
37
38
  unseenLinesMessage,
39
+ writeDriftWarning,
38
40
  } from "./messages";
39
41
  import { MismatchError } from "./mismatch";
40
42
  import { detectLineEnding, type LineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize";
41
43
  import { InvalidAbsoluteRangeError } from "./parser";
42
44
  import { Recovery, type RecoveryResult } from "./recovery";
43
45
  import type { Snapshot, SnapshotStore } from "./snapshots";
44
- import type { ApplyResult, BlockResolution, BlockResolver, BlockSpan, Edit, FileOp } from "./types";
46
+ import type { ApplyResult, BlockResolution, BlockResolver, BlockSpan, Clipboard, Edit, FileOp } from "./types";
45
47
 
46
48
  /**
47
49
  * Upper bound on the number of unseen anchor lines whose actual file content
@@ -80,6 +82,12 @@ export interface PatcherOptions {
80
82
  * validate on content hash alone and any anchor into the tagged content applies.
81
83
  */
82
84
  enforceSeenLines?: boolean;
85
+ /**
86
+ * Host-owned clipboard register shared across batches, so `CUT` content
87
+ * can be `PASTE`d by a later {@link Patcher.apply} call. Each batch works
88
+ * on a fork and publishes it back only after writes land.
89
+ */
90
+ clipboard?: Clipboard;
83
91
  }
84
92
 
85
93
  /** Per-section result returned by {@link Patcher.apply} / {@link Patcher.commit}. */
@@ -98,7 +106,13 @@ export interface PatchSectionResult {
98
106
  persisted: string;
99
107
  /** Final text that the {@link Filesystem} actually wrote (may differ if the FS transformed it). */
100
108
  written: string;
101
- /** 4-hex content-hash tag for `after`. Use to anchor follow-up edits. */
109
+ /**
110
+ * 4-hex content-hash tag. Hashes the content the {@link Filesystem}
111
+ * reports actually landed on disk (see `written`), which normally equals
112
+ * `after` but can diverge when the write path transforms content (e.g. an
113
+ * ACP-bridge write reformatted by the client's format-on-save). Use to
114
+ * anchor follow-up edits.
115
+ */
102
116
  fileHash: string;
103
117
  /** Hashline section header (`[path#tag]`) of the post-edit content. */
104
118
  header: string;
@@ -109,9 +123,8 @@ export interface PatchSectionResult {
109
123
  /** Destination path when this section includes `MV DEST`. */
110
124
  moveDest?: string;
111
125
  /**
112
- * Resolved spans for any `replace_block`/`delete_block` ops, present when the
113
- * apply matched the tagged content. Undefined for patches with no block ops
114
- * (and for resolutions routed through drift recovery, where numbers shift).
126
+ * Resolved spans for block ops, present when the apply matched the tagged
127
+ * content. Undefined for patches with no block ops and for drift recovery.
115
128
  */
116
129
  blockResolutions?: BlockResolution[];
117
130
  }
@@ -151,6 +164,8 @@ function hasAnchorScopedEdit(edits: readonly Edit[]): boolean {
151
164
  if (edit.kind === "delete") return true;
152
165
  // A `replace_block N:` edit anchors to concrete content on line N.
153
166
  if (edit.kind === "block") return true;
167
+ // A `CUT` range reads concrete content.
168
+ if (edit.kind === "cut") return true;
154
169
  return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
155
170
  });
156
171
  }
@@ -204,6 +219,7 @@ export class Patcher {
204
219
  readonly snapshots: SnapshotStore;
205
220
  readonly recovery: Recovery;
206
221
  readonly blockResolver: BlockResolver | undefined;
222
+ readonly clipboard: Clipboard | undefined;
207
223
  readonly #enforceSeenLines: boolean;
208
224
 
209
225
  constructor(options: PatcherOptions) {
@@ -214,6 +230,7 @@ export class Patcher {
214
230
  this.snapshots = options.snapshots;
215
231
  this.recovery = new Recovery(options.snapshots);
216
232
  this.blockResolver = options.blockResolver;
233
+ this.clipboard = options.clipboard;
217
234
  this.#enforceSeenLines = options.enforceSeenLines ?? true;
218
235
  }
219
236
 
@@ -224,16 +241,34 @@ export class Patcher {
224
241
  * {@link PatchSectionResult} per section in the original patch order.
225
242
  */
226
243
  async apply(patch: Patch): Promise<PatcherApplyResult> {
244
+ // One register per batch: `CUT` in one section feeds `PASTE` in a later
245
+ // one, so content can move across files. A host-owned register
246
+ // (see PatcherOptions.clipboard) additionally persists across batches:
247
+ // work on a fork and publish it per landed section, so a failed batch
248
+ // never poisons the persistent register and a mid-batch failure still
249
+ // preserves content already cut from disk.
250
+ const clipboard = forkClipboard(this.clipboard);
251
+
227
252
  // Single-section fast path.
228
253
  if (patch.sections.length === 1) {
229
- const prepared = await this.prepare(patch.sections[0]);
230
- return { sections: [await this.commit(prepared)] };
254
+ const prepared = await this.prepare(patch.sections[0], clipboard);
255
+ const result = await this.commit(prepared);
256
+ if (this.clipboard !== undefined) commitClipboard(clipboard, this.clipboard);
257
+ return { sections: [result] };
231
258
  }
232
259
 
233
260
  // Prepare every section first so any failure (stale hash, missing
234
261
  // file, parse error, in-memory no-op) surfaces before any write.
235
262
  const prepared: PreparedSection[] = [];
236
- for (const section of patch.sections) prepared.push(await this.prepare(section));
263
+ // Register state after each section's prepare. Commits are non-atomic:
264
+ // when a later write fails, the sections before it are already on disk,
265
+ // so the host register must reflect exactly the landed prefix — content
266
+ // a landed CUT deleted would otherwise be lost.
267
+ const sectionStates: Clipboard[] = [];
268
+ for (const section of patch.sections) {
269
+ prepared.push(await this.prepare(section, clipboard));
270
+ sectionStates.push(forkClipboard(clipboard));
271
+ }
237
272
  assertUniqueCanonicalPaths(prepared);
238
273
  for (const entry of prepared) {
239
274
  if (entry.isNoop) {
@@ -259,6 +294,7 @@ export class Patcher {
259
294
  { cause: error },
260
295
  );
261
296
  }
297
+ if (this.clipboard !== undefined) commitClipboard(sectionStates[index], this.clipboard);
262
298
  }
263
299
  return { sections: results };
264
300
  }
@@ -268,8 +304,10 @@ export class Patcher {
268
304
  * No writes hit the filesystem. Use for CI checks and dry runs.
269
305
  */
270
306
  async preflight(patch: Patch): Promise<void> {
307
+ // Dry run: fork the register and never publish it back.
308
+ const clipboard = forkClipboard(this.clipboard);
271
309
  const prepared: PreparedSection[] = [];
272
- for (const section of patch.sections) prepared.push(await this.prepare(section));
310
+ for (const section of patch.sections) prepared.push(await this.prepare(section, clipboard));
273
311
  assertUniqueCanonicalPaths(prepared);
274
312
  for (const entry of prepared) {
275
313
  if (entry.isNoop) {
@@ -303,10 +341,13 @@ export class Patcher {
303
341
  * {@link PreparedSection} which can be fed to {@link commit} to land
304
342
  * the result on the filesystem.
305
343
  *
344
+ * `clipboard` is the register shared by `CUT`/`PASTE` ops. Pass the batch
345
+ * register when preparing several sections so content can move across files.
346
+ *
306
347
  * Throws on parse error, missing-file-for-anchored-edit, or unrecovered
307
348
  * tag mismatch ({@link MismatchError}).
308
349
  */
309
- async prepare(section: PatchSection): Promise<PreparedSection> {
350
+ async prepare(section: PatchSection, clipboard?: Clipboard): Promise<PreparedSection> {
310
351
  const parsed = await this.#parseWithRangeDiagnostics(section);
311
352
  const parseWarnings = [...parsed.warnings];
312
353
  const fileOp = parsed.fileOp;
@@ -353,6 +394,7 @@ export class Patcher {
353
394
  const lineEnding = detectLineEnding(text);
354
395
  const normalized = normalizeToLF(text);
355
396
 
397
+ const register = clipboard ?? {};
356
398
  const applyResult =
357
399
  fileOp?.kind === "rem"
358
400
  ? this.#applyWithRecovery({
@@ -361,6 +403,7 @@ export class Patcher {
361
403
  exists: read.exists,
362
404
  normalized,
363
405
  edits: [],
406
+ clipboard: register,
364
407
  })
365
408
  : this.#applyWithRecovery({
366
409
  section: target,
@@ -368,6 +411,7 @@ export class Patcher {
368
411
  exists: read.exists,
369
412
  normalized,
370
413
  edits: parsed.edits,
414
+ clipboard: register,
371
415
  });
372
416
 
373
417
  return new PreparedSection(
@@ -488,9 +532,32 @@ export class Patcher {
488
532
  }
489
533
 
490
534
  const write: WriteResult = await this.fs.writeText(section.path, persisted);
491
- const fileHash = this.#recordFullSnapshot(canonicalPath, after);
492
535
  const op = exists ? "update" : "create";
493
536
 
537
+ // `write.text` is the FS adapter's report of what actually landed on
538
+ // disk (see `WriteResult`), which for an ACP-bridge write can diverge
539
+ // from `after` when the client transforms content on save (e.g.
540
+ // format-on-save reformatting indentation the tool never touched).
541
+ // Keying the snapshot on `after` unconditionally would record a hash
542
+ // for content that no longer exists on disk: the next `read` sees the
543
+ // drifted file, tag validation misses, and hunk resolution proceeds
544
+ // against a baseline the file has already left — the mechanism behind
545
+ // "single-line edit reformats the whole file". Re-derive the recorded
546
+ // text from what was actually persisted and hash THAT.
547
+ //
548
+ // Deliberately does NOT touch `after` (or the diff/`newText` derived
549
+ // from it downstream): `after` stays the content this section asked
550
+ // for, so the model-visible diff stays scoped to the intended hunk
551
+ // instead of ballooning to a whole-file diff against a formatter's
552
+ // output on every drifted write. The drift itself is a warning, not a
553
+ // diff — an O(1) signal instead of an O(file-size) one. Comparing the
554
+ // normalized forms (rather than raw `write.text`/`persisted`) avoids a
555
+ // false "drift" purely from BOM/line-ending restoration asymmetry.
556
+ const recorded = normalizeToLF(stripBom(write.text).text);
557
+ const driftedOnWrite = recorded !== after;
558
+ const fileHash = this.#recordFullSnapshot(canonicalPath, recorded);
559
+ const allWarnings = driftedOnWrite ? [...warnings, writeDriftWarning(section.path)] : warnings;
560
+
494
561
  return {
495
562
  path: section.path,
496
563
  canonicalPath,
@@ -503,7 +570,7 @@ export class Patcher {
503
570
  header: formatHashlineHeader(section.path, fileHash),
504
571
  firstChangedLine: applyResult.firstChangedLine,
505
572
  blockResolutions: applyResult.blockResolutions,
506
- warnings,
573
+ warnings: allWarnings,
507
574
  };
508
575
  }
509
576
 
@@ -609,8 +676,9 @@ export class Patcher {
609
676
  exists: boolean;
610
677
  normalized: string;
611
678
  edits: readonly Edit[];
679
+ clipboard: Clipboard;
612
680
  }): ApplyResult {
613
- const { section, canonicalPath, exists, normalized, edits } = args;
681
+ const { section, canonicalPath, exists, normalized, edits, clipboard } = args;
614
682
  const expected = exists ? section.fileHash : undefined;
615
683
  // The 4-hex tag is content-derived: when the live text hashes to it,
616
684
  // trust the match and apply directly. `storedSnapshotForTag` feeds the
@@ -643,6 +711,11 @@ export class Patcher {
643
711
  onWarning: warning => resolveWarnings.push(warning),
644
712
  });
645
713
  }
714
+ // Surface clipboard sequencing mistakes (`PASTE` before any capture,
715
+ // capturing over un-pasted `CUT` content) with their targeted message
716
+ // before the recovery path below, which swallows apply failures and
717
+ // re-surfaces them as tag-mismatch errors.
718
+ validateClipboardSequence(resolved, clipboard);
646
719
  const withResolveWarnings = (result: ApplyResult): ApplyResult =>
647
720
  resolveWarnings.length === 0
648
721
  ? result
@@ -659,7 +732,7 @@ export class Patcher {
659
732
  if (expected !== undefined && this.#enforceSeenLines) {
660
733
  this.#assertSeenLines(section, expected, matchedSnapshot);
661
734
  }
662
- const result = applyEdits(normalized, resolved);
735
+ const result = applyEdits(normalized, resolved, { clipboard });
663
736
  return withResolveWarnings(blockResolutions.length > 0 ? { ...result, blockResolutions } : result);
664
737
  }
665
738
  // Head/tail-only inserts are position-stable: "start"/"end" cannot move
@@ -667,7 +740,7 @@ export class Patcher {
667
740
  // content and warn instead of hard-failing — unlike an anchored
668
741
  // mismatch, which cannot be safely relocated and must reject.
669
742
  if (!hasAnchorScopedEdit(resolved)) {
670
- const result = applyEdits(normalized, resolved);
743
+ const result = applyEdits(normalized, resolved, { clipboard });
671
744
  return withResolveWarnings({ ...result, warnings: [HEADTAIL_DRIFT_WARNING, ...(result.warnings ?? [])] });
672
745
  }
673
746
  // File drifted: map every anchor from the tagged snapshot to unchanged
@@ -677,6 +750,7 @@ export class Patcher {
677
750
  currentText: normalized,
678
751
  fileHash: expected,
679
752
  edits: resolved,
753
+ clipboard,
680
754
  });
681
755
  if (recovered) return withResolveWarnings(recoveryToApplyResult(recovered));
682
756
  const hashRecognized = this.snapshots.byHash(canonicalPath, expected) !== null;
package/src/prompt.md CHANGED
@@ -1,49 +1,43 @@
1
- Your patch language names lines to replace, delete, or insert at, then lists the new content. Rule of thumb: a header ending in `:` is followed by `+` body rows; `DEL` has no body.
1
+ Line-anchored patch language: name original lines to replace, cut, or insert at, then list new content. A header ending in `:` takes `+` body rows; `CUT`, `PASTE`, `REM`, `MV` take none.
2
2
 
3
3
  <headers>
4
- Every file section starts with `[PATH#TAG]`. `TAG` = 4-hex snapshot tag from your latest `read`/`search`, REQUIRED on every section — no hashless form. Create new files with `write`; hashline only edits existing files.
4
+ Every file section starts `[PATH#TAG]`. `TAG` = 4-hex snapshot tag from your latest `read`/`search` REQUIRED on every section. Create new files with `write`; hashline only edits existing files.
5
5
  </headers>
6
6
 
7
7
  <ops>
8
- `SWAP N.=M:` — replace original lines N.=M with the body rows below. INCLUSIVE — line M is consumed too.
9
- `SWAP.BLK N:` — replace the whole syntactic block that BEGINS on line N; tree-sitter resolves the closing line. Body rows below.
10
- `DEL N.=M` — delete original lines N.=M. No body.
11
- `DEL.BLK N` delete the whole syntactic block that BEGINS on line N.
12
- `INS.PRE N:` — insert the body rows immediately before line N.
13
- `INS.POST N:` — insert the body rows immediately after line N.
14
- `INS.BLK.POST N:` insert the body rows after the END of the block that BEGINS on line N — outside it, at sibling depth. To append inside a block, use `INS.POST`.
15
- `INS.HEAD:` / `INS.TAIL:`insert the body rows at the very start / end of the file.
16
- `REM` delete the whole file named by the section header. No body, no line ops.
17
- `MV DEST` — move/rename the section file to `DEST` (a path, quoted when it contains spaces). Line edits above `MV` land on the source first, then the final content is written at `DEST`.
18
- Single line: `SWAP N.=N:` / `DEL N`. The range is the ORIGINAL lines you touch; body length is irrelevant (replacing 1 line with 10 is still `SWAP N.=N:`).
8
+ `SWAP N.=M:` — replace original lines N.=M (INCLUSIVE).
9
+ `SWAP.BLK N:` — replace the whole syntactic block BEGINNING on line N; its closing line is resolved for you.
10
+ `CUT N.=M` / `CUT.BLK N` — delete lines N.=M / the block beginning at N, and capture them for `PASTE`.
11
+ `INS.PRE N:` / `INS.POST N:` insert immediately before / after line N.
12
+ `INS.BLK.POST N:` — insert after the END of the block beginning at N, outside it at sibling depth. Append inside a block → `INS.POST`.
13
+ `INS.HEAD:` / `INS.TAIL:` — insert at the very start / end of the file.
14
+ `PASTE.PRE N` / `PASTE.POST N` / `PASTE.HEAD` / `PASTE.TAIL` / `PASTE.BLK.POST N`insert the clipboard at the position (the clipboard IS the body).
15
+ `REM` — delete the whole section file. `MV DEST` — move/rename to `DEST` (quote paths with spaces); edits above `MV` land on the source first, final content written at `DEST`.
16
+ Single line: `SWAP N.=N:` / `CUT N`. Range = ORIGINAL lines touched; body length irrelevant (1 line → 10 is still `SWAP N.=N:`).
19
17
  </ops>
20
18
 
21
19
  <body-rows>
22
- Body rows appear only under a `:` header. Every body row is `+TEXT` — add a literal line `TEXT`, verbatim (leading whitespace kept); `+` alone adds a blank line. No other row kind. NEVER write `-old` or a bare/context line. To keep a line, leave it out of every range. Literal lines starting with `-`/`+` still need the body prefix: Markdown `- item` → `+- item`, `+ item` → `++ item`.
20
+ Only under a `:` header. Every row is `+TEXT`, verbatim (leading whitespace kept); `+` alone = blank line. NEVER `-old` or bare/context rows — the range deletes; the body is only the final content. Keep a line: leave it out of every range. Literal leading `-`/`+` keeps the prefix: `- item` → `+- item`, `+ item` → `++ item`.
23
21
  </body-rows>
24
22
 
25
23
  <rules>
26
- - Line numbers + `[PATH#TAG]` header come from your latest `read`/`search` (`LINE:TEXT` rows).
27
- - Numbers refer to the ORIGINAL file; never shift as hunks apply.
28
- - They die with the call: every applied edit mints a fresh `#TAG` and renumbers anchor the next edit on the edit response or a fresh `read`.
29
- - Touch only lines your latest `read`/`search` literally displayed as `LINE:TEXT`; the tag certifies the snapshot, not your memory. A hunk anchored on a line you never displayed is REJECTED — re-`read` first. Seeing a line ≠ it holds the code you mean; confirm numbers map to the construct you intend, especially far from your read window.
30
- - Elided regions are UNSEEN: `…`/`..` markers and a collapsed `N-M:` summary row (only boundary lines N and M shown) hide their interior. NEVER place or span a hunk inside one — `read` the range first.
31
- - Never start or end a range mid-expression or mid-block.
32
- - Indent body rows exactly for the depth they should live at.
33
- - On a stale-tag rejection or any surprising result: STOP and re-`read` before further edits.
34
- - One hunk per range; body = final content, never an old/new pair.
35
- - Ranges cover ONLY lines whose content changes. Never widen over unchanged lines a stale wide range shreds everything it spans.
36
- - Whole construct → `SWAP.BLK N` (tree-sitter resolves the end); lines inside it → `SWAP N.=M`.
37
- - `SWAP.BLK N` resolves EXACTLY the node at N. Leading decorators/attributes/doc-comments are separate nodes: point N at the FIRST decorator to sweep both; standalone line-comments are never swept use `SWAP N.=M`.
38
- - Block ops (`SWAP.BLK`/`DEL.BLK`/`INS.BLK.POST`) anchor the OPENING line of a MULTI-LINE construct — never its closer, last line, or a bare inner statement. Anchoring one statement resolves to ONE line and is REJECTED: use the plain op (`SWAP N.=N` / `DEL N` / `INS.POST N`), or point N at the real opener. Saw the closer? Use plain `INS.POST M:`.
39
- - Markdown: a heading line IS a block opener — `SWAP.BLK`/`DEL.BLK`/`INS.BLK.POST` on a `##`/`###` heading resolves its WHOLE section (heading through every nested deeper heading, up to the next same-or-higher heading). So `DEL.BLK` drops the section, `SWAP.BLK` rewrites it, `INS.BLK.POST` lands after it (end the inserted body with a blank line to keep the next heading separated).
40
- - Non-adjacent changes = separate hunks; untouched lines stay out of every range.
41
- - Pure additions use `INS.PRE` / `INS.POST` / `INS.HEAD` / `INS.TAIL`, never a widened `SWAP` — retyped keepers are exactly what gets dropped. (A multi-line `SWAP` whose body restates the line just past the range is auto-dropped as an off-by-one keeper with a warning — issue the payload for the range only; never lean on the repair.)
42
- - NEVER format/restyle code with this tool; run the project formatter instead.
24
+ - Line numbers + `#TAG` come from your latest `read`/`search` (`LINE:TEXT` rows); numbers name ORIGINAL lines, never shifted by applied hunks.
25
+ - Applied edits renumber the file and change the `#TAG` — take the next edit's numbers from the edit response or a fresh `read`.
26
+ - Touch only displayed lines hunks on undisplayed lines are REJECTED. Far from your read window? Re-`read`; confirm numbers map to the intended construct.
27
+ - Elided regions are UNSEEN (`…`/`..` markers, collapsed `N-M:` summary rows) NEVER place or span a hunk inside one; `read` the range first.
28
+ - NEVER start or end a range mid-expression or mid-block.
29
+ - Ranges cover ONLY changed lines never widen over keepers. Non-adjacent changes = separate hunks.
30
+ - Whole construct `SWAP.BLK N`; lines inside one `SWAP N.=M`.
31
+ - `SWAP.BLK` resolves EXACTLY the node at N: leading decorators/attributes/doc-comments are separate nodes point N at the FIRST decorator to sweep both; standalone line-comments are never swept (use `SWAP N.=M`).
32
+ - Block ops anchor the OPENING line of a MULTI-LINE construct — never the closer, last line, or a bare inner statement; one statement → plain op (`SWAP N.=N:` / `CUT N` / `INS.POST N:`). Saw the closer? `INS.POST M:`.
33
+ - Markdown: a heading IS a block opener — block ops on `##`/`###` resolve the WHOLE section (through deeper nested headings, up to the next same-or-higher heading). `INS.BLK.POST` after a section: end the body with a blank line to keep the next heading separated.
34
+ - Pure additions → `INS.PRE`/`INS.POST`/`INS.HEAD`/`INS.TAIL`, never a widened `SWAP`.
35
+ - Move code with `CUT`+`PASTE`, never retype. Clipboard: top-to-bottom across the whole patch (cross-file moves), persists across edit calls, latest `CUT` wins, and `PASTE` repeats freely. Pasted indentation is verbatim; re-indent via `SWAP`.
36
+ - NEVER format/restyle code with this tool; run the project formatter.
43
37
  </rules>
44
38
 
45
39
  <example>
46
- Original (the exact shape `read` returns):
40
+ `read` output shape:
47
41
  ```
48
42
  [greet.py#A1B2]
49
43
  1:def greet(name):
@@ -52,40 +46,7 @@ Original (the exact shape `read` returns):
52
46
  4:greet("world")
53
47
  ```
54
48
 
55
- Insert a guard after line 1:
56
- ```
57
- [greet.py#A1B2]
58
- INS.POST 1:
59
- + if not name: name = "stranger"
60
- ```
61
-
62
- Replace line 2 with two lines:
63
- ```
64
- [greet.py#A1B2]
65
- SWAP 2.=2:
66
- + greeting = "Hi"
67
- + msg = f"{greeting}, {name}"
68
- ```
69
-
70
- Delete line 3:
71
- ```
72
- [greet.py#A1B2]
73
- DEL 3
74
- ```
75
-
76
- Delete the whole file:
77
- ```
78
- [greet.py#A1B2]
79
- REM
80
- ```
81
-
82
- Rename or move the file:
83
- ```
84
- [greet.py#A1B2]
85
- MV greet_v2.py
86
- ```
87
-
88
- Move after editing:
49
+ Edit, then move:
89
50
  ```
90
51
  [greet.py#A1B2]
91
52
  SWAP 1.=3:
@@ -94,16 +55,7 @@ SWAP 1.=3:
94
55
  MV lib/greet.py
95
56
  ```
96
57
 
97
- Add a header and trailer:
98
- ```
99
- [greet.py#A1B2]
100
- INS.HEAD:
101
- +# generated header
102
- INS.TAIL:
103
- +greet("everyone")
104
- ```
105
-
106
- Insert Markdown bullets — the leading `+` is the body-row marker; the file receives `- task`:
58
+ Markdown bullets the file receives `- task`:
107
59
  ```
108
60
  [PLAN.md#A1B2]
109
61
  INS.POST 2:
@@ -111,7 +63,15 @@ INS.POST 2:
111
63
  + - nested task
112
64
  ```
113
65
 
114
- Replace the whole `greet` function block `SWAP.BLK 1:` resolves lines 1–3 (the `def` header through `print(msg)`); line 4 is a separate statement and stays:
66
+ Move `greet` to a sibling file clipboard flows across sections:
67
+ ```
68
+ [greet.py#A1B2]
69
+ CUT.BLK 1
70
+ [other.py#3C4D]
71
+ PASTE.HEAD
72
+ ```
73
+
74
+ `SWAP.BLK 1:` resolves lines 1–3 (`def` header through `print(msg)`); line 4 is a separate statement and stays:
115
75
  ```
116
76
  [greet.py#A1B2]
117
77
  SWAP.BLK 1:
@@ -119,7 +79,7 @@ SWAP.BLK 1:
119
79
  + print(f"Hello, {name}")
120
80
  ```
121
81
 
122
- A decorator/doc-comment is a SEPARATE block — `SWAP.BLK` on the `def`/`fn` line keeps it. Point N at the decorator to take both; here line 1 is `@cache`, so anchoring on the `def` (line 2) would orphan `@cache`:
82
+ Decorator/doc-comment = SEPARATE block — point N at the decorator to take both; anchoring the `def` (line 2) would orphan `@cache`:
123
83
  ```
124
84
  [svc.py#C3D4]
125
85
  SWAP.BLK 1:
@@ -130,14 +90,14 @@ SWAP.BLK 1:
130
90
  </example>
131
91
 
132
92
  <anti-patterns>
133
- # WRONG — empty `SWAP` to delete. RIGHT: DEL 4
93
+ # WRONG — empty `SWAP` to delete. RIGHT: CUT 4
134
94
  SWAP 4.=4:
135
95
 
136
- # WRONG — range describes post-edit size. RIGHT: SWAP 1.=1: (body length is irrelevant)
96
+ # WRONG — range sized to the post-edit content. RIGHT: SWAP 1.=1: (body length irrelevant)
137
97
  SWAP 1.=2:
138
98
  +def greet(name):
139
99
 
140
- # WRONG — `-` rows / bare context lines do not exist. The range deletes; the body is only the new content.
100
+ # WRONG — `-` rows / bare context lines do not exist; the range deletes, the body is only new content.
141
101
  SWAP 3.=3:
142
102
  msg = "Hello, " + name
143
103
  - print(msg)
@@ -146,27 +106,29 @@ SWAP 3.=3:
146
106
  SWAP 3.=3:
147
107
  + return msg
148
108
 
149
- # WRONG — a pure insertion done as a widened `SWAP`: you want to add one line after 2,
150
- # but you replace 2.=4, retype the keepers, and drop one (here line 4, `greet("world")`).
109
+ # WRONG — pure insertion as a widened `SWAP`: retyped keepers get dropped (here line 4).
151
110
  SWAP 2.=4:
152
111
  + msg = "Hello, " + name
153
112
  + extra = compute(name)
154
113
  + print(msg)
155
- # RIGHT — touch nothing you keep; the new line is the whole body.
114
+ # RIGHT — touch nothing you keep.
156
115
  INS.POST 2:
157
116
  + extra = compute(name)
158
117
 
159
- # WRONG — `INS.BLK.POST N:` anchored on a closing delimiter / last visible line. RIGHT: plain `INS.POST M:`
118
+ # WRONG — `INS.BLK.POST` anchored on the closing delimiter / last visible line. RIGHT: plain `INS.POST M:`
160
119
  INS.BLK.POST 3:
161
120
  +after()
162
121
  # RIGHT
163
122
  INS.POST 3:
164
123
  +after()
124
+
125
+ # WRONG — body rows under PASTE; the clipboard is the body. RIGHT: capture first, then a bodyless `PASTE.POST 20`.
126
+ PASTE.POST 20:
127
+ +function f() {}
165
128
  </anti-patterns>
166
129
 
167
130
  <critical>
168
- If you remember nothing else:
169
- 1. RE-GROUND AFTER EVERY EDIT. Every apply mints a fresh `#TAG` and renumbers take the next edit's numbers from the edit response or a fresh `read`. Stale tag or surprise? STOP, re-`read`.
170
- 2. RANGES ARE TIGHT. Cover only lines that change; a stale wide range shreds everything it spans. Whole construct → `SWAP.BLK N`.
171
- 3. THE BODY IS THE FINAL CONTENT. Every body row starts with `+`; Markdown bullets use `+- item`, not `- item`.
131
+ 1. RE-GROUND AFTER EVERY EDIT — applied edits renumber the file and change the `#TAG`; take next numbers from the edit response or a fresh `read`. Stale tag or surprise? STOP, re-`read`.
132
+ 2. RANGES ARE TIGHTcover only lines that change. Whole construct `SWAP.BLK N`.
133
+ 3. BODY = FINAL CONTENT every body row starts with `+`; Markdown bullets use `+- item`, not `- item`.
172
134
  </critical>
package/src/recovery.ts CHANGED
@@ -10,13 +10,15 @@ import { diffLineRuns } from "@oh-my-pi/pi-natives";
10
10
  import { applyEdits } from "./apply";
11
11
  import { RECOVERY_EXTERNAL_WARNING, RECOVERY_LINE_REMAP_WARNING, RECOVERY_SESSION_CHAIN_WARNING } from "./messages";
12
12
  import type { SnapshotStore } from "./snapshots";
13
- import type { Anchor, ApplyResult, Edit } from "./types";
13
+ import type { Anchor, ApplyResult, Clipboard, Edit } from "./types";
14
14
 
15
15
  export interface RecoveryArgs {
16
16
  path: string;
17
17
  currentText: string;
18
18
  fileHash: string;
19
19
  edits: readonly Edit[];
20
+ /** Shared clipboard register for `cut`/`paste` edits, threaded into the replay apply. */
21
+ clipboard?: Clipboard;
20
22
  }
21
23
 
22
24
  export interface RecoveryResult {
@@ -41,6 +43,12 @@ function getEditAnchors(edit: Edit): Anchor[] {
41
43
  // Recovery only ever receives already-resolved edits (no `block`); this arm
42
44
  // exists for type-exhaustiveness over the full `Edit` union.
43
45
  if (edit.kind === "block") return [edit.anchor];
46
+ if (edit.kind === "cut") {
47
+ // Every captured line is an anchor: changed interior content is unsafe.
48
+ const anchors: Anchor[] = [];
49
+ for (let line = edit.range.start.line; line <= edit.range.end.line; line++) anchors.push({ line });
50
+ return anchors;
51
+ }
44
52
  return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor" ? [edit.cursor.anchor] : [];
45
53
  }
46
54
 
@@ -213,6 +221,21 @@ function remapEditsToCurrent(previousText: string, currentText: string, edits: r
213
221
  remapped.push({ ...edit, anchor });
214
222
  continue;
215
223
  }
224
+ if (edit.kind === "cut") {
225
+ // Map every captured line; an unmapped interior line means the
226
+ // content drifted and cannot be moved safely. Uniform offsets keep
227
+ // the mapped range contiguous.
228
+ const start = mapLine(edit.range.start.line);
229
+ if (start === null) return null;
230
+ let end = start;
231
+ for (let line = edit.range.start.line + 1; line <= edit.range.end.line; line++) {
232
+ const mapped = mapLine(line);
233
+ if (mapped === null) return null;
234
+ end = mapped;
235
+ }
236
+ remapped.push({ ...edit, range: { start: { line: start }, end: { line: end } } });
237
+ continue;
238
+ }
216
239
 
217
240
  let blockStart = edit.blockStart;
218
241
  if (blockStart !== undefined) {
@@ -243,12 +266,13 @@ function replayRemappedAnchorsOnCurrent(
243
266
  currentText: string,
244
267
  edits: readonly Edit[],
245
268
  recoveryWarning: string,
269
+ clipboard: Clipboard | undefined,
246
270
  ): RecoveryResult | null {
247
271
  const remapped = remapEditsToCurrent(previousText, currentText, edits);
248
272
  if (remapped === null) return null;
249
273
  let applied: ApplyResult;
250
274
  try {
251
- applied = applyEdits(currentText, remapped.edits);
275
+ applied = applyEdits(currentText, remapped.edits, clipboard === undefined ? {} : { clipboard });
252
276
  } catch {
253
277
  return null;
254
278
  }
@@ -276,13 +300,13 @@ export class Recovery {
276
300
  * caller should then surface a {@link MismatchError}.
277
301
  */
278
302
  tryRecover(args: RecoveryArgs): RecoveryResult | null {
279
- const { path, currentText, fileHash, edits } = args;
303
+ const { path, currentText, fileHash, edits, clipboard } = args;
280
304
  // When retained texts collide on the 16-bit tag, use the latest one.
281
305
  // Recovery still requires its anchors and context to map unambiguously.
282
306
  const snapshot = this.store.byHash(path, fileHash);
283
307
  if (!snapshot) return null;
284
308
  const recoveryWarning =
285
309
  this.store.head(path) === snapshot ? RECOVERY_EXTERNAL_WARNING : RECOVERY_SESSION_CHAIN_WARNING;
286
- return replayRemappedAnchorsOnCurrent(snapshot.text, currentText, edits, recoveryWarning);
310
+ return replayRemappedAnchorsOnCurrent(snapshot.text, currentText, edits, recoveryWarning, clipboard);
287
311
  }
288
312
  }