@d3ara1n/pi-hashline-edit 0.1.2 → 0.3.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/README.md CHANGED
@@ -1,16 +1,57 @@
1
1
  # @d3ara1n/pi-hashline-edit
2
2
 
3
- > Hashline-style file editing for [pi](https://github.com/earendil-works/pi-coding-agent) — line-anchored edits verified by content hash, replacing `oldText`/`newText` matching.
3
+ > Hashline-style file editing for [pi](https://github.com/earendil-works/pi-coding-agent) — line-anchored edits verified by content hash (replacing `oldText`/`newText` matching), plus a location-blind `replace` tool for bulk + regex transforms.
4
4
 
5
- Edits reference lines by `LINE#HASH` anchors (copied from `read` output) instead of retyping the code to be changed — eliminating string-not-found loops and whitespace battles at the root.
5
+ Edits reference lines by `LINE#HASH` anchors (copied from `read`/`grep` output) instead of retyping the code to be changed — eliminating string-not-found loops and whitespace battles at the root.
6
+
7
+ ## Why hashline?
8
+
9
+ The built-in `edit` matches `oldText`/`newText` exactly. When the model can't reproduce the source verbatim — wrong indentation, a non-unique snippet, or a line that drifted since the read — the edit fails and you loop. Hashline sidesteps all of it:
10
+
11
+ - **No string-not-found loops** — you edit by reference (`LINE#HASH`), not by retyping the line you want to change.
12
+ - **No whitespace battles** — the new content is the only thing you type; nothing has to match what's already there. Indentation mistakes on the *old* code are impossible.
13
+ - **Unique-by-construction hashes** — the line number is folded into the hash, so identical lines (blank lines, `}`) never share a hash and never collide.
14
+ - **Chain edits without re-reading** — a successful `edit` returns fresh anchors for the lines it produced, so the next edit cites them directly instead of forcing a full re-read.
15
+ - **Grep-to-edit, no detour** — search results carry the same `LINE#HASH` anchors (grouped by file, context lines included); grab one and edit directly, skipping the read you'd otherwise need.
16
+ - **Surgical drift detection** — each cited anchor is rechecked against the current line only; an unrelated change elsewhere never blocks your edit.
17
+ - **Self-healing stale anchors** — a drifted anchor (content shifted by an edit above it) doesn't force a full re-read: the applicator rescans ±lines for the original content and hands back a fresh `LINE#HASH` to retry with, only asking for a re-read when the content genuinely changed.
18
+
19
+ ## When to use it
20
+
21
+ Routine local code editing in pi — the common case. If you spend turns fighting "old_string not found" or fixing indentation the model dropped, this is the fix.
22
+
23
+ ## When to turn it off
24
+
25
+ Set `hashlineEdit.enabled = false` (or uninstall) to fall back to the built-in `read`/`edit`/`grep` when you need **remote or custom-storage files** — the overrides read/write/search the local filesystem directly, so pi's custom `ReadOperations`/`GrepOperations` (SSH, etc.) aren't supported. The same switch lets you opt out per-project. All four tools — `read`, `grep`, `edit`, `replace` — are one set governed by this switch: when disabled, `read`/`grep`/`edit` delegate to the built-ins and `replace` refuses (it has no built-in counterpart).
26
+
27
+ ## Gotchas (vs. the built-in `read`/`edit`)
28
+
29
+ Once hashline overrides the built-ins, a few things behave differently:
30
+
31
+ - **`read` is globally overridden.** Every read shows the `LINE#HASH│` prefix on each line — even reads that won't lead to an edit. This is expected (it's the substrate the reliability is built on), just don't be surprised when the format changes for all files.
32
+ - **Conservative overlap.** Two ops whose ranges touch (e.g. `insert_after` immediately followed by `replace` at the same line) are rejected to avoid backfill ambiguity — issue them as two separate `edit` calls.
33
+
34
+ ## `replace` — bulk + regex
35
+
36
+ A separate, location-blind tool for transforms `edit` can't express: replace **all** occurrences of a string/regex across the whole file in one call. Use it for renames, normalizations, and pattern-based rewrites that would otherwise need many individual anchored ops.
37
+
38
+ - **Two modes** — `regex: false` (default) treats `find` as a literal substring (replaceAll; the replacement is inserted verbatim, no `$` expansion); `regex: true` treats `find` as a JavaScript pattern source and `replace` supports `$1`, `$2`, `$&`, …
39
+ - **Flags** — `flags` adds regex flags in both modes (`g` is always forced so every occurrence is replaced): `i` (case-insensitive), `m` (per-line `^`/`$`), `s` (dotall, `.` matches `\n`), `u` (unicode).
40
+ - **Safety** — a `maxMatches` cap (default 2000) errors *before writing* if exceeded, so a runaway pattern can't produce a catastrophic write. `0` matches is an error (no silent no-op).
41
+ - **Shares the edit queue** — `replace` and `edit` on the same file are serialized via the same mutation queue, so concurrent edits never interleave.
42
+ - **Returns a diff + fresh anchors** for the changed region, so a follow-up `edit` can chain on the new content without a re-read (when the region is small).
43
+
44
+ `edit` vs `replace`: `edit` is **surgical and verified** (you point at a `LINE#HASH` and the tool confirms the line is unchanged before rewriting it). `replace` is **global and unverified** (you give a pattern, it rewrites every match sight-unseen). Pick by intent: change a known spot → `edit`; transform every occurrence → `replace`.
6
45
 
7
46
  ## Design
8
47
 
9
- - **Per-line hash + line number, dual anchor**: `read` shows each line with a short hash (`3#aF3│code`); `edit` references `LINE#HASH`. The line number is the address; the hash is a checksum that the line at that address is still what was read.
10
- - **Line + content hash**: each line's hash mixes its 1-based line number into its content, so every line gets a unique hash by construction — no in-file collisions, no length extension. The hash changes only when the line's own content changes, never when a neighbor changes.
11
- - **Live, surgical verification**: at apply time each cited anchor's hash is recomputed from the CURRENT line content and compared — no stored snapshot, no whole-file stale check. A line that changed (or was misremembered) fails its own anchor; an unrelated change elsewhere never blocks the edit. No fuzzy matching, no boundary repair, no drift relocation.
12
- - **Chain edits without re-reading**: a successful `edit` returns `Updated anchors` (`LINE#HASH│`) for the lines it produced (and the line that shifted into a deletion gap), so the next edit to the same file can cite them directly.
13
- - **No legacy compatibility**: overrides the built-in `edit`/`read`. `edit` accepts only structured hashline ops; sending legacy `oldText`/`newText` is rejected at the schema layer (never silently degrades)so you always know whether hashline is actually in use.
48
+ - **Per-line hash + line number, dual anchor**: `read` shows each line as `3#aF3│code`; `edit` references `LINE#HASH`. The line number is the address; the hash is a checksum that the line at that address is still what was read.
49
+ - **Line folded into the hash**: each line's hash mixes its 1-based line number into its content, so every line is unique by construction — no in-file collisions, no length extension. The hash changes only when the line's own content changes, never when a neighbor changes.
50
+ - **Live, surgical verification**: at apply time each cited anchor's hash is recomputed from the current line content and compared — no stored snapshot, no whole-file stale check. A line that changed (or was misremembered) fails its own anchor; an unrelated change elsewhere never blocks the edit. No fuzzy matching, no boundary repair.
51
+ - **Shifted-anchor recovery**: a mismatched anchor isn't a dead end. The applicator rescans ±`shiftRadius` lines for the original content holding the original line number fixed and re-hashing each candidate (`hash(line, candidate) === cited` iff the candidate *is* the original) — and returns a ready-to-resend anchor on a unique hit, the candidate list when ambiguous, or the cited line's live content when nothing matches. The model retries without a re-read in the common drift case.
52
+ - **Atomic batches, all failures collected**: every op in one `edit` is verified against the same snapshot; if any anchor fails, *all* failures (each with its recovery) are returned together and nothing is written partial writes would shift lines and invalidate the very recovery info just returned.
53
+ - **Chain edits without re-reading**: a successful `edit` returns `Updated anchors` for the lines it produced (and the line that shifted into a deletion gap), so the next edit can cite them directly.
54
+ - **No legacy compatibility on `edit`**: `edit` accepts only structured hashline ops; sending legacy `oldText`/`newText` is rejected at the schema layer (never silently degrades) — so you always know whether hashline is actually in use. Bulk/regex replacement is a *separate* tool, `replace`, not an `edit` mode (see below).
14
55
 
15
56
  ## Protocol
16
57
 
@@ -18,11 +59,22 @@ Edits reference lines by `LINE#HASH` anchors (copied from `read` output) instead
18
59
 
19
60
  ```
20
61
  src/foo.ts · 6 lines
21
- 1#aF3│import { compute } from "./util"
22
- 2#7Qk│
23
- 3#mP0│export function foo(x: number) {
62
+ 1#aF3│import { compute } from "./util"
63
+ 2#7Qk│
64
+ 3#mP0│export function foo(x: number) {
65
+ ```
66
+
67
+ `grep` output (results grouped by file, each line anchored — copy `LINE#HASH` straight into an edit):
68
+
69
+ ```
70
+ src/foo.ts · 2 matches
71
+ 3#mP0│export function foo(x: number) {
72
+ 4#kLp│ return x + 1
73
+ src/util.ts · 1 match
74
+ 10#aF3│ const z = compute(x)
24
75
  ```
25
76
 
77
+
26
78
  `edit` takes `path` + `edits` (an array of ops, each with `op`, `anchor`/`end` `{line, hash}` from read, and `body` string[]):
27
79
 
28
80
  ```jsonc
@@ -37,6 +89,42 @@ src/foo.ts · 6 lines
37
89
 
38
90
  Ops: `replace` · `delete` · `insert_after` · `insert_before` · `append` · `prepend`. `anchor`/`end` = `{line, hash}` from read; `body` = new content lines (string[], omit for `delete`).
39
91
 
92
+ `replace` takes `path`, `find`, `replace` (+ optional `regex`, `flags`, `maxMatches`) and substitutes **every** match:
93
+
94
+ ```jsonc
95
+ {
96
+ "path": "src/foo.ts",
97
+ "find": "oldName",
98
+ "replace": "newName"
99
+ }
100
+ ```
101
+
102
+ Regex with a capture group (rename `getName()` → `get_name()` everywhere):
103
+
104
+ ```jsonc
105
+ { "path": "src/foo.ts", "find": "get([A-Z]\w*)", "replace": "get_$1", "regex": true }
106
+ ```
107
+
108
+ Case-insensitive literal rename across the whole file:
109
+
110
+ ```jsonc
111
+ { "path": "src/foo.ts", "find": "TODO", "replace": "FIXME", "flags": "i" }
112
+ ```
113
+
114
+ ## Configuration
115
+
116
+ Add a `hashlineEdit` field to `~/.pi/agent/settings.json` (global) or `.pi/settings.json` in a project (project replaces global):
117
+
118
+ ```jsonc
119
+ {
120
+ "hashlineEdit": {
121
+ "enabled": true, // set false to fall back to the built-in read/edit
122
+ "hashLen": 4, // hash length, 2–8 (default 4)
123
+ "shiftRadius": 15 // ±lines scanned to rescue a stale anchor (default 15; 0 disables)
124
+ }
125
+ }
126
+ ```
127
+
40
128
  ## Installation
41
129
 
42
130
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-hashline-edit",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Hashline-style file editing for pi — line-anchored edits verified by content hash, replacing oldText/newText matching",
6
6
  "keywords": [
@@ -103,14 +103,14 @@ test("anchor hash mismatch rejected (line changed)", () => {
103
103
  const text = "a\nb\n";
104
104
  const r = applyEdits(text, [{ op: "replace", start: { line: 1, hash: "WRONG" }, body: ["x"] }]);
105
105
  assert.equal(r.ok, false);
106
- if (!r.ok) assert.equal(r.error.kind, "anchor");
106
+ if (!r.ok) assert.equal(r.failure.kind, "anchor");
107
107
  });
108
108
 
109
109
  test("line out of range rejected", () => {
110
110
  const text = "a\n";
111
111
  const r = applyEdits(text, [{ op: "replace", start: { line: 5, hash: computeLineHash(1, "a") }, body: ["x"] }]);
112
112
  assert.equal(r.ok, false);
113
- if (!r.ok) assert.equal(r.error.kind, "anchor");
113
+ if (!r.ok) assert.equal(r.failure.kind, "anchor");
114
114
  });
115
115
 
116
116
  test("anchor mismatch when the cited line's content differs (live verification)", () => {
@@ -118,14 +118,14 @@ test("anchor mismatch when the cited line's content differs (live verification)"
118
118
  const text = "a\nb\n";
119
119
  const r = applyEdits(text, [{ op: "replace", start: { line: 1, hash: computeLineHash(2, "b") }, body: ["x"] }]);
120
120
  assert.equal(r.ok, false);
121
- if (!r.ok) assert.equal(r.error.kind, "anchor");
121
+ if (!r.ok) assert.equal(r.failure.kind, "anchor");
122
122
  });
123
123
 
124
124
  test("reverse-order range rejected", () => {
125
125
  const text = "a\nb\nc\n";
126
126
  const r = applyEdits(text, [{ op: "replace", start: at(text, 3), end: at(text, 1), body: ["x"] }]);
127
127
  assert.equal(r.ok, false);
128
- if (!r.ok) assert.equal(r.error.kind, "range");
128
+ if (!r.ok) assert.equal(r.failure.kind, "range");
129
129
  });
130
130
 
131
131
  test("overlapping edits rejected", () => {
@@ -135,7 +135,7 @@ test("overlapping edits rejected", () => {
135
135
  { op: "replace", start: at(text, 3), body: ["y"] },
136
136
  ]);
137
137
  assert.equal(r.ok, false);
138
- if (!r.ok) assert.equal(r.error.kind, "range");
138
+ if (!r.ok) assert.equal(r.failure.kind, "range");
139
139
  });
140
140
 
141
141
  test("conflict at the same insertion point rejected", () => {
@@ -145,14 +145,14 @@ test("conflict at the same insertion point rejected", () => {
145
145
  { op: "insert_after", anchor: at(text, 1), body: ["y"] },
146
146
  ]);
147
147
  assert.equal(r.ok, false);
148
- if (!r.ok) assert.equal(r.error.kind, "range");
148
+ if (!r.ok) assert.equal(r.failure.kind, "range");
149
149
  });
150
150
 
151
151
  test("noop (byte-identical body) rejected", () => {
152
152
  const text = "a\nb\n";
153
153
  const r = applyEdits(text, [{ op: "replace", start: at(text, 1), body: ["a"] }]);
154
154
  assert.equal(r.ok, false);
155
- if (!r.ok) assert.equal(r.error.kind, "noop");
155
+ if (!r.ok) assert.equal(r.failure.kind, "noop");
156
156
  });
157
157
 
158
158
  test("unrelated change elsewhere does NOT block the edit (no global stale check)", () => {
@@ -171,3 +171,94 @@ test("CRLF line endings preserved", () => {
171
171
  if (r.ok) assert.equal(r.text, "A\r\nb\r\n");
172
172
  });
173
173
 
174
+ // --- shifted-anchor recovery ---
175
+
176
+ test("shifted recovery: content moved down → found with a fresh anchor", () => {
177
+ const readText = "a\nb\nc\nd\ne\n";
178
+ const currentText = "a\nX\nb\nc\nd\ne\n"; // inserted X after line 1 → "c" moved 3→4
179
+ const r = applyEdits(currentText, [{ op: "replace", start: at(readText, 3), body: ["C"] }]);
180
+ assert.equal(r.ok, false);
181
+ if (!r.ok && r.failure.kind === "anchor") {
182
+ const f = r.failure.failures[0];
183
+ assert.equal(f.recovery.kind, "found");
184
+ if (f.recovery.kind === "found") {
185
+ assert.equal(f.recovery.newLine, 4);
186
+ // the rescued anchor must verify against the current file
187
+ assert.equal(computeLineHash(4, splitLines(currentText)[3]), f.recovery.newHash);
188
+ }
189
+ }
190
+ });
191
+
192
+ test("rescued anchor lets the retry succeed without a re-read", () => {
193
+ const readText = "a\nb\nc\nd\ne\n";
194
+ const currentText = "a\nX\nb\nc\nd\ne\n";
195
+ // first attempt with a stale anchor → rescued
196
+ const r1 = applyEdits(currentText, [{ op: "replace", start: at(readText, 3), body: ["C"] }]);
197
+ assert.equal(r1.ok, false);
198
+ if (r1.ok) return;
199
+ if (r1.failure.kind !== "anchor") return;
200
+ const f = r1.failure.failures[0];
201
+ assert.equal(f.recovery.kind, "found");
202
+ if (f.recovery.kind !== "found") return;
203
+ // retry with the rescued anchor → succeeds, file unchanged elsewhere
204
+ const r2 = applyEdits(currentText, [
205
+ { op: "replace", start: { line: f.recovery.newLine, hash: f.recovery.newHash }, body: ["C"] },
206
+ ]);
207
+ assert.equal(r2.ok, true);
208
+ if (r2.ok) assert.equal(r2.text, "a\nX\nb\nC\nd\ne\n");
209
+ });
210
+
211
+ test("shifted recovery: duplicate content → ambiguous candidates", () => {
212
+ const readText = "a\nx\nb\nx\nc\n";
213
+ const currentText = "a\nY\nx\nb\nx\nc\n"; // both "x" shifted
214
+ const r = applyEdits(currentText, [{ op: "replace", start: at(readText, 2), body: ["Z"] }]);
215
+ assert.equal(r.ok, false);
216
+ if (!r.ok && r.failure.kind === "anchor") {
217
+ const f = r.failure.failures[0];
218
+ assert.equal(f.recovery.kind, "ambiguous");
219
+ if (f.recovery.kind === "ambiguous") {
220
+ assert.deepEqual(
221
+ f.recovery.candidates.map((c) => c.line),
222
+ [3, 5],
223
+ );
224
+ }
225
+ }
226
+ });
227
+
228
+ test("shifted recovery: content genuinely changed → none, with live content", () => {
229
+ const readText = "a\nb\nc\n";
230
+ const currentText = "a\nBCHANGED\nc\n"; // "b" is gone
231
+ const r = applyEdits(currentText, [{ op: "replace", start: at(readText, 2), body: ["B"] }]);
232
+ assert.equal(r.ok, false);
233
+ if (!r.ok && r.failure.kind === "anchor") {
234
+ const f = r.failure.failures[0];
235
+ assert.equal(f.recovery.kind, "none");
236
+ assert.equal(f.current?.content, "BCHANGED");
237
+ }
238
+ });
239
+
240
+ test("collect-all: two stale anchors in one batch → both failures returned", () => {
241
+ const readText = "a\nb\nc\nd\n";
242
+ const currentText = "X\na\nb\nc\nd\n"; // inserted X at top → all shifted +1
243
+ const r = applyEdits(currentText, [
244
+ { op: "replace", start: at(readText, 2), body: ["B"] },
245
+ { op: "replace", start: at(readText, 4), body: ["D"] },
246
+ ]);
247
+ assert.equal(r.ok, false);
248
+ if (!r.ok && r.failure.kind === "anchor") {
249
+ assert.equal(r.failure.failures.length, 2);
250
+ const found = r.failure.failures.map((f) => (f.recovery.kind === "found" ? f.recovery.newLine : -1));
251
+ assert.deepEqual(found, [3, 5]);
252
+ }
253
+ });
254
+
255
+ test("shiftRadius=0 disables rescue (always none)", () => {
256
+ const readText = "a\nb\nc\nd\ne\n";
257
+ const currentText = "a\nX\nb\nc\nd\ne\n";
258
+ const r = applyEdits(currentText, [{ op: "replace", start: at(readText, 3), body: ["C"] }], 4, 0);
259
+ assert.equal(r.ok, false);
260
+ if (!r.ok && r.failure.kind === "anchor") {
261
+ assert.equal(r.failure.failures[0].recovery.kind, "none");
262
+ }
263
+ });
264
+
package/src/core/apply.ts CHANGED
@@ -7,6 +7,20 @@
7
7
  * misremembered) fails its own anchor; unchanged lines elsewhere never block
8
8
  * the edit.
9
9
  *
10
+ * Shifted-anchor recovery: when a cited anchor no longer matches, we rescan
11
+ * ±radius lines for the original content, holding the ORIGINAL line number fixed
12
+ * and re-hashing each candidate's content. On a unique hit the new anchor (with
13
+ * its freshly computed hash) is returned so the caller can retry without a
14
+ * re-read; on several hits they are reported as ambiguous; on none the live
15
+ * content at the cited line is returned to steer a re-read.
16
+ *
17
+ * Batch semantics: all ops are verified against the same current snapshot. If
18
+ * ANY anchor fails, EVERY failure (with recovery) is collected and returned
19
+ * together — nothing is written. This keeps the rescue report and the on-disk
20
+ * file in sync: a partial write would shift lines and invalidate the very
21
+ * recovery info we just returned. Range issues among the surviving ops are
22
+ * deferred until anchors are corrected.
23
+ *
10
24
  * Other strict semantics:
11
25
  * - Operation ranges must not overlap (including the same insertion point).
12
26
  * - body byte-identical to the whole-file result → `noop` error (guides the
@@ -17,7 +31,7 @@
17
31
 
18
32
  import { computeLineHash } from "./hash.ts";
19
33
  import { detectLineEnding, joinLines, splitLines } from "./lines.ts";
20
- import type { Anchor, ApplyResult, Edit, PatchError } from "./types.ts";
34
+ import type { Anchor, AnchorFailure, AnchorRecovery, ApplyResult, Edit } from "./types.ts";
21
35
 
22
36
  /** Line-level operation: replace the raw lines in the `[lo, hi)` range (0-based, hi exclusive) with newLines. */
23
37
  interface SpanOp {
@@ -26,40 +40,94 @@ interface SpanOp {
26
40
  newLines: string[];
27
41
  }
28
42
 
29
- /** Verify an anchor against the current file: line in range and hash matches the live content. */
30
- function checkAnchor(lines: readonly string[], line: number, hash: string, hashLen: number): PatchError | null {
31
- if (line < 1 || line > lines.length) {
32
- return {
33
- kind: "anchor",
34
- message: `line ${line} does not exist (file has ${lines.length} lines)`,
35
- };
43
+ /** Default ±line radius for shifted-anchor recovery. */
44
+ const DEFAULT_SHIFT_RADIUS = 15;
45
+
46
+ /**
47
+ * Verify an anchor against the live content; on mismatch, attempt shifted
48
+ * recovery. Returns null when the anchor matches, otherwise an
49
+ * {@link AnchorFailure} carrying the recovery outcome and the cited line's
50
+ * current snapshot.
51
+ *
52
+ * Recovery holds the ORIGINAL line number fixed and re-hashes each candidate's
53
+ * content: `computeLineHash(citedLine, candidateContent) === citedHash` holds
54
+ * iff the candidate IS the original content (modulo negligible hash collision).
55
+ * A returned candidate's anchor uses the candidate's real line number with a
56
+ * hash computed for that line, so it verifies on retry.
57
+ */
58
+ function verifyAnchor(
59
+ lines: readonly string[],
60
+ cited: Anchor,
61
+ which: "anchor" | "end",
62
+ opIndex: number,
63
+ op: Edit["op"],
64
+ hashLen: number,
65
+ radius: number,
66
+ ): AnchorFailure | null {
67
+ const { line, hash } = cited;
68
+ if (line >= 1 && line <= lines.length && computeLineHash(line, lines[line - 1], hashLen) === hash) {
69
+ return null;
36
70
  }
37
- if (computeLineHash(line, lines[line - 1], hashLen) !== hash) {
38
- return {
39
- kind: "anchor",
40
- message: `line ${line} changed or hash is wrong: re-read to get the current #HASH`,
41
- };
71
+
72
+ // Shifted recovery: scan ±radius (excluding the already-failed cited line).
73
+ const candidates: { line: number; hash: string }[] = [];
74
+ const lo = Math.max(1, line - radius);
75
+ const hi = Math.min(lines.length, line + radius);
76
+ for (let c = lo; c <= hi; c++) {
77
+ if (c === line) continue;
78
+ if (computeLineHash(line, lines[c - 1], hashLen) === hash) {
79
+ candidates.push({ line: c, hash: computeLineHash(c, lines[c - 1], hashLen) });
80
+ }
81
+ }
82
+
83
+ let recovery: AnchorRecovery;
84
+ if (candidates.length === 1) {
85
+ recovery = { kind: "found", newLine: candidates[0].line, newHash: candidates[0].hash };
86
+ } else if (candidates.length > 1) {
87
+ recovery = { kind: "ambiguous", candidates };
88
+ } else {
89
+ recovery = { kind: "none" };
42
90
  }
