@oh-my-pi/hashline 15.13.1 → 15.13.2

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/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.13.2] - 2026-06-15
6
+
7
+ ### Breaking Changes
8
+
9
+ - Renamed all hashline DSL operators to concise abbreviated keywords:
10
+ - `replace` -> `SWAP`
11
+ - `delete` -> `DEL`
12
+ - `insert before`/`after`/`head`/`tail` -> `INS.PRE`/`POST`/`HEAD`/`TAIL`
13
+ - `replace_block` -> `SWAP.BLK`
14
+ - `delete_block` -> `DEL.BLK`
15
+ - `insert_after_block` -> `INS.BLK.POST`
16
+
5
17
  ## [15.13.1] - 2026-06-15
6
18
 
7
19
  ### Breaking Changes
package/README.md CHANGED
@@ -26,7 +26,7 @@ await fs.writeText("hello.ts", before);
26
26
  const tag = snapshots.record("hello.ts", before);
27
27
  const patcher = new Patcher({ fs, snapshots });
28
28
  const patch = Patch.parse(String.raw`[hello.ts#${tag}]
29
- replace 1..1:
29
+ SWAP 1..1:
30
30
  +const greeting = "hello";`);
31
31
  const result = await patcher.apply(patch);
32
32
 
@@ -47,11 +47,11 @@ still matches the recorded content hash, and refusing or attempting
47
47
  session-aware recovery on mismatch.
48
48
 
49
49
  Inside a section:
50
- - `replace A..B:` — replace lines A..B with following `+TEXT` body rows.
51
- - `replace block A:` — replace the syntactic block beginning on line A.
52
- - `delete A..B` / `delete block A` — delete concrete lines or a resolved block.
53
- - `insert before A:` / `insert after A:` / `insert head:` / `insert tail:` — insert following body rows.
54
- - `insert after block A:` — insert following body rows after the resolved block's last line.
50
+ - `SWAP A..B:` — replace lines A..B with following `+TEXT` body rows.
51
+ - `SWAP.BLK A:` — replace the syntactic block beginning on line A.
52
+ - `DEL A..B` / `DEL.BLK A` — delete concrete lines or a resolved block.
53
+ - `INS.PRE A:` / `INS.POST A:` / `INS.HEAD:` / `INS.TAIL:` — insert following body rows.
54
+ - `INS.BLK.POST A:` — insert following body rows after the resolved block's last line.
55
55
  - `+TEXT` — literal body row (use `+` alone for a blank line).
56
56
 
57
57
  ## Abstractions
