opencode-hashline 1.2.0 → 1.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.en.md CHANGED
@@ -104,6 +104,49 @@ if (!result.valid) {
104
104
 
105
105
  Hash verification uses the length of the provided hash reference (not the current file size), so a reference like `2:f1` remains valid even if the file has grown.
106
106
 
107
+ ### 🔒 File Revision (`fileRev`)
108
+
109
+ In addition to per-line hashes, hashline computes a whole-file hash (FNV-1a, 8 hex chars). It's prepended as the first annotation line:
110
+
111
+ ```
112
+ #HL REV:72c4946c
113
+ #HL 1:a3f|function hello() {
114
+ #HL 2:f1c| return "world";
115
+ ```
116
+
117
+ Pass `fileRev` to `hashline_edit` when editing — if the file changed since it was read, the edit is rejected with `FILE_REV_MISMATCH`.
118
+
119
+ ### 🔄 Safe Reapply
120
+
121
+ If a line moved (e.g., due to insertions above), `safeReapply` finds it by content hash:
122
+
123
+ - **1 candidate** — edit applies at the new position
124
+ - **>1 candidates** — `AMBIGUOUS_REAPPLY` error (ambiguous)
125
+ - **0 candidates** — `HASH_MISMATCH` error
126
+
127
+ ```typescript
128
+ const result = applyHashEdit(
129
+ { operation: "replace", startRef: "1:a3f", replacement: "new" },
130
+ content,
131
+ undefined,
132
+ true, // safeReapply
133
+ );
134
+ ```
135
+
136
+ ### 🏷️ Structured Errors
137
+
138
+ All hashline errors are instances of `HashlineError` with error codes, diagnostics, and hints:
139
+
140
+ | Code | Description |
141
+ |------|-------------|
142
+ | `HASH_MISMATCH` | Line content changed since last read |
143
+ | `FILE_REV_MISMATCH` | File was modified since last read |
144
+ | `AMBIGUOUS_REAPPLY` | Multiple candidates found during safe reapply |
145
+ | `TARGET_OUT_OF_RANGE` | Line number exceeds file length |
146
+ | `INVALID_REF` | Malformed hash reference |
147
+ | `INVALID_RANGE` | Start line is after end line |
148
+ | `MISSING_REPLACEMENT` | Replace/insert operation without content |
149
+
107
150
  ### 🔍 Indentation-Sensitive Hashing
108
151
 
109
152
  Hash computation uses `trimEnd()` (not `trim()`), so changes to leading whitespace (indentation) are detected as content changes, while trailing whitespace is ignored.
@@ -155,6 +198,8 @@ const isExcluded = hl.shouldExclude("node_modules/foo.js"); // true
155
198
  | `hashLength` | `number \| undefined` | `undefined` (adaptive) | Force specific hash length |
156
199
  | `cacheSize` | `number` | `100` | Max files in LRU cache |
157
200
  | `prefix` | `string \| false` | `"#HL "` | Line prefix (`false` to disable) |
201
+ | `fileRev` | `boolean` | `true` | Include file revision hash (`#HL REV:...`) in annotations |
202
+ | `safeReapply` | `boolean` | `false` | Auto-relocate moved lines by content hash |
158
203
 
159
204
  Default exclude patterns cover: lock files, `node_modules`, minified files, binary files (images, fonts, archives, etc.).
160
205
 
@@ -442,9 +487,12 @@ The idea behind hashline is inspired by concepts from **oh-my-pi** by [can1357](
442
487
 
443
488
  Hashline solves this by assigning each line a short, deterministic hash tag (e.g. `2:f1c`), making line addressing **exact and unambiguous**. The model can reference any line or range precisely, eliminating off-by-one errors and duplicate-line confusion.
444
489
 
490
+ The advanced features — **file revision** (`fileRev`), **safe reapply**, and **structured errors** — are inspired by the hash-based editing implementation in **AssistAgents** by [OzeroHAX](https://github.com/OzeroHAX/AssistAgents), which independently applied a similar approach for OpenCode with additional integrity checks and error diagnostics.
491
+
445
492
  **References:**
446
493
  - [oh-my-pi by can1357](https://github.com/can1357/oh-my-pi) — AI coding agent toolkit: coding agent CLI, unified LLM API, TUI libraries
447
494
  - [The Harness Problem](https://blog.can.ac/2026/02/12/the-harness-problem/) — blog post describing the problem in detail
495
+ - [AssistAgents by OzeroHAX](https://github.com/OzeroHAX/AssistAgents) — hash-based editing for OpenCode with file revision, safe reapply, and structured conflicts
448
496
  - [Описание подхода на Хабре](https://habr.com/ru/companies/bothub/news/995986/) — overview of the approach in Russian
449
497
 
450
498
  ---
package/README.md CHANGED
@@ -104,6 +104,49 @@ if (!result.valid) {
104
104
 
105
105
  Верификация хешей использует длину предоставленной хеш-ссылки (а не текущий размер файла), поэтому ссылка вроде `2:f1` остаётся валидной даже если файл вырос.
106
106
 
107
+ ### 🔒 Ревизия файла (`fileRev`)
108
+
109
+ Помимо построчных хешей, hashline вычисляет хеш всего файла (FNV-1a, 8 hex-символов). Он добавляется первой строкой аннотации:
110
+
111
+ ```
112
+ #HL REV:72c4946c
113
+ #HL 1:a3f|function hello() {
114
+ #HL 2:f1c| return "world";
115
+ ```
116
+
117
+ При редактировании передайте `fileRev` в `hashline_edit` — если файл изменился с момента чтения, правка будет отклонена с ошибкой `FILE_REV_MISMATCH`.
118
+
119
+ ### 🔄 Safe Reapply
120
+
121
+ Если строка переместилась (например, из-за вставки строк выше), `safeReapply` находит её по хешу контента:
122
+
123
+ - **1 кандидат** — правка применяется к новой позиции
124
+ - **>1 кандидатов** — ошибка `AMBIGUOUS_REAPPLY` (неоднозначность)
125
+ - **0 кандидатов** — ошибка `HASH_MISMATCH`
126
+
127
+ ```typescript
128
+ const result = applyHashEdit(
129
+ { operation: "replace", startRef: "1:a3f", replacement: "new" },
130
+ content,
131
+ undefined,
132
+ true, // safeReapply
133
+ );
134
+ ```
135
+
136
+ ### 🏷️ Structured Errors
137
+
138
+ Все ошибки hashline — экземпляры `HashlineError` с кодом, диагностикой и подсказками:
139
+
140
+ | Код | Описание |
141
+ |-----|----------|
142
+ | `HASH_MISMATCH` | Содержимое строки изменилось |
143
+ | `FILE_REV_MISMATCH` | Файл модифицирован с момента чтения |
144
+ | `AMBIGUOUS_REAPPLY` | Несколько кандидатов при safe reapply |
145
+ | `TARGET_OUT_OF_RANGE` | Номер строки за пределами файла |
146
+ | `INVALID_REF` | Некорректная хеш-ссылка |
147
+ | `INVALID_RANGE` | Начало диапазона после конца |
148
+ | `MISSING_REPLACEMENT` | Операция replace/insert без содержимого |
149
+
107
150
  ### 🔍 Чувствительность к отступам
108
151
 
109
152
  Вычисление хеша использует `trimEnd()` (а не `trim()`), поэтому изменения ведущих пробелов (отступов) обнаруживаются как изменения содержимого, а завершающие пробелы игнорируются.
@@ -155,6 +198,8 @@ const isExcluded = hl.shouldExclude("node_modules/foo.js"); // true
155
198
  | `hashLength` | `number \| undefined` | `undefined` (адаптивно) | Принудительная длина хеша |
156
199
  | `cacheSize` | `number` | `100` | Макс. файлов в LRU-кеше |
157
200
  | `prefix` | `string \| false` | `"#HL "` | Префикс строки (`false` для отключения) |
201
+ | `fileRev` | `boolean` | `true` | Включать ревизию файла (`#HL REV:...`) в аннотации |
202
+ | `safeReapply` | `boolean` | `false` | Автоматический поиск перемещённых строк по хешу |
158
203
 
159
204
  Паттерны исключения по умолчанию: lock-файлы, `node_modules`, минифицированные файлы, бинарные файлы (изображения, шрифты, архивы и т.д.).
160
205
 
@@ -417,9 +462,12 @@ npm run typecheck
417
462
 
418
463
  Hashline решает эту проблему, присваивая каждой строке короткий детерминированный хеш-тег (например, `2:f1c`), что делает адресацию строк **точной и однозначной**. Модель может ссылаться на любую строку или диапазон без ошибок смещения и путаницы с дубликатами.
419
464
 
465
+ Продвинутые фичи — **ревизия файла** (`fileRev`), **safe reapply** и **structured errors** — вдохновлены реализацией hash-based editing в проекте **AssistAgents** от [OzeroHAX](https://github.com/OzeroHAX/AssistAgents), который независимо применил аналогичный подход для OpenCode с дополнительными механизмами проверки целостности и диагностики ошибок.
466
+
420
467
  **Ссылки:**
421
468
  - [oh-my-pi от can1357](https://github.com/can1357/oh-my-pi) — AI-тулкит для разработки: coding agent CLI, unified LLM API, TUI-библиотеки
422
469
  - [The Harness Problem](https://blog.can.ac/2026/02/12/the-harness-problem/) — блог-пост с подробным описанием проблемы
470
+ - [AssistAgents от OzeroHAX](https://github.com/OzeroHAX/AssistAgents) — hash-based editing для OpenCode с file revision, safe reapply и structured conflicts
423
471
  - [Статья на Хабре](https://habr.com/ru/companies/bothub/news/995986/) — описание подхода на русском языке
424
472
 
425
473
  ---
@@ -4,7 +4,7 @@ import {
4
4
  resolveConfig,
5
5
  shouldExclude,
6
6
  stripHashes
7
- } from "./chunk-I6RACR3D.js";
7
+ } from "./chunk-DOR4YDIS.js";
8
8
 
9
9
  // src/hooks.ts
10
10
  import { appendFileSync } from "fs";
@@ -94,7 +94,7 @@ function createFileReadAfterHook(cache, config) {
94
94
  return;
95
95
  }
96
96
  }
97
- const annotated = formatFileWithHashes(content, hashLen || void 0, prefix);
97
+ const annotated = formatFileWithHashes(content, hashLen || void 0, prefix, resolved.fileRev);
98
98
  output.output = annotated;
99
99
  debug("annotated", typeof filePath === "string" ? filePath : input.tool, "lines:", content.split("\n").length);
100
100
  if (cache && typeof filePath === "string") {
@@ -206,10 +206,32 @@ function createSystemPromptHook(config) {
206
206
  '- Hash references include both the line number AND the content hash, so `2:f1c` means "line 2 with hash f1c".',
207
207
  "- If you see a mismatch, do NOT proceed with the edit \u2014 re-read the file to get fresh references.",
208
208
  "",
209
+ "### File revision (`#HL REV:<hash>`):",
210
+ "- When files are read, the first line may contain a file revision header: `" + prefix + "REV:<8-char-hex>`.",
211
+ "- This is a hash of the entire file content. Pass it as the `fileRev` parameter to `hashline_edit` to verify the file hasn't changed.",
212
+ "- If the file was modified between read and edit, the revision check fails with `FILE_REV_MISMATCH` \u2014 re-read the file.",
213
+ "",
214
+ "### Safe reapply (`safeReapply`):",
215
+ "- Pass `safeReapply: true` to `hashline_edit` to enable automatic line relocation.",
216
+ "- If a line moved (e.g., due to insertions above), safe reapply finds it by content hash.",
217
+ "- If exactly one match is found, the edit proceeds at the new location.",
218
+ "- If multiple matches exist, the edit fails with `AMBIGUOUS_REAPPLY` \u2014 re-read the file.",
219
+ "",
220
+ "### Structured error codes:",
221
+ "- `HASH_MISMATCH` \u2014 line content changed since last read",
222
+ "- `FILE_REV_MISMATCH` \u2014 file was modified since last read",
223
+ "- `AMBIGUOUS_REAPPLY` \u2014 multiple candidate lines found during safe reapply",
224
+ "- `TARGET_OUT_OF_RANGE` \u2014 line number exceeds file length",
225
+ "- `INVALID_REF` \u2014 malformed hash reference",
226
+ "- `INVALID_RANGE` \u2014 start line is after end line",
227
+ "- `MISSING_REPLACEMENT` \u2014 replace/insert operation without replacement content",
228
+ "",
209
229
  "### Best practices:",
210
230
  "- Use hash references for all edit operations to ensure precision.",
211
231
  "- When making multiple edits, work from bottom to top to avoid line number shifts.",
212
- "- For large replacements, use range references (e.g., `1:a3f to 10:b2c`) instead of individual lines."
232
+ "- For large replacements, use range references (e.g., `1:a3f to 10:b2c`) instead of individual lines.",
233
+ "- Use `fileRev` to guard against stale edits on critical files.",
234
+ "- Use `safeReapply: true` when editing files that may have shifted due to earlier edits."
213
235
  ].join("\n")
214
236
  );
215
237
  };
@@ -56,7 +56,9 @@ var DEFAULT_CONFIG = {
56
56
  // 0 = adaptive
57
57
  cacheSize: 100,
58
58
  prefix: DEFAULT_PREFIX,
59
- debug: false
59
+ debug: false,
60
+ fileRev: true,
61
+ safeReapply: false
60
62
  };
61
63
  function resolveConfig(config, pluginConfig) {
62
64
  const merged = {
@@ -72,9 +74,55 @@ function resolveConfig(config, pluginConfig) {
72
74
  hashLength: merged.hashLength ?? DEFAULT_CONFIG.hashLength,
73
75
  cacheSize: merged.cacheSize ?? DEFAULT_CONFIG.cacheSize,
74
76
  prefix: merged.prefix !== void 0 ? merged.prefix : DEFAULT_CONFIG.prefix,
75
- debug: merged.debug ?? DEFAULT_CONFIG.debug
77
+ debug: merged.debug ?? DEFAULT_CONFIG.debug,
78
+ fileRev: merged.fileRev ?? DEFAULT_CONFIG.fileRev,
79
+ safeReapply: merged.safeReapply ?? DEFAULT_CONFIG.safeReapply
76
80
  };
77
81
  }
82
+ var HashlineError = class extends Error {
83
+ code;
84
+ expected;
85
+ actual;
86
+ candidates;
87
+ hint;
88
+ lineNumber;
89
+ filePath;
90
+ constructor(opts) {
91
+ super(opts.message);
92
+ this.name = "HashlineError";
93
+ this.code = opts.code;
94
+ this.expected = opts.expected;
95
+ this.actual = opts.actual;
96
+ this.candidates = opts.candidates;
97
+ this.hint = opts.hint;
98
+ this.lineNumber = opts.lineNumber;
99
+ this.filePath = opts.filePath;
100
+ }
101
+ toDiagnostic() {
102
+ const parts = [`[${this.code}] ${this.message}`];
103
+ if (this.filePath) {
104
+ parts.push(` File: ${this.filePath}`);
105
+ }
106
+ if (this.lineNumber !== void 0) {
107
+ parts.push(` Line: ${this.lineNumber}`);
108
+ }
109
+ if (this.expected !== void 0 && this.actual !== void 0) {
110
+ parts.push(` Expected hash: ${this.expected}`);
111
+ parts.push(` Actual hash: ${this.actual}`);
112
+ }
113
+ if (this.candidates && this.candidates.length > 0) {
114
+ parts.push(` Candidates (${this.candidates.length}):`);
115
+ for (const c of this.candidates) {
116
+ const preview = c.content.length > 60 ? c.content.slice(0, 60) + "..." : c.content;
117
+ parts.push(` - line ${c.lineNumber}: ${preview}`);
118
+ }
119
+ }
120
+ if (this.hint) {
121
+ parts.push(` Hint: ${this.hint}`);
122
+ }
123
+ return parts.join("\n");
124
+ }
125
+ };
78
126
  function fnv1aHash(str) {
79
127
  let hash = 2166136261;
80
128
  for (let i = 0; i < str.length; i++) {
@@ -104,7 +152,49 @@ function computeLineHash(idx, line, hashLen = 3) {
104
152
  const hash = raw % modulus;
105
153
  return hash.toString(16).padStart(hashLen, "0");
106
154
  }
107
- function formatFileWithHashes(content, hashLen, prefix) {
155
+ function computeFileRev(content) {
156
+ const normalized = content.includes("\r\n") ? content.replace(/\r\n/g, "\n") : content;
157
+ const hash = fnv1aHash(normalized);
158
+ return hash.toString(16).padStart(8, "0");
159
+ }
160
+ function extractFileRev(annotatedContent, prefix) {
161
+ const effectivePrefix = prefix === void 0 ? DEFAULT_PREFIX : prefix === false ? "" : prefix;
162
+ const escapedPrefix = effectivePrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
163
+ const pattern = new RegExp(`^${escapedPrefix}REV:([0-9a-f]{8})$`);
164
+ const firstLine = annotatedContent.split("\n")[0];
165
+ const match = firstLine.match(pattern);
166
+ return match ? match[1] : null;
167
+ }
168
+ function verifyFileRev(expectedRev, currentContent) {
169
+ const actualRev = computeFileRev(currentContent);
170
+ if (actualRev !== expectedRev) {
171
+ throw new HashlineError({
172
+ code: "FILE_REV_MISMATCH",
173
+ message: `File revision mismatch: expected "${expectedRev}", got "${actualRev}". The file has changed since it was last read.`,
174
+ expected: expectedRev,
175
+ actual: actualRev,
176
+ hint: "Re-read the file to get fresh hash references and a new file revision."
177
+ });
178
+ }
179
+ }
180
+ function findCandidateLines(originalLineNumber, expectedHash, lines, hashLen) {
181
+ const effectiveLen = hashLen && hashLen >= 2 ? hashLen : expectedHash.length;
182
+ const originalIdx = originalLineNumber - 1;
183
+ const candidates = [];
184
+ for (let i = 0; i < lines.length; i++) {
185
+ if (i === originalIdx) continue;
186
+ const candidateHash = computeLineHash(originalIdx, lines[i], effectiveLen);
187
+ if (candidateHash === expectedHash) {
188
+ candidates.push({
189
+ lineNumber: i + 1,
190
+ // 1-based
191
+ content: lines[i]
192
+ });
193
+ }
194
+ }
195
+ return candidates;
196
+ }
197
+ function formatFileWithHashes(content, hashLen, prefix, includeFileRev) {
108
198
  const normalized = content.includes("\r\n") ? content.replace(/\r\n/g, "\n") : content;
109
199
  const lines = normalized.split("\n");
110
200
  const effectiveLen = hashLen && hashLen >= 3 ? hashLen : getAdaptiveHashLength(lines.length);
@@ -128,9 +218,14 @@ function formatFileWithHashes(content, hashLen, prefix) {
128
218
  hashes[idx] = hash;
129
219
  }
130
220
  }
131
- return lines.map((line, idx) => {
221
+ const annotatedLines = lines.map((line, idx) => {
132
222
  return `${effectivePrefix}${idx + 1}:${hashes[idx]}|${line}`;
133
- }).join("\n");
223
+ });
224
+ if (includeFileRev) {
225
+ const rev = computeFileRev(content);
226
+ annotatedLines.unshift(`${effectivePrefix}REV:${rev}`);
227
+ }
228
+ return annotatedLines.join("\n");
134
229
  }
135
230
  var stripRegexCache = /* @__PURE__ */ new Map();
136
231
  function stripHashes(content, prefix) {
@@ -141,9 +236,10 @@ function stripHashes(content, prefix) {
141
236
  hashLinePattern = new RegExp(`^([+ \\-])?${escapedPrefix}\\d+:[0-9a-f]{2,8}\\|`);
142
237
  stripRegexCache.set(escapedPrefix, hashLinePattern);
143
238
  }
239
+ const revPattern = new RegExp(`^${escapedPrefix}REV:[0-9a-f]{8}$`);
144
240
  const lineEnding = detectLineEnding(content);
145
241
  const normalized = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
146
- const result = normalized.split("\n").map((line) => {
242
+ const result = normalized.split("\n").filter((line) => !revPattern.test(line)).map((line) => {
147
243
  const match = line.match(hashLinePattern);
148
244
  if (match) {
149
245
  const patchMarker = match[1] || "";
@@ -157,7 +253,10 @@ function parseHashRef(ref) {
157
253
  const match = ref.match(/^(\d+):([0-9a-f]{2,8})$/);
158
254
  if (!match) {
159
255
  const display = ref.length > 100 ? `${ref.slice(0, 100)}\u2026` : ref;
160
- throw new Error(`Invalid hash reference: "${display}". Expected format: "<line>:<2-8 char hex>"`);
256
+ throw new HashlineError({
257
+ code: "INVALID_REF",
258
+ message: `Invalid hash reference: "${display}". Expected format: "<line>:<2-8 char hex>"`
259
+ });
161
260
  }
162
261
  return {
163
262
  line: parseInt(match[1], 10),
@@ -175,9 +274,10 @@ function normalizeHashRef(ref) {
175
274
  return `${parseInt(annotated[1], 10)}:${annotated[2].toLowerCase()}`;
176
275
  }
177
276
  const display = ref.length > 100 ? `${ref.slice(0, 100)}\u2026` : ref;
178
- throw new Error(
179
- `Invalid hash reference: "${display}". Expected "<line>:<hash>" or an annotated line like "#HL <line>:<hash>|..."`
180
- );
277
+ throw new HashlineError({
278
+ code: "INVALID_REF",
279
+ message: `Invalid hash reference: "${display}". Expected "<line>:<hash>" or an annotated line like "#HL <line>:<hash>|..."`
280
+ });
181
281
  }
182
282
  function buildHashMap(content, hashLen) {
183
283
  const lines = content.split("\n");
@@ -190,50 +290,97 @@ function buildHashMap(content, hashLen) {
190
290
  }
191
291
  return map;
192
292
  }
193
- function verifyHash(lineNumber, hash, currentContent, hashLen, lines) {
293
+ function verifyHash(lineNumber, hash, currentContent, hashLen, lines, safeReapply) {
194
294
  const contentLines = lines ?? currentContent.split("\n");
195
295
  const effectiveLen = hashLen && hashLen >= 2 ? hashLen : hash.length;
196
296
  if (lineNumber < 1 || lineNumber > contentLines.length) {
197
297
  return {
198
298
  valid: false,
299
+ code: "TARGET_OUT_OF_RANGE",
199
300
  message: `Line ${lineNumber} is out of range (file has ${contentLines.length} lines)`
200
301
  };
201
302
  }
202
303
  const idx = lineNumber - 1;
203
304
  const actualHash = computeLineHash(idx, contentLines[idx], effectiveLen);
204
305
  if (actualHash !== hash) {
306
+ const candidates = findCandidateLines(lineNumber, hash, contentLines, effectiveLen);
307
+ if (safeReapply && candidates.length === 1) {
308
+ return {
309
+ valid: true,
310
+ relocatedLine: candidates[0].lineNumber,
311
+ candidates
312
+ };
313
+ }
314
+ if (safeReapply && candidates.length > 1) {
315
+ return {
316
+ valid: false,
317
+ code: "AMBIGUOUS_REAPPLY",
318
+ expected: hash,
319
+ actual: actualHash,
320
+ candidates,
321
+ message: `Hash mismatch at line ${lineNumber}: expected "${hash}", got "${actualHash}". Found ${candidates.length} candidate lines \u2014 ambiguous reapply.`
322
+ };
323
+ }
205
324
  return {
206
325
  valid: false,
326
+ code: "HASH_MISMATCH",
207
327
  expected: hash,
208
328
  actual: actualHash,
329
+ candidates,
209
330
  message: `Hash mismatch at line ${lineNumber}: expected "${hash}", got "${actualHash}". The file may have changed since it was read.`
210
331
  };
211
332
  }
212
333
  return { valid: true };
213
334
  }
214
- function resolveRange(startRef, endRef, content, hashLen) {
335
+ function resolveRange(startRef, endRef, content, hashLen, safeReapply) {
215
336
  const start = parseHashRef(startRef);
216
337
  const end = parseHashRef(endRef);
217
338
  if (start.line > end.line) {
218
- throw new Error(
219
- `Invalid range: start line ${start.line} is after end line ${end.line}`
220
- );
339
+ throw new HashlineError({
340
+ code: "INVALID_RANGE",
341
+ message: `Invalid range: start line ${start.line} is after end line ${end.line}`
342
+ });
221
343
  }
222
344
  const lineEnding = detectLineEnding(content);
223
345
  const normalized = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
224
346
  const lines = normalized.split("\n");
225
- const startVerify = verifyHash(start.line, start.hash, normalized, hashLen, lines);
347
+ const startVerify = verifyHash(start.line, start.hash, normalized, hashLen, lines, safeReapply);
226
348
  if (!startVerify.valid) {
227
- throw new Error(`Start reference invalid: ${startVerify.message}`);
349
+ throw new HashlineError({
350
+ code: startVerify.code ?? "HASH_MISMATCH",
351
+ message: `Start reference invalid: ${startVerify.message}`,
352
+ expected: startVerify.expected,
353
+ actual: startVerify.actual,
354
+ candidates: startVerify.candidates,
355
+ lineNumber: start.line,
356
+ hint: startVerify.candidates && startVerify.candidates.length > 0 ? `Content may have moved. Candidates: ${startVerify.candidates.map((c) => `line ${c.lineNumber}`).join(", ")}` : "Re-read the file to get fresh hash references."
357
+ });
228
358
  }
229
- const endVerify = verifyHash(end.line, end.hash, normalized, hashLen, lines);
359
+ const effectiveStartLine = startVerify.relocatedLine ?? start.line;
360
+ const endVerify = verifyHash(end.line, end.hash, normalized, hashLen, lines, safeReapply);
230
361
  if (!endVerify.valid) {
231
- throw new Error(`End reference invalid: ${endVerify.message}`);
362
+ throw new HashlineError({
363
+ code: endVerify.code ?? "HASH_MISMATCH",
364
+ message: `End reference invalid: ${endVerify.message}`,
365
+ expected: endVerify.expected,
366
+ actual: endVerify.actual,
367
+ candidates: endVerify.candidates,
368
+ lineNumber: end.line,
369
+ hint: endVerify.candidates && endVerify.candidates.length > 0 ? `Content may have moved. Candidates: ${endVerify.candidates.map((c) => `line ${c.lineNumber}`).join(", ")}` : "Re-read the file to get fresh hash references."
370
+ });
232
371
  }
233
- const rangeLines = lines.slice(start.line - 1, end.line);
372
+ const effectiveEndLine = endVerify.relocatedLine ?? end.line;
373
+ if (effectiveStartLine > effectiveEndLine) {
374
+ throw new HashlineError({
375
+ code: "INVALID_RANGE",
376
+ message: `Invalid effective range after relocation: start line ${effectiveStartLine} is after end line ${effectiveEndLine}`,
377
+ hint: "The referenced lines may have been reordered. Re-read the file to get fresh references."
378
+ });
379
+ }
380
+ const rangeLines = lines.slice(effectiveStartLine - 1, effectiveEndLine);
234
381
  return {
235
- startLine: start.line,
236
- endLine: end.line,
382
+ startLine: effectiveStartLine,
383
+ endLine: effectiveEndLine,
237
384
  lines: rangeLines,
238
385
  content: rangeLines.join(lineEnding)
239
386
  };
@@ -249,22 +396,37 @@ function replaceRange(startRef, endRef, content, replacement, hashLen) {
249
396
  const result = [...before, ...replacementLines, ...after].join("\n");
250
397
  return lineEnding === "\r\n" ? result.replace(/\n/g, "\r\n") : result;
251
398
  }
252
- function applyHashEdit(input, content, hashLen) {
399
+ function applyHashEdit(input, content, hashLen, safeReapply) {
253
400
  const lineEnding = detectLineEnding(content);
254
401
  const workContent = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
402
+ if (input.fileRev) {
403
+ verifyFileRev(input.fileRev, workContent);
404
+ }
255
405
  const normalizedStart = normalizeHashRef(input.startRef);
256
406
  const start = parseHashRef(normalizedStart);
257
407
  const lines = workContent.split("\n");
258
- const startVerify = verifyHash(start.line, start.hash, workContent, hashLen, lines);
408
+ const startVerify = verifyHash(start.line, start.hash, workContent, hashLen, lines, safeReapply);
259
409
  if (!startVerify.valid) {
260
- throw new Error(`Start reference invalid: ${startVerify.message}`);
410
+ throw new HashlineError({
411
+ code: startVerify.code ?? "HASH_MISMATCH",
412
+ message: `Start reference invalid: ${startVerify.message}`,
413
+ expected: startVerify.expected,
414
+ actual: startVerify.actual,
415
+ candidates: startVerify.candidates,
416
+ lineNumber: start.line,
417
+ hint: startVerify.candidates && startVerify.candidates.length > 0 ? `Content may have moved. Candidates: ${startVerify.candidates.map((c) => `line ${c.lineNumber}`).join(", ")}` : "Re-read the file to get fresh hash references."
418
+ });
261
419
  }
420
+ const effectiveStartLine = startVerify.relocatedLine ?? start.line;
262
421
  if (input.operation === "insert_before" || input.operation === "insert_after") {
263
422
  if (input.replacement === void 0) {
264
- throw new Error(`Operation "${input.operation}" requires "replacement" content`);
423
+ throw new HashlineError({
424
+ code: "MISSING_REPLACEMENT",
425
+ message: `Operation "${input.operation}" requires "replacement" content`
426
+ });
265
427
  }
266
428
  const insertionLines = input.replacement.split("\n");
267
- const insertIndex = input.operation === "insert_before" ? start.line - 1 : start.line;
429
+ const insertIndex = input.operation === "insert_before" ? effectiveStartLine - 1 : effectiveStartLine;
268
430
  const next2 = [
269
431
  ...lines.slice(0, insertIndex),
270
432
  ...insertionLines,
@@ -272,34 +434,54 @@ function applyHashEdit(input, content, hashLen) {
272
434
  ].join("\n");
273
435
  return {
274
436
  operation: input.operation,
275
- startLine: start.line,
276
- endLine: start.line,
437
+ startLine: effectiveStartLine,
438
+ endLine: effectiveStartLine,
277
439
  content: lineEnding === "\r\n" ? next2.replace(/\n/g, "\r\n") : next2
278
440
  };
279
441
  }
280
442
  const normalizedEnd = normalizeHashRef(input.endRef ?? input.startRef);
281
443
  const end = parseHashRef(normalizedEnd);
282
444
  if (start.line > end.line) {
283
- throw new Error(
284
- `Invalid range: start line ${start.line} is after end line ${end.line}`
285
- );
445
+ throw new HashlineError({
446
+ code: "INVALID_RANGE",
447
+ message: `Invalid range: start line ${start.line} is after end line ${end.line}`
448
+ });
286
449
  }
287
- const endVerify = verifyHash(end.line, end.hash, workContent, hashLen, lines);
450
+ const endVerify = verifyHash(end.line, end.hash, workContent, hashLen, lines, safeReapply);
288
451
  if (!endVerify.valid) {
289
- throw new Error(`End reference invalid: ${endVerify.message}`);
452
+ throw new HashlineError({
453
+ code: endVerify.code ?? "HASH_MISMATCH",
454
+ message: `End reference invalid: ${endVerify.message}`,
455
+ expected: endVerify.expected,
456
+ actual: endVerify.actual,
457
+ candidates: endVerify.candidates,
458
+ lineNumber: end.line,
459
+ hint: endVerify.candidates && endVerify.candidates.length > 0 ? `Content may have moved. Candidates: ${endVerify.candidates.map((c) => `line ${c.lineNumber}`).join(", ")}` : "Re-read the file to get fresh hash references."
460
+ });
461
+ }
462
+ const effectiveEndLine = endVerify.relocatedLine ?? end.line;
463
+ if (effectiveStartLine > effectiveEndLine) {
464
+ throw new HashlineError({
465
+ code: "INVALID_RANGE",
466
+ message: `Invalid effective range after relocation: start line ${effectiveStartLine} is after end line ${effectiveEndLine}`,
467
+ hint: "The referenced lines may have been reordered. Re-read the file to get fresh references."
468
+ });
290
469
  }
291
470
  const replacement = input.operation === "delete" ? "" : input.replacement;
292
471
  if (replacement === void 0) {
293
- throw new Error(`Operation "${input.operation}" requires "replacement" content`);
472
+ throw new HashlineError({
473
+ code: "MISSING_REPLACEMENT",
474
+ message: `Operation "${input.operation}" requires "replacement" content`
475
+ });
294
476
  }
295
- const before = lines.slice(0, start.line - 1);
296
- const after = lines.slice(end.line);
477
+ const before = lines.slice(0, effectiveStartLine - 1);
478
+ const after = lines.slice(effectiveEndLine);
297
479
  const replacementLines = input.operation === "delete" ? [] : replacement.split("\n");
298
480
  const next = [...before, ...replacementLines, ...after].join("\n");
299
481
  return {
300
482
  operation: input.operation,
301
- startLine: start.line,
302
- endLine: end.line,
483
+ startLine: effectiveStartLine,
484
+ endLine: effectiveEndLine,
303
485
  content: lineEnding === "\r\n" ? next.replace(/\n/g, "\r\n") : next
304
486
  };
305
487
  }
@@ -396,7 +578,7 @@ function createHashline(config) {
396
578
  const cached = cache.get(filePath, content);
397
579
  if (cached) return cached;
398
580
  }
399
- const result = formatFileWithHashes(content, hl, pfx);
581
+ const result = formatFileWithHashes(content, hl, pfx, resolved.fileRev);
400
582
  if (filePath) {
401
583
  cache.set(filePath, content, result);
402
584
  }
@@ -412,16 +594,16 @@ function createHashline(config) {
412
594
  return buildHashMap(content, hl);
413
595
  },
414
596
  verifyHash(lineNumber, hash, currentContent) {
415
- return verifyHash(lineNumber, hash, currentContent, hl);
597
+ return verifyHash(lineNumber, hash, currentContent, hl, void 0, resolved.safeReapply);
416
598
  },
417
599
  resolveRange(startRef, endRef, content) {
418
- return resolveRange(startRef, endRef, content, hl);
600
+ return resolveRange(startRef, endRef, content, hl, resolved.safeReapply);
419
601
  },
420
602
  replaceRange(startRef, endRef, content, replacement) {
421
603
  return replaceRange(startRef, endRef, content, replacement, hl);
422
604
  },
423
605
  applyHashEdit(input, content) {
424
- return applyHashEdit(input, content, hl);
606
+ return applyHashEdit(input, content, hl, resolved.safeReapply);
425
607
  },
426
608
  normalizeHashRef(ref) {
427
609
  return normalizeHashRef(ref);
@@ -431,6 +613,18 @@ function createHashline(config) {
431
613
  },
432
614
  shouldExclude(filePath) {
433
615
  return shouldExclude(filePath, resolved.exclude);
616
+ },
617
+ computeFileRev(content) {
618
+ return computeFileRev(content);
619
+ },
620
+ verifyFileRev(expectedRev, currentContent) {
621
+ return verifyFileRev(expectedRev, currentContent);
622
+ },
623
+ extractFileRev(annotatedContent) {
624
+ return extractFileRev(annotatedContent, pfx);
625
+ },
626
+ findCandidateLines(originalLineNumber, expectedHash, lines, hashLen) {
627
+ return findCandidateLines(originalLineNumber, expectedHash, lines, hashLen);
434
628
  }
435
629
  };
436
630
  }
@@ -440,8 +634,13 @@ export {
440
634
  DEFAULT_PREFIX,
441
635
  DEFAULT_CONFIG,
442
636
  resolveConfig,
637
+ HashlineError,
443
638
  getAdaptiveHashLength,
444
639
  computeLineHash,
640
+ computeFileRev,
641
+ extractFileRev,
642
+ verifyFileRev,
643
+ findCandidateLines,
445
644
  formatFileWithHashes,
446
645
  stripHashes,
447
646
  parseHashRef,