43
- return null;
91
+
92
+ const current =
93
+ line >= 1 && line <= lines.length
94
+ ? { hash: computeLineHash(line, lines[line - 1], hashLen), content: lines[line - 1] }
95
+ : null;
96
+
97
+ return { opIndex, which, op, cited, recovery, current };
44
98
  }
45
99
 
100
+ type TranslateResult =
101
+ | { readonly ok: true; readonly op: SpanOp }
102
+ | { readonly ok: false; readonly anchorFailures: AnchorFailure[] }
103
+ | { readonly ok: false; readonly rangeError: string };
104
+
46
105
  /** Translate an Edit into a SpanOp, verifying anchors and ranges against the current lines. */
47
- function translateEdit(edit: Edit, lines: readonly string[], hashLen: number): { op: SpanOp } | { error: PatchError } {
106
+ function translateEdit(
107
+ edit: Edit,
108
+ opIndex: number,
109
+ lines: readonly string[],
110
+ hashLen: number,
111
+ radius: number,
112
+ ): TranslateResult {
48
113
  switch (edit.op) {
49
114
  case "replace":
50
115
  case "delete": {
51
- const startErr = checkAnchor(lines, edit.start.line, edit.start.hash, hashLen);
52
- if (startErr) return { error: startErr };
116
+ const failures: AnchorFailure[] = [];
117
+ const startF = verifyAnchor(lines, edit.start, "anchor", opIndex, edit.op, hashLen, radius);
118
+ if (startF) failures.push(startF);
53
119
  let endLine = edit.start.line;
54
120
  if (edit.end) {
55
- const endErr = checkAnchor(lines, edit.end.line, edit.end.hash, hashLen);
56
- if (endErr) return { error: endErr };
121
+ const endF = verifyAnchor(lines, edit.end, "end", opIndex, edit.op, hashLen, radius);
122
+ if (endF) failures.push(endF);
57
123
  endLine = edit.end.line;
58
124
  }
125
+ if (failures.length > 0) return { ok: false, anchorFailures: failures };
59
126
  if (endLine < edit.start.line) {
60
- return { error: { kind: "range", message: `range ${edit.start.line}..${endLine} ends before it starts` } };
127
+ return { ok: false, rangeError: `range ${edit.start.line}..${endLine} ends before it starts` };
61
128
  }
62
129
  return {
130
+ ok: true,
63
131
  op: {
64
132
  lo: edit.start.line - 1,
65
133
  hi: endLine,
@@ -68,20 +136,20 @@ function translateEdit(edit: Edit, lines: readonly string[], hashLen: number): {
68
136
  };
69
137
  }
70
138
  case "insert_after": {
71
- const err = checkAnchor(lines, edit.anchor.line, edit.anchor.hash, hashLen);
72
- if (err) return { error: err };
73
- return { op: { lo: edit.anchor.line, hi: edit.anchor.line, newLines: edit.body } };
139
+ const f = verifyAnchor(lines, edit.anchor, "anchor", opIndex, edit.op, hashLen, radius);
140
+ if (f) return { ok: false, anchorFailures: [f] };
141
+ return { ok: true, op: { lo: edit.anchor.line, hi: edit.anchor.line, newLines: edit.body } };
74
142
  }
75
143
  case "insert_before": {
76
- const err = checkAnchor(lines, edit.anchor.line, edit.anchor.hash, hashLen);
77
- if (err) return { error: err };
78
- return { op: { lo: edit.anchor.line - 1, hi: edit.anchor.line - 1, newLines: edit.body } };
144
+ const f = verifyAnchor(lines, edit.anchor, "anchor", opIndex, edit.op, hashLen, radius);
145
+ if (f) return { ok: false, anchorFailures: [f] };
146
+ return { ok: true, op: { lo: edit.anchor.line - 1, hi: edit.anchor.line - 1, newLines: edit.body } };
79
147
  }
80
148
  case "append": {
81
- return { op: { lo: lines.length, hi: lines.length, newLines: edit.body } };
149
+ return { ok: true, op: { lo: lines.length, hi: lines.length, newLines: edit.body } };
82
150
  }
83
151
  case "prepend": {
84
- return { op: { lo: 0, hi: 0, newLines: edit.body } };
152
+ return { ok: true, op: { lo: 0, hi: 0, newLines: edit.body } };
85
153
  }
86
154
  }
87
155
  }
@@ -94,21 +162,40 @@ function maxAffected(op: SpanOp): number {
94
162
  /**
95
163
  * Apply edits to `text`. Anchors are verified against the current content; on
96
164
  * success `touchedLines` gives the 0-based indices of the new-file lines this
97
- * edit produced.
165
+ * edit produced. On any anchor mismatch, all failures (with shifted recovery)
166
+ * are collected and returned together — nothing is written.
98
167
  *
99
- * @param text current full file text
100
- * @param edits parsed edit operations
101
- * @param hashLen hash length used to verify anchors (default 4)
168
+ * @param text current full file text
169
+ * @param edits parsed edit operations
170
+ * @param hashLen hash length used to verify anchors (default 4)
171
+ * @param shiftRadius ±line radius for shifted-anchor recovery (default 15; 0 disables rescue)
102
172
  */
103
- export function applyEdits(text: string, edits: Edit[], hashLen = 4): ApplyResult {
173
+ export function applyEdits(text: string, edits: Edit[], hashLen = 4, shiftRadius = DEFAULT_SHIFT_RADIUS): ApplyResult {
104
174
  const lines = splitLines(text);
105
175
  const ending = detectLineEnding(text);
106
176
 
107
177
  const ops: SpanOp[] = [];
108
- for (const edit of edits) {
109
- const t = translateEdit(edit, lines, hashLen);
110
- if ("error" in t) return { ok: false, error: t.error };
111
- ops.push(t.op);
178
+ const anchorFailures: AnchorFailure[] = [];
179
+ let rangeError: string | null = null;
180
+
181
+ for (let i = 0; i < edits.length; i++) {
182
+ const t = translateEdit(edits[i], i, lines, hashLen, shiftRadius);
183
+ if (t.ok) {
184
+ ops.push(t.op);
185
+ } else if ("anchorFailures" in t) {
186
+ anchorFailures.push(...t.anchorFailures);
187
+ } else if (rangeError === null) {
188
+ rangeError = t.rangeError;
189
+ }
190
+ }
191
+
192
+ // Anchor failures take priority: the model must fix anchors first; range
193
+ // issues among surviving ops are premature until anchors are corrected.
194
+ if (anchorFailures.length > 0) {
195
+ return { ok: false, failure: { kind: "anchor", failures: anchorFailures } };
196
+ }
197
+ if (rangeError !== null) {
198
+ return { ok: false, failure: { kind: "range", message: rangeError } };
112
199
  }
113
200
 
114
201
  // Overlap check: sort ascending by lo; the next op's start must not fall inside the previous op's affected range
@@ -117,7 +204,7 @@ export function applyEdits(text: string, edits: Edit[], hashLen = 4): ApplyResul
117
204
  if (sorted[k].lo <= maxAffected(sorted[k - 1])) {
118
205
  return {
119
206
  ok: false,
120
- error: {
207
+ failure: {
121
208
  kind: "range",
122
209
  message: `overlapping edits near line ${sorted[k].lo + 1}; issue one edit per range`,
123
210
  },
@@ -135,7 +222,7 @@ export function applyEdits(text: string, edits: Edit[], hashLen = 4): ApplyResul
135
222
  if (newText === text) {
136
223
  return {
137
224
  ok: false,
138
- error: {
225
+ failure: {
139
226
  kind: "noop",
140
227
  message: "edit parsed and applied cleanly but produced no change; body is byte-identical — the bug is elsewhere, re-read first",
141
228
  },
package/src/core/hash.ts CHANGED
@@ -18,10 +18,6 @@
18
18
  * - Content alone would leave identical lines (blank lines, `}`) sharing a
19
19
  * hash; mixing the line number disambiguates them for free.
20
20
  *
21
- * Drift (file changed since read) is caught up-front by the global stale check
22
- * (`text !== snapshot.text`). This hash's job is to verify the model actually
23
- * read the line — it cannot forge a `(line, content)` hash without reading.
24
- *
25
21
  * @module pi-hashline-edit/core
26
22
  */
27
23
 
package/src/core/types.ts CHANGED
@@ -25,22 +25,57 @@ export type Edit =
25
25
 
26
26
  export type LineEnding = "lf" | "crlf";
27
27
 
28
- /** Error kinds. */
29
- export type PatchErrorKind =
30
- | "anchor" // anchor hash does not match the current line content (line changed, or model misremembered) or line out of range
31
- | "range" // illegal operation range (overlap, reverse order, etc.)
32
- | "noop"; // edit produced no change (body byte-identical to the target)
28
+ /**
29
+ * Outcome of shifted-anchor recovery. When a cited anchor's hash no longer
30
+ * matches the live content, the applicator rescans ±radius lines for the
31
+ * original content holding the ORIGINAL line number fixed and re-hashing each
32
+ * candidate's content (`computeLineHash(citedLine, candidateContent) === citedHash`
33
+ * iff the candidate is the original content). A ready-to-resend anchor (with the
34
+ * freshly computed hash) is returned so the model can retry without a re-read.
35
+ *
36
+ * - `found` — exactly one nearby line holds the original content; resend the op
37
+ * with the provided anchor.
38
+ * - `ambiguous` — several nearby lines match (e.g. duplicate content); the model
39
+ * picks the right one from the candidates (each carries its own new hash).
40
+ * - `none` — the content genuinely changed; re-read.
41
+ */
42
+ export type AnchorRecovery =
43
+ | { readonly kind: "found"; readonly newLine: number; readonly newHash: string }
44
+ | {
45
+ readonly kind: "ambiguous";
46
+ readonly candidates: ReadonlyArray<{ readonly line: number; readonly hash: string }>;
47
+ }
48
+ | { readonly kind: "none" };
33
49
 
34
- export interface PatchError {
35
- readonly kind: PatchErrorKind;
36
- readonly message: string;
50
+ /**
51
+ * A single anchor that failed verification, with its recovery attempt.
52
+ *
53
+ * `opIndex` is the 0-based position in the input `edits[]`; `which` names the
54
+ * op's anchor (`"anchor"` = start, `"end"` = range end); `op` is the op kind.
55
+ * `current` is the cited line's live content + hash (null if the line number is
56
+ * out of range) — surfaced when recovery is `none` so the model can self-diagnose.
57
+ */
58
+ export interface AnchorFailure {
59
+ readonly opIndex: number;
60
+ readonly which: "anchor" | "end";
61
+ readonly op: Edit["op"];
62
+ readonly cited: Anchor;
63
+ readonly recovery: AnchorRecovery;
64
+ readonly current: { readonly hash: string; readonly content: string } | null;
37
65
  }
38
66
 
67
+ /** Batch-level failure. `anchor` carries every per-anchor failure collected across the batch. */
68
+ export type ApplyFailure =
69
+ | { readonly kind: "anchor"; readonly failures: readonly AnchorFailure[] }
70
+ | { readonly kind: "range"; readonly message: string }
71
+ | { readonly kind: "noop"; readonly message: string };
72
+
39
73
  /**
40
74
  * Apply result. On success, `touchedLines` lists the 0-based line indices in
41
75
  * the NEW file that this edit produced (inserted or replaced) — callers use it
42
76
  * to surface fresh `LINE#HASH` anchors so the model can chain edits without a
43
- * re-read.
77
+ * re-read. On failure, `failure` is either the collected set of anchor failures
78
+ * (each with recovery) or a single range/noop error; nothing is written.
44
79
  */
45
80
  export type ApplyResult =
46
81
  | {
@@ -49,4 +84,4 @@ export type ApplyResult =
49
84
  readonly changed: boolean;
50
85
  readonly touchedLines: readonly number[];
51
86
  }
52
- | { readonly ok: false; readonly error: PatchError };
87
+ | { readonly ok: false; readonly failure: ApplyFailure };
package/src/index.ts CHANGED
@@ -3,8 +3,11 @@
3
3
  *
4
4
  * Overrides the built-in read/edit: read outputs "lineNo#hash│content";
5
5
  * edit accepts structured hashline ops (edits[] with LINE#HASH anchors), and
6
- * legacy oldText/newText is rejected explicitly (no silent degradation). Each
7
- * tool carries its own renderer.
6
+ * legacy oldText/newText is rejected explicitly (no silent degradation). grep
7
+ * is overridden the same way so results carry usable anchors. A separate
8
+ * `replace` tool adds location-blind bulk + regex replacement (replaceAll and
9
+ * full JS regex with capture groups) for renames/pattern transforms that
10
+ * would need many individual edits. Each tool carries its own renderer.
8
11
  *
9
12
  * @module pi-hashline-edit
10
13
  */
@@ -14,6 +17,8 @@ import { loadConfig } from "./pi/config.ts";
14
17
  import { getState } from "./pi/state.ts";
15
18
  import { makeEditOverride } from "./pi/edit-tool.ts";
16
19
  import { makeReadOverride } from "./pi/read-tool.ts";
20
+ import { makeGrepOverride } from "./pi/grep-tool.ts";
21
+ import { makeReplaceTool } from "./pi/replace-tool.ts";
17
22
 
18
23
  export default function (pi: ExtensionAPI) {
19
24
  const cwd = process.cwd();
@@ -26,4 +31,6 @@ export default function (pi: ExtensionAPI) {
26
31
 
27
32
  pi.registerTool(makeReadOverride(cwd));
28
33
  pi.registerTool(makeEditOverride(cwd));
34
+ pi.registerTool(makeGrepOverride(cwd));
35
+ pi.registerTool(makeReplaceTool(cwd));
29
36
  }