@@ -6,7 +6,7 @@ export interface ResolveBlockEditsOptions {
6
6
  * `blockUnresolvedMessage` error — used by the authoritative apply + final
7
7
  * preview paths. `"drop"` silently skips the edit — used by the streaming
8
8
  * preview, where a half-written file or transient parse error must not
9
- * throw. Unresolvable `insert after block N:` edits never reach this: they
9
+ * throw. Unresolvable `insert_after_block N:` edits never reach this: they
10
10
  * are lowered to plain `insert after N:` with a warning.
11
11
  */
12
12
  onUnresolved?: "throw" | "drop";
@@ -19,7 +19,7 @@ export interface ResolveBlockEditsOptions {
19
19
  onResolved?: (resolution: BlockResolution) => void;
20
20
  /**
21
21
  * Invoked once per diagnostic produced while resolving — currently the
22
- * `insert after block N:` lowerings (closer anchor or unresolvable block).
22
+ * `insert_after_block N:` lowerings (closer anchor or unresolvable block).
23
23
  * Hosts should surface these on the apply result's `warnings`.
24
24
  */
25
25
  onWarning?: (message: string) => void;
@@ -10,22 +10,25 @@ export declare const HL_FILE_SUFFIX = "]";
10
10
  /** Payload sigil for literal body rows. */
11
11
  export declare const HL_PAYLOAD_REPLACE = "+";
12
12
  /** Hunk-header keyword for concrete line replacement. */
13
- export declare const HL_REPLACE_KEYWORD = "replace";
14
- /** Hunk-header sub-keyword: `replace block N:` resolves N to a tree-sitter block range. */
15
- export declare const HL_BLOCK_KEYWORD = "block";
13
+ export declare const HL_REPLACE_KEYWORD = "SWAP";
16
14
  /** Hunk-header keyword for concrete line deletion. */
17
- export declare const HL_DELETE_KEYWORD = "delete";
15
+ export declare const HL_DELETE_KEYWORD = "DEL";
18
16
  /** Hunk-header keyword for insertion operations. */
19
- export declare const HL_INSERT_KEYWORD = "insert";
17
+ export declare const HL_INSERT_KEYWORD = "INS";
20
18
  /** Insert position keyword for inserting before a concrete line. */
21
- export declare const HL_INSERT_BEFORE = "before";
19
+ export declare const HL_INSERT_BEFORE = "PRE";
22
20
  /** Insert position keyword for inserting after a concrete line. */
23
- export declare const HL_INSERT_AFTER = "after";
21
+ export declare const HL_INSERT_AFTER = "POST";
24
22
  /** Insert position keyword for inserting at the start of the file. */
25
- export declare const HL_INSERT_HEAD = "head";
23
+ export declare const HL_INSERT_HEAD = "HEAD";
26
24
  /** Insert position keyword for inserting at the end of the file. */
27
- export declare const HL_INSERT_TAIL = "tail";
28
- /** Hunk-header terminator for body-bearing operations. */
25
+ export declare const HL_INSERT_TAIL = "TAIL";
26
+ /** Hunk-header keyword: `SWAP.BLK N:` resolves N to a tree-sitter block range and replaces its span. */
27
+ export declare const HL_REPLACE_BLOCK_KEYWORD = "SWAP.BLK";
28
+ /** Hunk-header keyword: `DEL.BLK N` resolves N to a tree-sitter block range and deletes its span. */
29
+ export declare const HL_DELETE_BLOCK_KEYWORD = "DEL.BLK";
30
+ /** Hunk-header keyword: `INS.BLK.POST N:` inserts after the last line of the tree-sitter block at N. */
31
+ export declare const HL_INSERT_AFTER_BLOCK_KEYWORD = "INS.BLK.POST";
29
32
  export declare const HL_HEADER_COLON = ":";
30
33
  /** Separator between a hashline file path and its opaque snapshot tag. */
31
34
  export declare const HL_FILE_HASH_SEP = "#";
@@ -50,7 +50,7 @@ export declare class PatchSection {
50
50
  * method directly when you've already validated the file content and
51
51
  * just want the result.
52
52
  *
53
- * `blockResolver` resolves any `replace block N:` edits against `text`; an
53
+ * `blockResolver` resolves any `replace_block N:` edits against `text`; an
54
54
  * unresolvable block throws (this is the final, authoritative preview path).
55
55
  */
56
56
  applyTo(text: string, blockResolver?: BlockResolver): ApplyResult;
@@ -61,7 +61,7 @@ export declare class PatchSection {
61
61
  * empty-payload edit. Intended for incremental diff previews; the writer
62
62
  * path should always use {@link applyTo}.
63
63
  *
64
- * `blockResolver` resolves any `replace block N:` edits against `text`; an
64
+ * `blockResolver` resolves any `replace_block N:` edits against `text`; an
65
65
  * unresolvable block is silently dropped so a half-written file does not
66
66
  * throw mid-stream.
67
67
  */
@@ -17,51 +17,49 @@ export declare const END_PATCH_MARKER = "*** End Patch";
17
17
  */
18
18
  export declare const ABORT_MARKER = "*** Abort";
19
19
  /** Two consecutive hunks targeted the exact same concrete range. */
20
- export declare const REPLACE_PAIR_COALESCED_WARNING = "Two hunks targeted the same range; kept only the second. One `replace N..M:` hunk per range \u2014 the body is the final content, never old+new.";
21
- /** Bare bodyless hunk followed by an overlapping concrete hunk. */
22
- export declare const REPLACE_PAIR_COALESCED_OVERLAP_WARNING = "Dropped a bare hunk overlapped by the concrete hunk after it. One `replace N..M:` hunk per range \u2014 the body is the final content, never old+new.";
20
+ export declare const REPLACE_PAIR_COALESCED_WARNING = "Two hunks targeted the same range; kept only the second. One `SWAP N..M:` hunk per range \u2014 the body is the final content, never old+new.";
23
21
  /** Bare body rows auto-converted to literal `+` rows. */
24
22
  export declare const BARE_BODY_AUTO_PIPED_WARNING = "Auto-prefixed bare body row(s) with `+`. Body rows must be `+TEXT` literal lines.";
25
23
  /** Unified-diff-style `-` row in a hunk body. */
26
24
  export declare const MINUS_ROW_REJECTED = "`-` rows are not valid; the range already names the lines being changed. For a literal `-` line, write `+-\u2026`.";
27
25
  /** Replace hunk with no body. */
28
- export declare const EMPTY_REPLACE = "`replace N..M:` needs at least one `+TEXT` body row. To delete lines, use `delete N..M`.";
29
- /** `replace block N:` hunk with no body. */
30
- export declare const EMPTY_BLOCK = "`replace block N:` needs at least one `+TEXT` body row. To delete a block, use `delete block N`.";
26
+ export declare const EMPTY_REPLACE = "`SWAP N..M:` needs at least one `+TEXT` body row. To delete lines, use `DEL N..M`.";
27
+ /** `replace_block N:` hunk with no body. */
28
+ export declare const EMPTY_BLOCK = "`SWAP.BLK N:` needs at least one `+TEXT` body row. To delete a block, use `DEL.BLK N`.";
31
29
  /**
32
30
  * Block-anchored replace/delete could not resolve to a syntactic block
33
31
  * (unsupported language, blank/out-of-range line, no node beginning on N, or
34
32
  * parse error). Appends a {@link formatAnchoredContext} preview when
35
- * `fileLines` is given. `insert after block N:` never reaches this — it is
33
+ * `fileLines` is given. `insert_after_block N:` never reaches this — it is
36
34
  * lowered to plain `insert after N:` instead (see
37
35
  * {@link insertAfterBlockUnresolvedLoweredWarning}).
38
36
  */
39
37
  export declare function blockUnresolvedMessage(line: number, op?: "replace" | "delete", fileLines?: readonly string[]): string;
40
38
  /** Block-anchored edit reached a path with no {@link BlockResolver} wired in — a host-configuration bug. */
41
- export declare const BLOCK_RESOLVER_UNAVAILABLE = "`replace block`/`delete block`/`insert after block` are not available here (no block resolver configured). Use a concrete line range.";
39
+ export declare const BLOCK_RESOLVER_UNAVAILABLE = "`SWAP.BLK`/`DEL.BLK`/`INS.BLK.POST` are not available here (no block resolver configured). Use a concrete line range.";
42
40
  /**
43
- * `insert after block N:` anchored on a closing-delimiter line, lowered to
41
+ * `insert_after_block N:` anchored on a closing-delimiter line, lowered to
44
42
  * plain `insert after N:` — the closer ends a block, and inserting after it
45
43
  * is exactly what the plain form does.
46
44
  */
47
45
  export declare function insertAfterBlockCloserLoweredWarning(line: number): string;
48
46
  /**
49
- * `insert after block N:` anchor unresolvable (unsupported language, blank
47
+ * `insert_after_block N:` anchor unresolvable (unsupported language, blank
50
48
  * line, parse error, or no resolver), lowered to plain `insert after N:` —
51
49
  * applying with a warning beats failing the patch.
52
50
  */
53
51
  export declare function insertAfterBlockUnresolvedLoweredWarning(line: number): string;
54
52
  /**
55
- * Internal invariant: `applyEdits` received an unresolved `replace block N:`
53
+ * Internal invariant: `applyEdits` received an unresolved `replace_block N:`
56
54
  * edit; `resolveBlockEdits` must run first. Wiring bug, not authored input.
57
55
  */
58
- export declare const UNRESOLVED_BLOCK_INTERNAL = "internal error: unresolved `replace block` edit reached the applier (resolveBlockEdits was not run).";
56
+ export declare const UNRESOLVED_BLOCK_INTERNAL = "internal error: unresolved `SWAP.BLK` edit reached the applier (resolveBlockEdits was not run).";
59
57
  /** Delete hunk received a body row. */
60
- export declare const DELETE_TAKES_NO_BODY = "`delete N..M` does not take body rows. Remove the body, or use `replace N..M:`.";
61
- /** `delete block N` hunk received a body row. */
62
- export declare const DELETE_BLOCK_TAKES_NO_BODY = "`delete block N` does not take body rows. Remove the body, or use `replace block N:`.";
58
+ export declare const DELETE_TAKES_NO_BODY = "`DEL N..M` does not take body rows. Remove the body, or use `SWAP N..M:`.";
59
+ /** `delete_block N` hunk received a body row. */
60
+ export declare const DELETE_BLOCK_TAKES_NO_BODY = "`DEL.BLK N` does not take body rows. Remove the body, or use `SWAP.BLK N:`.";
63
61
  /** Insert hunk with no body. */
64
- export declare const EMPTY_INSERT = "`insert` needs at least one `+TEXT` body row.";
62
+ export declare const EMPTY_INSERT = "`INS` needs at least one `+TEXT` body row.";
65
63
  /**
66
64
  * `insert after` body indented shallower than the anchor: the landing slid
67
65
  * forward past trailing closer lines — the common "anchored on the last line
@@ -69,7 +67,7 @@ export declare const EMPTY_INSERT = "`insert` needs at least one `+TEXT` body ro
69
67
  */
70
68
  export declare function afterInsertLandingShiftWarning(anchorLine: number, landingLine: number, crossed: number): string;
71
69
  /**
72
- * `insert after block N:` body indented deeper than the block's closer: the
70
+ * `insert_after_block N:` body indented deeper than the block's closer: the
73
71
  * landing was pulled inside the block — a deeper body almost always means
74
72
  * "append inside the block's body".
75
73
  */
@@ -90,7 +88,7 @@ export declare const RECOVERY_SESSION_REPLAY_WARNING = "Recovered by replaying y
90
88
  * Head/tail position is content-independent, so drift is non-fatal: apply
91
89
  * onto live content and warn instead of hard-failing.
92
90
  */
93
- export declare const HEADTAIL_DRIFT_WARNING = "Applied the `insert head:`/`insert tail:` edit despite a stale snapshot tag (file changed since your read) \u2014 head/tail position is content-independent. Re-read if the drift was unexpected.";
91
+ export declare const HEADTAIL_DRIFT_WARNING = "Applied the `INS.HEAD:`/`INS.TAIL:` edit despite a stale snapshot tag (file changed since your read) \u2014 head/tail position is content-independent. Re-read if the drift was unexpected.";
94
92
  /**
95
93
  * Section omitted the mandatory snapshot tag. Shared by the apply
96
94
  * ({@link Patcher.prepare}) and preview/diff paths so both stay in lockstep.
@@ -106,7 +104,7 @@ export declare function unseenLinesMessage(sectionPath: string, unseenLines: rea
106
104
  /** Op kind of a deferred block edit, for {@link blockSingleLineMessage}. */
107
105
  export type BlockOp = "replace" | "delete" | "insert_after";
108
106
  /**
109
- * A `replace block`/`delete block`/`insert after block` anchor resolved to a
107
+ * A `replace_block`/`delete_block`/`insert_after_block` anchor resolved to a
110
108
  * single line — almost always a bare statement the model mis-anchored, not a
111
109
  * multi-line construct. The plain op is unambiguous for one line; the block
112
110
  * form only earns its keep when it spares counting a closing line you cannot
@@ -10,8 +10,8 @@ export interface PatcherOptions {
10
10
  /** Snapshot store that minted and resolves hashline section tags. Required. */
11
11
  snapshots: SnapshotStore;
12
12
  /**
13
- * Resolves `replace block N:` anchors to concrete line spans via tree-sitter.
14
- * Optional: when omitted, any `replace block N:` edit throws on apply (the
13
+ * Resolves `replace_block N:` anchors to concrete line spans via tree-sitter.
14
+ * Optional: when omitted, any `replace_block N:` edit throws on apply (the
15
15
  * host did not wire a resolver). Plain line-range ops never need it.
16
16
  */
17
17
  blockResolver?: BlockResolver;
@@ -41,7 +41,7 @@ export interface PatchSectionResult {
41
41
  /** Warnings collected by the parser, applier, and (optionally) recovery. */
42
42
  warnings: string[];
43
43
  /**
44
- * Resolved spans for any `replace block`/`delete block` ops, present when the
44
+ * Resolved spans for any `replace_block`/`delete_block` ops, present when the
45
45
  * apply matched the tagged content. Undefined for patches with no block ops
46
46
  * (and for resolutions routed through drift recovery, where numbers shift).
47
47
  */
@@ -34,7 +34,7 @@ export type Edit = {
34
34
  index: number;
35
35
  mode?: "replacement";
36
36
  /**
37
- * Present on inserts lowered from `insert after block N:`: the
37
+ * Present on inserts lowered from `insert_after_block N:`: the
38
38
  * resolved block's first line. Lets the applier slide a body that
39
39
  * claims a depth inside the block back across the block's trailing
40
40
  * closer lines (never above this line).
@@ -48,13 +48,13 @@ export type Edit = {
48
48
  oldAssertion?: string;
49
49
  } | {
50
50
  /**
51
- * Deferred block edit (`replace block N:` / `delete block N` /
52
- * `insert after block N:`). The exact line span is unknown at parse
51
+ * Deferred block edit (`replace_block N:` / `delete_block N` /
52
+ * `insert_after_block N:`). The exact line span is unknown at parse
53
53
  * time — it is computed by {@link resolveBlockEdits} once file text +
54
54
  * path (→ language) are available, then expanded into concrete edits:
55
- * a non-empty `payloads` without `mode` (from `replace block`) becomes
55
+ * a non-empty `payloads` without `mode` (from `replace_block`) becomes
56
56
  * the same `replacement` inserts + deletes that `replace start..end:`
57
- * produces; an empty `payloads` (from `delete block`) becomes a pure
57
+ * produces; an empty `payloads` (from `delete_block`) becomes a pure
58
58
  * range deletion; `mode: "insert_after"` becomes plain `after_anchor`
59
59
  * inserts at the block's last line. `applyEdits` never sees this
60
60
  * variant.
@@ -75,7 +75,7 @@ export interface ApplyResult {
75
75
  /** Diagnostic warnings collected by the parser, patcher, or recovery. */
76
76
  warnings?: string[];
77
77
  /**
78
- * Resolved spans for each `replace block`/`delete block` op in this apply,
78
+ * Resolved spans for each `replace_block`/`delete_block` op in this apply,
79
79
  * in patch order. Present only when the apply matched the tagged content
80
80
  * (the common no-drift path), so the line numbers line up with what the
81
81
  * caller read. Absent when there were no block ops.
@@ -121,7 +121,7 @@ export interface CompactDiffOptions {
121
121
  maxUnchangedRun?: number;
122
122
  }
123
123
  /**
124
- * Resolved 1-indexed inclusive line span of a `replace block N:` target.
124
+ * Resolved 1-indexed inclusive line span of a `replace_block N:` target.
125
125
  */
126
126
  export interface BlockSpan {
127
127
  /** First line of the block (1-indexed, inclusive). */
@@ -130,7 +130,7 @@ export interface BlockSpan {
130
130
  end: number;
131
131
  }
132
132
  /**
133
- * One `replace block N:` / `delete block N` / `insert after block N:` anchor
133
+ * One `replace_block N:` / `delete_block N` / `insert_after_block N:` anchor
134
134
  * resolved to its concrete line span. Surfaced on {@link ApplyResult} so the
135
135
  * host can echo "block N → lines start..end" and let the model catch a wrong
136
136
  * opener — e.g. a decorator or doc-comment that sits in a separate node
@@ -146,7 +146,7 @@ export interface BlockResolution {
146
146
  /** Which block op produced this resolution. */
147
147
  op: "replace" | "delete" | "insert_after";
148
148
  }
149
- /** Request handed to a {@link BlockResolver} to resolve one `replace block N:` anchor. */
149
+ /** Request handed to a {@link BlockResolver} to resolve one `replace_block N:` anchor. */
150
150
  export interface BlockResolverRequest {
151
151
  /** Target file path (used to infer language by extension). */
152
152
  path: string;
@@ -156,7 +156,7 @@ export interface BlockResolverRequest {
156
156
  line: number;
157
157
  }
158
158
  /**
159
- * Resolves a `replace block N:` anchor to the line span of the syntactic block
159
+ * Resolves a `replace_block N:` anchor to the line span of the syntactic block
160
160
  * that begins on line N. Returns `null` when no block can be resolved
161
161
  * (unrecognized language, blank/out-of-range line, no node begins there, or the
162
162
  * resolved subtree has a syntax error). Pure seam: the hashline core declares
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/hashline",
4
- "version": "15.13.1",
4
+ "version": "15.13.2",
5
5
  "description": "Hashline: a compact, line-anchored patch language and applier. Pluggable FS/IO so it works over disk, in-memory, or any custom backend.",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
package/src/apply.ts CHANGED
@@ -592,7 +592,7 @@ function repairReplacementBoundaries(
592
592
  // content lines are never crossed) places the body at the depth its
593
593
  // indentation names.
594
594
  //
595
- // Inward (block-lowered inserts only): `insert after block N:` anchors on the
595
+ // Inward (block-lowered inserts only): `insert_after_block N:` anchors on the
596
596
  // resolved block's closing line, but a body indented deeper than that closer
597
597
  // claims a depth inside the block — the common misreading of the op as
598
598
  // "append at the end of block N's body". Sliding the landing point backward
@@ -630,7 +630,7 @@ interface AfterInsertGroup {
630
630
  anchor: number;
631
631
  /** Indices into the edit list, in patch order. */
632
632
  members: number[];
633
- /** First line of the resolved block when lowered from `insert after block N:`. */
633
+ /** First line of the resolved block when lowered from `insert_after_block N:`. */
634
634
  blockStart?: number;
635
635
  }
636
636
 
@@ -730,7 +730,7 @@ function resolveInwardLanding(
730
730
  /**
731
731
  * Slide mis-anchored after-insert hunks to the depth their body indentation
732
732
  * claims: outward past the structural closer lines that follow the anchor
733
- * when the body is shallower, or — for `insert after block N:` lowerings —
733
+ * when the body is shallower, or — for `insert_after_block N:` lowerings —
734
734
  * inward across the block's trailing closers when the body is deeper than
735
735
  * the block's closing line. Returns the corrected edit list plus one warning
736
736
  * per shifted hunk.
package/src/block.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Expand deferred block edits (`replace block N:` / `delete block N` /
3
- * `insert after block N:`) into concrete inserts + deletes.
2
+ * Expand deferred block edits (`replace_block N:` / `delete_block N` /
3
+ * `insert_after_block N:`) into concrete inserts + deletes.
4
4
  *
5
5
  * The hashline parser cannot expand a block edit on its own — the line span is
6
6
  * unknown until file text + path (→ language) are available. This transform
@@ -29,7 +29,7 @@ export interface ResolveBlockEditsOptions {
29
29
  * `blockUnresolvedMessage` error — used by the authoritative apply + final
30
30
  * preview paths. `"drop"` silently skips the edit — used by the streaming
31
31
  * preview, where a half-written file or transient parse error must not
32
- * throw. Unresolvable `insert after block N:` edits never reach this: they
32
+ * throw. Unresolvable `insert_after_block N:` edits never reach this: they
33
33
  * are lowered to plain `insert after N:` with a warning.
34
34
  */
35
35
  onUnresolved?: "throw" | "drop";
@@ -42,7 +42,7 @@ export interface ResolveBlockEditsOptions {
42
42
  onResolved?: (resolution: BlockResolution) => void;
43
43
  /**
44
44
  * Invoked once per diagnostic produced while resolving — currently the
45
- * `insert after block N:` lowerings (closer anchor or unresolvable block).
45
+ * `insert_after_block N:` lowerings (closer anchor or unresolvable block).
46
46
  * Hosts should surface these on the apply result's `warnings`.
47
47
  */
48
48
  onWarning?: (message: string) => void;
@@ -82,7 +82,7 @@ export function resolveBlockEdits(
82
82
  const op = edit.mode === "insert_after" ? "insert_after" : edit.payloads.length === 0 ? "delete" : "replace";
83
83
  const span = resolver ? resolver({ path, text, line: edit.anchor.line }) : null;
84
84
  if (span === null) {
85
- // `insert after block N:` never fails the patch — lower it to plain
85
+ // `insert_after_block N:` never fails the patch — lower it to plain
86
86
  // `insert after N:` with a warning instead. Two flavors:
87
87
  // - anchored on a pure closing-delimiter line: no block begins
88
88
  // there, but line N IS the end of one, and "after the end of the
@@ -148,7 +148,7 @@ export function resolveBlockEdits(
148
148
  // Mirror the parser's `replace start..end:` expansion exactly: one
149
149
  // `before_anchor` replacement insert per payload row at `span.start`,
150
150
  // then one delete per line across `[span.start, span.end]`. An empty
151
- // `payloads` (from `delete block N`) emits no inserts — a pure deletion.
151
+ // `payloads` (from `delete_block N`) emits no inserts — a pure deletion.
152
152
  for (const payload of edit.payloads) {
153
153
  const cursor: Cursor = { kind: "before_anchor", anchor: { line: span.start } };
154
154
  resolved.push({
package/src/format.ts CHANGED
@@ -14,22 +14,25 @@ export const HL_FILE_SUFFIX = "]";
14
14
  export const HL_PAYLOAD_REPLACE = "+";
15
15
 
16
16
  /** Hunk-header keyword for concrete line replacement. */
17
- export const HL_REPLACE_KEYWORD = "replace";
18
- /** Hunk-header sub-keyword: `replace block N:` resolves N to a tree-sitter block range. */
19
- export const HL_BLOCK_KEYWORD = "block";
17
+ export const HL_REPLACE_KEYWORD = "SWAP";
20
18
  /** Hunk-header keyword for concrete line deletion. */
21
- export const HL_DELETE_KEYWORD = "delete";
19
+ export const HL_DELETE_KEYWORD = "DEL";
22
20
  /** Hunk-header keyword for insertion operations. */
23
- export const HL_INSERT_KEYWORD = "insert";
21
+ export const HL_INSERT_KEYWORD = "INS";
24
22
  /** Insert position keyword for inserting before a concrete line. */
25
- export const HL_INSERT_BEFORE = "before";
23
+ export const HL_INSERT_BEFORE = "PRE";
26
24
  /** Insert position keyword for inserting after a concrete line. */
27
- export const HL_INSERT_AFTER = "after";
25
+ export const HL_INSERT_AFTER = "POST";
28
26
  /** Insert position keyword for inserting at the start of the file. */
29
- export const HL_INSERT_HEAD = "head";
27
+ export const HL_INSERT_HEAD = "HEAD";
30
28
  /** Insert position keyword for inserting at the end of the file. */
31
- export const HL_INSERT_TAIL = "tail";
32
- /** Hunk-header terminator for body-bearing operations. */
29
+ export const HL_INSERT_TAIL = "TAIL";
30
+ /** Hunk-header keyword: `SWAP.BLK N:` resolves N to a tree-sitter block range and replaces its span. */
31
+ export const HL_REPLACE_BLOCK_KEYWORD = "SWAP.BLK";
32
+ /** Hunk-header keyword: `DEL.BLK N` resolves N to a tree-sitter block range and deletes its span. */
33
+ export const HL_DELETE_BLOCK_KEYWORD = "DEL.BLK";
34
+ /** Hunk-header keyword: `INS.BLK.POST N:` inserts after the last line of the tree-sitter block at N. */
35
+ export const HL_INSERT_AFTER_BLOCK_KEYWORD = "INS.BLK.POST";
33
36
  export const HL_HEADER_COLON = ":";
34
37
 
35
38
  /** Separator between a hashline file path and its opaque snapshot tag. */
@@ -65,13 +68,13 @@ export function formatDeleteHeader(start: number, end = start): string {
65
68
  export function formatInsertHeader(cursor: Cursor): string {
66
69
  switch (cursor.kind) {
67
70
  case "before_anchor":
68
- return `${HL_INSERT_KEYWORD} ${HL_INSERT_BEFORE} ${cursor.anchor.line}${HL_HEADER_COLON}`;
71
+ return `${HL_INSERT_KEYWORD}.${HL_INSERT_BEFORE} ${cursor.anchor.line}${HL_HEADER_COLON}`;
69
72
  case "after_anchor":
70
- return `${HL_INSERT_KEYWORD} ${HL_INSERT_AFTER} ${cursor.anchor.line}${HL_HEADER_COLON}`;
73
+ return `${HL_INSERT_KEYWORD}.${HL_INSERT_AFTER} ${cursor.anchor.line}${HL_HEADER_COLON}`;
71
74
  case "bof":
72
- return `${HL_INSERT_KEYWORD} ${HL_INSERT_HEAD}${HL_HEADER_COLON}`;
75
+ return `${HL_INSERT_KEYWORD}.${HL_INSERT_HEAD}${HL_HEADER_COLON}`;
73
76
  case "eof":
74
- return `${HL_INSERT_KEYWORD} ${HL_INSERT_TAIL}${HL_HEADER_COLON}`;
77
+ return `${HL_INSERT_KEYWORD}.${HL_INSERT_TAIL}${HL_HEADER_COLON}`;
75
78
  }
76
79
  }
77
80
 
package/src/grammar.lark CHANGED
@@ -12,13 +12,13 @@ replace_hunk: replace_anchor LF emit_op*
12
12
  replace_block_hunk: replace_block_anchor LF emit_op+
13
13
  insert_hunk: insert_anchor LF emit_op+
14
14
  insert_block_hunk: insert_block_anchor LF emit_op+
15
- delete_hunk: "delete " header_range LF
16
- delete_block_hunk: "delete block " LID LF
17
- replace_anchor: "replace " header_range ":"
18
- replace_block_anchor: "replace block " LID ":"
19
- insert_anchor: "insert " insert_pos ":"
20
- insert_block_anchor: "insert after block " LID ":"
21
- insert_pos: "before " LID | "after " LID | "head" | "tail"
15
+ delete_hunk: "DEL " header_range LF
16
+ delete_block_hunk: "DEL.BLK " LID LF
17
+ replace_anchor: "SWAP " header_range ":"
18
+ replace_block_anchor: "SWAP.BLK " LID ":"
19
+ insert_anchor: "INS." insert_pos ":"
20
+ insert_block_anchor: "INS.BLK.POST " LID ":"
21
+ insert_pos: "PRE " LID | "POST " LID | "HEAD" | "TAIL"
22
22
  emit_op: "+" /(.*)/ LF
23
23
 
24
24
  header_range: LID ".." LID
package/src/input.ts CHANGED
@@ -273,7 +273,7 @@ export class PatchSection {
273
273
  get hasAnchorScopedEdit(): boolean {
274
274
  return this.edits.some(edit => {
275
275
  if (edit.kind === "delete") return true;
276
- // A `replace block N:` edit is anchored to concrete content on line N.
276
+ // A `replace_block N:` edit is anchored to concrete content on line N.
277
277
  if (edit.kind === "block") return true;
278
278
  return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
279
279
  });
@@ -305,7 +305,7 @@ export class PatchSection {
305
305
  * method directly when you've already validated the file content and
306
306
  * just want the result.
307
307
  *
308
- * `blockResolver` resolves any `replace block N:` edits against `text`; an
308
+ * `blockResolver` resolves any `replace_block N:` edits against `text`; an
309
309
  * unresolvable block throws (this is the final, authoritative preview path).
310
310
  */
311
311
  applyTo(text: string, blockResolver?: BlockResolver): ApplyResult {
@@ -331,7 +331,7 @@ export class PatchSection {
331
331
  * empty-payload edit. Intended for incremental diff previews; the writer
332
332
  * path should always use {@link applyTo}.
333
333
  *
334
- * `blockResolver` resolves any `replace block N:` edits against `text`; an
334
+ * `blockResolver` resolves any `replace_block N:` edits against `text`; an
335
335
  * unresolvable block is silently dropped so a half-written file does not
336
336
  * throw mid-stream.
337
337
  */
package/src/messages.ts CHANGED
@@ -44,11 +44,10 @@ export const ABORT_MARKER = "*** Abort";
44
44
 
45
45
  /** Two consecutive hunks targeted the exact same concrete range. */
46
46
  export const REPLACE_PAIR_COALESCED_WARNING =
47
- "Two hunks targeted the same range; kept only the second. One `replace N..M:` hunk per range — the body is the final content, never old+new.";
47
+ "Two hunks targeted the same range; kept only the second. One `SWAP N..M:` hunk per range — the body is the final content, never old+new.";
48
48
 
49
49
  /** Bare bodyless hunk followed by an overlapping concrete hunk. */
50
- export const REPLACE_PAIR_COALESCED_OVERLAP_WARNING =
51
- "Dropped a bare hunk overlapped by the concrete hunk after it. One `replace N..M:` hunk per range — the body is the final content, never old+new.";
50
+ ("Dropped a bare hunk overlapped by the concrete hunk after it. One `SWAP N..M:` hunk per range — the body is the final content, never old+new.");
52
51
 
53
52
  /** Bare body rows auto-converted to literal `+` rows. */
54
53
  export const BARE_BODY_AUTO_PIPED_WARNING =
@@ -59,17 +58,16 @@ export const MINUS_ROW_REJECTED =
59
58
  "`-` rows are not valid; the range already names the lines being changed. For a literal `-` line, write `+-…`.";
60
59
 
61
60
  /** Replace hunk with no body. */
62
- export const EMPTY_REPLACE = "`replace N..M:` needs at least one `+TEXT` body row. To delete lines, use `delete N..M`.";
61
+ export const EMPTY_REPLACE = "`SWAP N..M:` needs at least one `+TEXT` body row. To delete lines, use `DEL N..M`.";
63
62
 
64
- /** `replace block N:` hunk with no body. */
65
- export const EMPTY_BLOCK =
66
- "`replace block N:` needs at least one `+TEXT` body row. To delete a block, use `delete block N`.";
63
+ /** `replace_block N:` hunk with no body. */
64
+ export const EMPTY_BLOCK = "`SWAP.BLK N:` needs at least one `+TEXT` body row. To delete a block, use `DEL.BLK N`.";
67
65
 
68
66
  /**
69
67
  * Block-anchored replace/delete could not resolve to a syntactic block
70
68
  * (unsupported language, blank/out-of-range line, no node beginning on N, or
71
69
  * parse error). Appends a {@link formatAnchoredContext} preview when
72
- * `fileLines` is given. `insert after block N:` never reaches this — it is
70
+ * `fileLines` is given. `insert_after_block N:` never reaches this — it is
73
71
  * lowered to plain `insert after N:` instead (see
74
72
  * {@link insertAfterBlockUnresolvedLoweredWarning}).
75
73
  */
@@ -78,8 +76,8 @@ export function blockUnresolvedMessage(
78
76
  op: "replace" | "delete" = "replace",
79
77
  fileLines?: readonly string[],
80
78
  ): string {
81
- const phrase = op === "delete" ? `delete block ${line}` : `replace block ${line}:`;
82
- const fallback = op === "delete" ? `delete ${line}..M` : `replace ${line}..M:`;
79
+ const phrase = op === "delete" ? `DEL.BLK ${line}` : `SWAP.BLK ${line}:`;
80
+ const fallback = op === "delete" ? `DEL ${line}..M` : `SWAP ${line}..M:`;
83
81
  let message =
84
82
  `\`${phrase}\` could not resolve a syntactic block beginning on line ${line} ` +
85
83
  `(unsupported language, blank/closer line, or parse error). Use \`${fallback}\` with explicit lines.`;
@@ -92,42 +90,41 @@ export function blockUnresolvedMessage(
92
90
 
93
91
  /** Block-anchored edit reached a path with no {@link BlockResolver} wired in — a host-configuration bug. */
94
92
  export const BLOCK_RESOLVER_UNAVAILABLE =
95
- "`replace block`/`delete block`/`insert after block` are not available here (no block resolver configured). Use a concrete line range.";
93
+ "`SWAP.BLK`/`DEL.BLK`/`INS.BLK.POST` are not available here (no block resolver configured). Use a concrete line range.";
96
94
 
97
95
  /**
98
- * `insert after block N:` anchored on a closing-delimiter line, lowered to
96
+ * `insert_after_block N:` anchored on a closing-delimiter line, lowered to
99
97
  * plain `insert after N:` — the closer ends a block, and inserting after it
100
98
  * is exactly what the plain form does.
101
99
  */
102
100
  export function insertAfterBlockCloserLoweredWarning(line: number): string {
103
- return `\`insert after block ${line}:\` anchors on a closing delimiter, so it was applied as plain \`insert after ${line}:\`. Anchor on the line that OPENS the construct.`;
101
+ return `\`INS.BLK.POST ${line}:\` anchors on a closing delimiter, so it was applied as plain \`INS.POST ${line}:\`. Anchor on the line that OPENS the construct.`;
104
102
  }
105
103
 
106
104
  /**
107
- * `insert after block N:` anchor unresolvable (unsupported language, blank
105
+ * `insert_after_block N:` anchor unresolvable (unsupported language, blank
108
106
  * line, parse error, or no resolver), lowered to plain `insert after N:` —
109
107
  * applying with a warning beats failing the patch.
110
108
  */
111
109
  export function insertAfterBlockUnresolvedLoweredWarning(line: number): string {
112
- return `\`insert after block ${line}:\` could not resolve a syntactic block on line ${line}, so it was applied as plain \`insert after ${line}:\`. Verify the landing line; anchor on a line that OPENS a construct.`;
110
+ return `\`INS.BLK.POST ${line}:\` could not resolve a syntactic block on line ${line}, so it was applied as plain \`INS.POST ${line}:\`. Verify the landing line; anchor on a line that OPENS a construct.`;
113
111
  }
114
112
 
115
113
  /**
116
- * Internal invariant: `applyEdits` received an unresolved `replace block N:`
114
+ * Internal invariant: `applyEdits` received an unresolved `replace_block N:`
117
115
  * edit; `resolveBlockEdits` must run first. Wiring bug, not authored input.
118
116
  */
119
117
  export const UNRESOLVED_BLOCK_INTERNAL =
120
- "internal error: unresolved `replace block` edit reached the applier (resolveBlockEdits was not run).";
118
+ "internal error: unresolved `SWAP.BLK` edit reached the applier (resolveBlockEdits was not run).";
121
119
 
122
120
  /** Delete hunk received a body row. */
123
- export const DELETE_TAKES_NO_BODY = "`delete N..M` does not take body rows. Remove the body, or use `replace N..M:`.";
121
+ export const DELETE_TAKES_NO_BODY = "`DEL N..M` does not take body rows. Remove the body, or use `SWAP N..M:`.";
124
122
 
125
- /** `delete block N` hunk received a body row. */
126
- export const DELETE_BLOCK_TAKES_NO_BODY =
127
- "`delete block N` does not take body rows. Remove the body, or use `replace block N:`.";
123
+ /** `delete_block N` hunk received a body row. */
124
+ export const DELETE_BLOCK_TAKES_NO_BODY = "`DEL.BLK N` does not take body rows. Remove the body, or use `SWAP.BLK N:`.";
128
125
 
129
126
  /** Insert hunk with no body. */
130
- export const EMPTY_INSERT = "`insert` needs at least one `+TEXT` body row.";
127
+ export const EMPTY_INSERT = "`INS` needs at least one `+TEXT` body row.";
131
128
 
132
129
  /**
133
130
  * `insert after` body indented shallower than the anchor: the landing slid
@@ -135,16 +132,16 @@ export const EMPTY_INSERT = "`insert` needs at least one `+TEXT` body row.";
135
132
  * I read instead of after the block" mistake.
136
133
  */
137
134
  export function afterInsertLandingShiftWarning(anchorLine: number, landingLine: number, crossed: number): string {
138
- return `insert after ${anchorLine}: body indented shallower than the anchor, so the landing moved past ${crossed} closing line${crossed === 1 ? "" : "s"} to after line ${landingLine}. For the deeper position inside the block, re-issue with the body indented to match.`;
135
+ return `INS.POST ${anchorLine}: body indented shallower than the anchor, so the landing moved past ${crossed} closing line${crossed === 1 ? "" : "s"} to after line ${landingLine}. For the deeper position inside the block, re-issue with the body indented to match.`;
139
136
  }
140
137
 
141
138
  /**
142
- * `insert after block N:` body indented deeper than the block's closer: the
139
+ * `insert_after_block N:` body indented deeper than the block's closer: the
143
140
  * landing was pulled inside the block — a deeper body almost always means
144
141
  * "append inside the block's body".
145
142
  */
146
143
  export function blockInsertLandingShiftWarning(blockStart: number, closerLine: number, landingLine: number): string {
147
- return `insert after block ${blockStart}: body indented deeper than closing line ${closerLine}, so it was placed inside the block, after line ${landingLine}. \`insert after block\` lands AFTER the block at sibling depth — if inside was intended, use plain \`insert after ${closerLine}:\`.`;
144
+ return `INS.BLK.POST ${blockStart}: body indented deeper than closing line ${closerLine}, so it was placed inside the block, after line ${landingLine}. \`INS.BLK.POST\` lands AFTER the block at sibling depth — if inside was intended, use plain \`INS.POST ${closerLine}:\`.`;
148
145
  }
149
146
 
150
147
  /** `Recovery`: an external write matched a cached snapshot. */
@@ -170,7 +167,7 @@ export const RECOVERY_SESSION_REPLAY_WARNING =
170
167
  * onto live content and warn instead of hard-failing.
171
168
  */
172
169
  export const HEADTAIL_DRIFT_WARNING =
173
- "Applied the `insert head:`/`insert tail:` edit despite a stale snapshot tag (file changed since your read) — head/tail position is content-independent. Re-read if the drift was unexpected.";
170
+ "Applied the `INS.HEAD:`/`INS.TAIL:` edit despite a stale snapshot tag (file changed since your read) — head/tail position is content-independent. Re-read if the drift was unexpected.";
174
171
 
175
172
  /**
176
173
  * Section omitted the mandatory snapshot tag. Shared by the apply
@@ -220,20 +217,16 @@ export function unseenLinesMessage(sectionPath: string, unseenLines: readonly nu
220
217
  export type BlockOp = "replace" | "delete" | "insert_after";
221
218
 
222
219
  /**
223
- * A `replace block`/`delete block`/`insert after block` anchor resolved to a
220
+ * A `replace_block`/`delete_block`/`insert_after_block` anchor resolved to a
224
221
  * single line — almost always a bare statement the model mis-anchored, not a
225
222
  * multi-line construct. The plain op is unambiguous for one line; the block
226
223
  * form only earns its keep when it spares counting a closing line you cannot
227
224
  * see. Reject and point at both fixes.
228
225
  */
229
226
  export function blockSingleLineMessage(line: number, op: BlockOp): string {
230
- const blockForm = op === "insert_after" ? "insert after block" : op === "delete" ? "delete block" : "replace block";
227
+ const blockForm = op === "insert_after" ? "INS.BLK.POST" : op === "delete" ? "DEL.BLK" : "SWAP.BLK";
231
228
  const plainForm =
232
- op === "insert_after"
233
- ? `insert after ${line}:`
234
- : op === "delete"
235
- ? `delete ${line}`
236
- : `replace ${line}..${line}:`;
229
+ op === "insert_after" ? `INS.POST ${line}:` : op === "delete" ? `DEL ${line}` : `SWAP ${line}..${line}:`;
237
230
  return (
238
231
  `\`${blockForm} ${line}\` resolved a single-line block — line ${line} is a bare statement, not the opening line ` +
239
232
  `of a multi-line construct. For that one line use \`${plainForm}\`; to act on an enclosing construct, anchor ${blockForm} ` +
package/src/parser.ts CHANGED
@@ -52,33 +52,33 @@ function detectApplyPatchContamination(text: string, _hasPending: boolean): stri
52
52
  return (
53
53
  `apply_patch sentinel ${JSON.stringify(preview)} is not valid in hashline. ` +
54
54
  "File sections start with `[path#HASH]` (no `Update File:` / `Add File:` keyword). " +
55
- "Use `replace N..M:`, `delete N..M`, or `insert before|after|head|tail:` ops."
55
+ "Use `SWAP N..M:`, `DEL N..M`, or `INS.PRE|POST|HEAD|TAIL:` ops."
56
56
  );
57
57
  }
58
58
  if (/^@@\s+[-+]?\d+,\d+\s+[-+]?\d+,\d+\s+@@/.test(trimmed)) {
59
59
  return (
60
60
  "unified-diff hunk header (`@@ -N,M +N,M @@`) is not valid in hashline. " +
61
- "Use `replace N..M:`, `delete N..M`, or `insert before|after|head|tail:` ops."
61
+ "Use `SWAP N..M:`, `DEL N..M`, or `INS.PRE|POST|HEAD|TAIL:` ops."
62
62
  );
63
63
  }
64
64
  if (trimmed.startsWith("@@")) {
65
65
  const preview = trimmed.length > 48 ? `${trimmed.slice(0, 48)}…` : trimmed;
66
66
  return (
67
67
  `\`@@\`-bracketed hunk header ${JSON.stringify(preview)} is not valid in hashline. ` +
68
- "Drop the `@@ ... @@` brackets and write a verb header such as `replace N..M:`."
68
+ "Drop the `@@ ... @@` brackets and write a verb header such as `SWAP N..M:`."
69
69
  );
70
70
  }
71
- if (/^delete\s+[1-9]\d*(?:\s*(?:\.\.|-|…|\s)\s*[1-9]\d*)?\s*:/.test(trimmed)) {
72
- return "`delete N..M` has no colon and no body. Remove the colon and body rows.";
71
+ if (/^DEL\s+[1-9]\d*(?:\s*(?:\.\.|-|…|\s)\s*[1-9]\d*)?\s*:/.test(trimmed)) {
72
+ return "`DEL N..M` has no colon and no body. Remove the colon and body rows.";
73
73
  }
74
74
  if (/^[1-9]\d*\s*$/.test(trimmed)) {
75
- return `hunk headers need a verb. Use \`replace ${trimmed}..${trimmed}:\` to replace, or \`delete ${trimmed}\` to delete.`;
75
+ return `hunk headers need a verb. Use \`SWAP ${trimmed}..${trimmed}:\` to replace, or \`DEL ${trimmed}\` to delete.`;
76
76
  }
77
77
  const bareRange = /^([1-9]\d*)\s*[-. …]+\s*([1-9]\d*)\s*:?$/.exec(trimmed);
78
78
  if (bareRange !== null) {
79
79
  return (
80
80
  `bare range hunk header ${JSON.stringify(trimmed)} is not valid. ` +
81
- `Hunk headers need a verb: write \`replace ${bareRange[1]}..${bareRange[2]}:\` or \`delete ${bareRange[1]}..${bareRange[2]}\`.`
81
+ `Hunk headers need a verb: write \`SWAP ${bareRange[1]}..${bareRange[2]}:\` or \`DEL ${bareRange[1]}..${bareRange[2]}\`.`
82
82
  );
83
83
  }
84
84
  return null;
@@ -253,7 +253,7 @@ export class Executor {
253
253
  if (text.trim().length === 0) return;
254
254
  throw new Error(
255
255
  `line ${lineNum}: payload line has no preceding hunk header. ` +
256
- `Use \`replace N..M:\`, \`delete N..M\`, or \`insert before|after|head|tail:\` above the body. Got ${JSON.stringify(text)}.`,
256
+ `Use \`SWAP N..M:\`, \`DEL N..M\`, or \`INS.PRE|POST|HEAD|TAIL:\` above the body. Got ${JSON.stringify(text)}.`,
257
257
  );
258
258
  }
259
259
 
package/src/patcher.ts CHANGED
@@ -41,8 +41,8 @@ export interface PatcherOptions {
41
41
  /** Snapshot store that minted and resolves hashline section tags. Required. */
42
42
  snapshots: SnapshotStore;
43
43
  /**
44
- * Resolves `replace block N:` anchors to concrete line spans via tree-sitter.
45
- * Optional: when omitted, any `replace block N:` edit throws on apply (the
44
+ * Resolves `replace_block N:` anchors to concrete line spans via tree-sitter.
45
+ * Optional: when omitted, any `replace_block N:` edit throws on apply (the
46
46
  * host did not wire a resolver). Plain line-range ops never need it.
47
47
  */
48
48
  blockResolver?: BlockResolver;
@@ -73,7 +73,7 @@ export interface PatchSectionResult {
73
73
  /** Warnings collected by the parser, applier, and (optionally) recovery. */
74
74
  warnings: string[];
75
75
  /**
76
- * Resolved spans for any `replace block`/`delete block` ops, present when the
76
+ * Resolved spans for any `replace_block`/`delete_block` ops, present when the
77
77
  * apply matched the tagged content. Undefined for patches with no block ops
78
78
  * (and for resolutions routed through drift recovery, where numbers shift).
79
79
  */
@@ -112,7 +112,7 @@ export class PreparedSection {
112
112
  function hasAnchorScopedEdit(edits: readonly Edit[]): boolean {
113
113
  return edits.some(edit => {
114
114
  if (edit.kind === "delete") return true;
115
- // A `replace block N:` edit anchors to concrete content on line N.
115
+ // A `replace_block N:` edit anchors to concrete content on line N.
116
116
  if (edit.kind === "block") return true;
117
117
  return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
118
118
  });
@@ -386,7 +386,7 @@ export class Patcher {
386
386
  const expected = exists ? section.fileHash : undefined;
387
387
  const liveMatches = expected !== undefined && computeFileHash(normalized) === expected;
388
388
 
389
- // Resolve `replace block N:` edits to concrete ranges before recovery
389
+ // Resolve `replace_block N:` edits to concrete ranges before recovery
390
390
  // runs. Block anchors are expressed against the snapshot the section tag
391
391
  // names, so resolve against that exact text:
392
392
  // - live content matches the tag (or there is no tag) → resolve against
package/src/prompt.md CHANGED
@@ -1,20 +1,20 @@
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; `delete` has no body.
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.
2
2
 
3
3
  <headers>
4
4
  Every file section starts with `[PATH#TAG]`. `TAG` is the 4-hex snapshot tag from your latest `read`/`search`, and is REQUIRED on every section — there is no hashless form. To create a new file, use the `write` tool; hashline only edits files that already exist.
5
5
  </headers>
6
6
 
7
7
  <ops>
8
- `replace N..M:` — replace original lines N..M with the body rows below. INCLUSIVE — line M is consumed too.
9
- `replace block N:` — replace the whole syntactic block that BEGINS on line N; tree-sitter resolves the closing line. Body rows below.
10
- `delete N..M` — delete original lines N..M. No body.
11
- `delete block N` — delete the whole syntactic block that BEGINS on line N.
12
- `insert before N:` — insert the body rows immediately before line N.
13
- `insert after N:` — insert the body rows immediately after line N.
14
- `insert after block 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 `insert after`.
15
- `insert head:` — insert the body rows at the very start of the file.
16
- `insert tail:` — insert the body rows at the very end of the file.
17
- Single line: `replace N..N:` / `delete N`. The range is the ORIGINAL lines you touch; body length is irrelevant (replacing 1 line with 10 is still `replace N..N:`).
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:` — insert the body rows at the very start of the file.
16
+ `INS.TAIL:` — insert the body rows at the very end of the file.
17
+ 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:`).
18
18
  </ops>
19
19
 
20
20
  <body-rows>
@@ -34,11 +34,11 @@ There is NO other body row kind. NEVER write `-old` or a bare/context line. To k
34
34
  - On a stale-tag rejection or any surprising result: STOP and re-`read` before further edits.
35
35
  - One hunk per range; the body is the final content, never an old/new pair.
36
36
  - Ranges cover ONLY lines whose content changes. Never widen over unchanged lines — a stale wide range shreds everything it spans.
37
- - Whole construct → `replace block N` (tree-sitter resolves the end); lines inside it → `replace N..M`.
38
- - `replace block 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 `replace N..M`.
39
- - Block ops (`replace block`/`delete block`/`insert after block`) anchor the OPENING line of a MULTI-LINE construct — never its closer, its last line, or a bare statement inside it. Anchoring a single statement resolves to ONE line and is REJECTED: use the plain op (`replace N..N` / `delete N` / `insert after N`) for one line, or point N at the real opener. Saw the closer? Use plain `insert after M:`.
37
+ - Whole construct → `SWAP.BLK N` (tree-sitter resolves the end); lines inside it → `SWAP N..M`.
38
+ - `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`.
39
+ - Block ops (`SWAP.BLK`/`DEL.BLK`/`INS.BLK.POST`) anchor the OPENING line of a MULTI-LINE construct — never its closer, its last line, or a bare statement inside it. Anchoring a single statement resolves to ONE line and is REJECTED: use the plain op (`SWAP N..N` / `DEL N` / `INS.POST N`) for one line, or point N at the real opener. Saw the closer? Use plain `INS.POST M:`.
40
40
  - Non-adjacent changes = separate hunks; untouched lines stay out of every range.
41
- - Pure additions use `insert`, never a widened `replace` — retyped keepers are exactly what gets dropped. A multi-line `replace` whose body restates the line just outside the range is auto-dropped as an off-by-one keeper (with a warning), but issue the payload as the final content for the range only and never lean on the repair.
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 outside the range is auto-dropped as an off-by-one keeper (with a warning), but issue the payload as the final content for the range only and never lean on the repair.
42
42
  - NEVER format/restyle code with this tool; run the project formatter instead.
43
43
  </rules>
44
44
 
@@ -55,14 +55,14 @@ Original (the exact shape `read` returns):
55
55
  Insert a guard after line 1:
56
56
  ```
57
57
  [greet.py#A1B2]
58
- insert after 1:
58
+ INS.POST 1:
59
59
  + if not name: name = "stranger"
60
60
  ```
61
61
 
62
62
  Replace line 2 with two lines:
63
63
  ```
64
64
  [greet.py#A1B2]
65
- replace 2..2:
65
+ SWAP 2..2:
66
66
  + greeting = "Hi"
67
67
  + msg = f"{greeting}, {name}"
68
68
  ```
@@ -70,30 +70,30 @@ replace 2..2:
70
70
  Delete line 3:
71
71
  ```
72
72
  [greet.py#A1B2]
73
- delete 3
73
+ DEL 3
74
74
  ```
75
75
 
76
76
  Add a header and trailer:
77
77
  ```
78
78
  [greet.py#A1B2]
79
- insert head:
79
+ INS.HEAD:
80
80
  +# generated header
81
- insert tail:
81
+ INS.TAIL:
82
82
  +greet("everyone")
83
83
  ```
84
84
 
85
- Replace the whole `greet` function block — `replace block 1:` resolves lines 1–3 (the `def` header through `print(msg)`); line 4 is a separate statement and stays:
85
+ 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:
86
86
  ```
87
87
  [greet.py#A1B2]
88
- replace block 1:
88
+ SWAP.BLK 1:
89
89
  +def greet(name):
90
90
  + print(f"Hello, {name}")
91
91
  ```
92
92
 
93
- A decorator or doc-comment is a SEPARATE block — `replace block` 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 resolve only the function and orphan `@cache`:
93
+ A decorator or 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 resolve only the function and orphan `@cache`:
94
94
  ```
95
95
  [svc.py#C3D4]
96
- replace block 1:
96
+ SWAP.BLK 1:
97
97
  +@cache
98
98
  +def load(key):
99
99
  + return store[key]
@@ -101,43 +101,43 @@ replace block 1:
101
101
  </example>
102
102
 
103
103
  <anti-patterns>
104
- # WRONG — empty `replace` to delete. RIGHT: delete 4
105
- replace 4..4:
104
+ # WRONG — empty `SWAP` to delete. RIGHT: DEL 4
105
+ SWAP 4..4:
106
106
 
107
- # WRONG — range describes post-edit size. RIGHT: replace 1..1: (body length is irrelevant)
108
- replace 1..2:
107
+ # WRONG — range describes post-edit size. RIGHT: SWAP 1..1: (body length is irrelevant)
108
+ SWAP 1..2:
109
109
  +def greet(name):
110
110
 
111
111
  # WRONG — `-` rows / bare context lines do not exist. The range deletes; the body is only the new content.
112
- replace 3..3:
112
+ SWAP 3..3:
113
113
  msg = "Hello, " + name
114
114
  - print(msg)
115
115
  + return msg
116
116
  # RIGHT
117
- replace 3..3:
117
+ SWAP 3..3:
118
118
  + return msg
119
119
 
120
- # WRONG — a pure insertion done as a widened `replace`: you only want to add one line after 2,
120
+ # WRONG — a pure insertion done as a widened `SWAP`: you only want to add one line after 2,
121
121
  # but you replace 2..4, retype the keepers in the body, and drop one (here line 4, `greet("world")`).
122
- replace 2..4:
122
+ SWAP 2..4:
123
123
  + msg = "Hello, " + name
124
124
  + extra = compute(name)
125
125
  + print(msg)
126
126
  # RIGHT — touch nothing you keep; the new line is the whole body.
127
- insert after 2:
127
+ INS.POST 2:
128
128
  + extra = compute(name)
129
129
 
130
- # WRONG — `insert after block N:` anchored on a closing delimiter / last visible line. RIGHT: plain `insert after M:`
131
- insert after block 3:
130
+ # WRONG — `INS.BLK.POST N:` anchored on a closing delimiter / last visible line. RIGHT: plain `INS.POST M:`
131
+ INS.BLK.POST 3:
132
132
  +after()
133
133
  # RIGHT
134
- insert after 3:
134
+ INS.POST 3:
135
135
  +after()
136
136
  </anti-patterns>
137
137
 
138
138
  <critical>
139
139
  If you remember nothing else:
140
140
  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`.
141
- 2. RANGES ARE TIGHT. Cover only lines that change; a stale wide range shreds everything it spans. Whole construct → `replace block N`.
141
+ 2. RANGES ARE TIGHT. Cover only lines that change; a stale wide range shreds everything it spans. Whole construct → `SWAP.BLK N`.
142
142
  3. THE BODY IS THE FINAL CONTENT. Only `+TEXT` rows; never `-old`/context lines. The range does the deleting.
143
143
  </critical>
package/src/tokenizer.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import {
12
12
  describeAnchorExamples,
13
- HL_BLOCK_KEYWORD,
13
+ HL_DELETE_BLOCK_KEYWORD,
14
14
  HL_DELETE_KEYWORD,
15
15
  HL_FILE_HASH_LENGTH,
16
16
  HL_FILE_HASH_SEP,
@@ -18,11 +18,13 @@ import {
18
18
  HL_FILE_SUFFIX,
19
19
  HL_HEADER_COLON,
20
20
  HL_INSERT_AFTER,
21
+ HL_INSERT_AFTER_BLOCK_KEYWORD,
21
22
  HL_INSERT_BEFORE,
22
23
  HL_INSERT_HEAD,
23
24
  HL_INSERT_KEYWORD,
24
25
  HL_INSERT_TAIL,
25
26
  HL_PAYLOAD_REPLACE,
27
+ HL_REPLACE_BLOCK_KEYWORD,
26
28
  HL_REPLACE_KEYWORD,
27
29
  } from "./format";
28
30
  import { ABORT_MARKER, BEGIN_PATCH_MARKER, END_PATCH_MARKER } from "./messages";
@@ -218,7 +220,7 @@ function scanKeyword(line: string, index: number, end: number, keyword: string):
218
220
  const next = index + keyword.length;
219
221
  if (next < end) {
220
222
  const code = line.charCodeAt(next);
221
- if (!isWhitespaceCode(code) && code !== CHAR_COLON) return null;
223
+ if (!isWhitespaceCode(code) && code !== CHAR_COLON && code !== CHAR_DOT) return null;
222
224
  }
223
225
  return next;
224
226
  }
@@ -229,7 +231,8 @@ function consumeOptionalColon(line: string, index: number, end: number): number
229
231
  }
230
232
 
231
233
  function scanInsertTarget(line: string, index: number, end: number): TargetScan | null {
232
- const cursor = skipWhitespace(line, index, end);
234
+ if (index >= end || line.charCodeAt(index) !== CHAR_DOT) return null;
235
+ const cursor = skipWhitespace(line, index + 1, end);
233
236
  const beforeEnd = scanKeyword(line, cursor, end, HL_INSERT_BEFORE);
234
237
  if (beforeEnd !== null) {
235
238
  const anchor = scanLineNumber(line, skipWhitespace(line, beforeEnd, end), end);
@@ -239,16 +242,6 @@ function scanInsertTarget(line: string, index: number, end: number): TargetScan
239
242
  }
240
243
  const afterEnd = scanKeyword(line, cursor, end, HL_INSERT_AFTER);
241
244
  if (afterEnd !== null) {
242
- // `insert after block N:` — resolve N to a tree-sitter block range at
243
- // apply time and insert after its last line. Try the `block` sub-keyword
244
- // before falling back to a literal `insert after N:` anchor.
245
- const blockEnd = scanKeyword(line, skipWhitespace(line, afterEnd, end), end, HL_BLOCK_KEYWORD);
246
- if (blockEnd !== null) {
247
- const anchor = scanLineNumber(line, skipWhitespace(line, blockEnd, end), end);
248
- if (anchor === null) return null;
249
- const nextIndex = consumeOptionalColon(line, anchor.nextIndex, end);
250
- return { target: { kind: "insert_after_block", anchor: { line: anchor.line } }, nextIndex };
251
- }
252
245
  const anchor = scanLineNumber(line, skipWhitespace(line, afterEnd, end), end);
253
246
  if (anchor === null) return null;
254
247
  const nextIndex = consumeOptionalColon(line, anchor.nextIndex, end);
@@ -263,20 +256,19 @@ function scanInsertTarget(line: string, index: number, end: number): TargetScan
263
256
 
264
257
  function scanHunkAnchor(line: string, start: number, end: number): TargetScan | null {
265
258
  const cursor = skipWhitespace(line, start, end);
259
+
260
+ // `replace_block N:` — resolve N to a tree-sitter block range at apply time.
261
+ const replaceBlockEnd = scanKeyword(line, cursor, end, HL_REPLACE_BLOCK_KEYWORD);
262
+ if (replaceBlockEnd !== null) {
263
+ const anchor = scanLineNumber(line, skipWhitespace(line, replaceBlockEnd, end), end);
264
+ if (anchor === null) return null;
265
+ return {
266
+ target: { kind: "block", anchor: { line: anchor.line } },
267
+ nextIndex: consumeOptionalColon(line, anchor.nextIndex, end),
268
+ };
269
+ }
266
270
  const replaceEnd = scanKeyword(line, cursor, end, HL_REPLACE_KEYWORD);
267
271
  if (replaceEnd !== null) {
268
- // `replace block N:` — resolve N to a tree-sitter block range at apply
269
- // time. Try the `block` sub-keyword before falling back to a literal
270
- // `replace N..M:` range.
271
- const blockEnd = scanKeyword(line, skipWhitespace(line, replaceEnd, end), end, HL_BLOCK_KEYWORD);
272
- if (blockEnd !== null) {
273
- const anchor = scanLineNumber(line, skipWhitespace(line, blockEnd, end), end);
274
- if (anchor === null) return null;
275
- return {
276
- target: { kind: "block", anchor: { line: anchor.line } },
277
- nextIndex: consumeOptionalColon(line, anchor.nextIndex, end),
278
- };
279
- }
280
272
  const range = scanHeaderRange(line, replaceEnd, end, true);
281
273
  if (range === null) return null;
282
274
  return {
@@ -284,25 +276,36 @@ function scanHunkAnchor(line: string, start: number, end: number): TargetScan |
284
276
  nextIndex: consumeOptionalColon(line, range.nextIndex, end),
285
277
  };
286
278
  }
279
+ // `delete_block N` — resolve N to a tree-sitter block range at apply time
280
+ // and delete its whole span. Like `delete N..M`, it takes no body and no
281
+ // trailing colon.
282
+ const deleteBlockEnd = scanKeyword(line, cursor, end, HL_DELETE_BLOCK_KEYWORD);
283
+ if (deleteBlockEnd !== null) {
284
+ const anchor = scanLineNumber(line, skipWhitespace(line, deleteBlockEnd, end), end);
285
+ if (anchor === null) return null;
286
+ const next = skipWhitespace(line, anchor.nextIndex, end);
287
+ if (next < end && line.charCodeAt(next) === CHAR_COLON) return null;
288
+ return { target: { kind: "delete_block", anchor: { line: anchor.line } }, nextIndex: next };
289
+ }
287
290
  const deleteEnd = scanKeyword(line, cursor, end, HL_DELETE_KEYWORD);
288
291
  if (deleteEnd !== null) {
289
- // `delete block N` — resolve N to a tree-sitter block range at apply
290
- // time and delete its whole span. Like `delete N..M`, it takes no body
291
- // and no trailing colon.
292
- const blockEnd = scanKeyword(line, skipWhitespace(line, deleteEnd, end), end, HL_BLOCK_KEYWORD);
293
- if (blockEnd !== null) {
294
- const anchor = scanLineNumber(line, skipWhitespace(line, blockEnd, end), end);
295
- if (anchor === null) return null;
296
- const next = skipWhitespace(line, anchor.nextIndex, end);
297
- if (next < end && line.charCodeAt(next) === CHAR_COLON) return null;
298
- return { target: { kind: "delete_block", anchor: { line: anchor.line } }, nextIndex: next };
299
- }
300
292
  const range = scanHeaderRange(line, deleteEnd, end, true);
301
293
  if (range === null) return null;
302
294
  const next = skipWhitespace(line, range.nextIndex, end);
303
295
  if (next < end && line.charCodeAt(next) === CHAR_COLON) return null;
304
296
  return { target: { kind: "delete", range: range.range }, nextIndex: next };
305
297
  }
298
+ // `insert_after_block N:` — insert after the last line of the tree-sitter
299
+ // block at N.
300
+ const insertAfterBlockEnd = scanKeyword(line, cursor, end, HL_INSERT_AFTER_BLOCK_KEYWORD);
301
+ if (insertAfterBlockEnd !== null) {
302
+ const anchor = scanLineNumber(line, skipWhitespace(line, insertAfterBlockEnd, end), end);
303
+ if (anchor === null) return null;
304
+ return {
305
+ target: { kind: "insert_after_block", anchor: { line: anchor.line } },
306
+ nextIndex: consumeOptionalColon(line, anchor.nextIndex, end),
307
+ };
308
+ }
306
309
  const insertEnd = scanKeyword(line, cursor, end, HL_INSERT_KEYWORD);
307
310
  if (insertEnd !== null) return scanInsertTarget(line, insertEnd, end);
308
311
  return null;
package/src/types.ts CHANGED
@@ -32,7 +32,7 @@ export type Edit =
32
32
  index: number;
33
33
  mode?: "replacement";
34
34
  /**
35
- * Present on inserts lowered from `insert after block N:`: the
35
+ * Present on inserts lowered from `insert_after_block N:`: the
36
36
  * resolved block's first line. Lets the applier slide a body that
37
37
  * claims a depth inside the block back across the block's trailing
38
38
  * closer lines (never above this line).
@@ -42,13 +42,13 @@ export type Edit =
42
42
  | { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string }
43
43
  | {
44
44
  /**
45
- * Deferred block edit (`replace block N:` / `delete block N` /
46
- * `insert after block N:`). The exact line span is unknown at parse
45
+ * Deferred block edit (`replace_block N:` / `delete_block N` /
46
+ * `insert_after_block N:`). The exact line span is unknown at parse
47
47
  * time — it is computed by {@link resolveBlockEdits} once file text +
48
48
  * path (→ language) are available, then expanded into concrete edits:
49
- * a non-empty `payloads` without `mode` (from `replace block`) becomes
49
+ * a non-empty `payloads` without `mode` (from `replace_block`) becomes
50
50
  * the same `replacement` inserts + deletes that `replace start..end:`
51
- * produces; an empty `payloads` (from `delete block`) becomes a pure
51
+ * produces; an empty `payloads` (from `delete_block`) becomes a pure
52
52
  * range deletion; `mode: "insert_after"` becomes plain `after_anchor`
53
53
  * inserts at the block's last line. `applyEdits` never sees this
54
54
  * variant.
@@ -70,7 +70,7 @@ export interface ApplyResult {
70
70
  /** Diagnostic warnings collected by the parser, patcher, or recovery. */
71
71
  warnings?: string[];
72
72
  /**
73
- * Resolved spans for each `replace block`/`delete block` op in this apply,
73
+ * Resolved spans for each `replace_block`/`delete_block` op in this apply,
74
74
  * in patch order. Present only when the apply matched the tagged content
75
75
  * (the common no-drift path), so the line numbers line up with what the
76
76
  * caller read. Absent when there were no block ops.
@@ -122,7 +122,7 @@ export interface CompactDiffOptions {
122
122
  }
123
123
 
124
124
  /**
125
- * Resolved 1-indexed inclusive line span of a `replace block N:` target.
125
+ * Resolved 1-indexed inclusive line span of a `replace_block N:` target.
126
126
  */
127
127
  export interface BlockSpan {
128
128
  /** First line of the block (1-indexed, inclusive). */
@@ -132,7 +132,7 @@ export interface BlockSpan {
132
132
  }
133
133
 
134
134
  /**
135
- * One `replace block N:` / `delete block N` / `insert after block N:` anchor
135
+ * One `replace_block N:` / `delete_block N` / `insert_after_block N:` anchor
136
136
  * resolved to its concrete line span. Surfaced on {@link ApplyResult} so the
137
137
  * host can echo "block N → lines start..end" and let the model catch a wrong
138
138
  * opener — e.g. a decorator or doc-comment that sits in a separate node
@@ -149,7 +149,7 @@ export interface BlockResolution {
149
149
  op: "replace" | "delete" | "insert_after";
150
150
  }
151
151
 
152
- /** Request handed to a {@link BlockResolver} to resolve one `replace block N:` anchor. */
152
+ /** Request handed to a {@link BlockResolver} to resolve one `replace_block N:` anchor. */
153
153
  export interface BlockResolverRequest {
154
154
  /** Target file path (used to infer language by extension). */
155
155
  path: string;
@@ -160,7 +160,7 @@ export interface BlockResolverRequest {
160
160
  }
161
161
 
162
162
  /**
163
- * Resolves a `replace block N:` anchor to the line span of the syntactic block
163
+ * Resolves a `replace_block N:` anchor to the line span of the syntactic block
164
164
  * that begins on line N. Returns `null` when no block can be resolved
165
165
  * (unrecognized language, blank/out-of-range line, no node begins there, or the
166
166
  * resolved subtree has a syntax error). Pure seam: the hashline core declares