opencode-hashline 1.2.0 → 1.3.1

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.
@@ -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,33 +152,90 @@ 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);
111
201
  const effectivePrefix = prefix === void 0 ? DEFAULT_PREFIX : prefix === false ? "" : prefix;
202
+ const hashLens = new Array(lines.length).fill(effectiveLen);
112
203
  const hashes = new Array(lines.length);
113
- const seen = /* @__PURE__ */ new Map();
114
- const upgraded = /* @__PURE__ */ new Set();
115
204
  for (let idx = 0; idx < lines.length; idx++) {
116
- const hash = computeLineHash(idx, lines[idx], effectiveLen);
117
- if (seen.has(hash)) {
118
- const longerLen = Math.min(effectiveLen + 1, 8);
119
- const prevIdx = seen.get(hash);
120
- if (!upgraded.has(prevIdx)) {
121
- hashes[prevIdx] = computeLineHash(prevIdx, lines[prevIdx], longerLen);
122
- upgraded.add(prevIdx);
205
+ hashes[idx] = computeLineHash(idx, lines[idx], effectiveLen);
206
+ }
207
+ let hasCollisions = true;
208
+ while (hasCollisions) {
209
+ hasCollisions = false;
210
+ const seen = /* @__PURE__ */ new Map();
211
+ for (let idx = 0; idx < lines.length; idx++) {
212
+ const h = hashes[idx];
213
+ const group = seen.get(h);
214
+ if (group) {
215
+ group.push(idx);
216
+ } else {
217
+ seen.set(h, [idx]);
218
+ }
219
+ }
220
+ for (const [, group] of seen) {
221
+ if (group.length < 2) continue;
222
+ for (const idx of group) {
223
+ const newLen = Math.min(hashLens[idx] + 1, 8);
224
+ if (newLen === hashLens[idx]) continue;
225
+ hashLens[idx] = newLen;
226
+ hashes[idx] = computeLineHash(idx, lines[idx], newLen);
227
+ hasCollisions = true;
123
228
  }
124
- hashes[idx] = computeLineHash(idx, lines[idx], longerLen);
125
- upgraded.add(idx);
126
- } else {
127
- seen.set(hash, idx);
128
- hashes[idx] = hash;
129
229
  }
130
230
  }
131
- return lines.map((line, idx) => {
231
+ const annotatedLines = lines.map((line, idx) => {
132
232
  return `${effectivePrefix}${idx + 1}:${hashes[idx]}|${line}`;
133
- }).join("\n");
233
+ });
234
+ if (includeFileRev) {
235
+ const rev = computeFileRev(content);
236
+ annotatedLines.unshift(`${effectivePrefix}REV:${rev}`);
237
+ }
238
+ return annotatedLines.join("\n");
134
239
  }
135
240
  var stripRegexCache = /* @__PURE__ */ new Map();
136
241
  function stripHashes(content, prefix) {
@@ -141,9 +246,10 @@ function stripHashes(content, prefix) {
141
246
  hashLinePattern = new RegExp(`^([+ \\-])?${escapedPrefix}\\d+:[0-9a-f]{2,8}\\|`);
142
247
  stripRegexCache.set(escapedPrefix, hashLinePattern);
143
248
  }
249
+ const revPattern = new RegExp(`^${escapedPrefix}REV:[0-9a-f]{8}$`);
144
250
  const lineEnding = detectLineEnding(content);
145
251
  const normalized = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
146
- const result = normalized.split("\n").map((line) => {
252
+ const result = normalized.split("\n").filter((line) => !revPattern.test(line)).map((line) => {
147
253
  const match = line.match(hashLinePattern);
148
254
  if (match) {
149
255
  const patchMarker = match[1] || "";
@@ -157,7 +263,10 @@ function parseHashRef(ref) {
157
263
  const match = ref.match(/^(\d+):([0-9a-f]{2,8})$/);
158
264
  if (!match) {
159
265
  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>"`);
266
+ throw new HashlineError({
267
+ code: "INVALID_REF",
268
+ message: `Invalid hash reference: "${display}". Expected format: "<line>:<2-8 char hex>"`
269
+ });
161
270
  }
162
271
  return {
163
272
  line: parseInt(match[1], 10),
@@ -175,9 +284,10 @@ function normalizeHashRef(ref) {
175
284
  return `${parseInt(annotated[1], 10)}:${annotated[2].toLowerCase()}`;
176
285
  }
177
286
  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
- );
287
+ throw new HashlineError({
288
+ code: "INVALID_REF",
289
+ message: `Invalid hash reference: "${display}". Expected "<line>:<hash>" or an annotated line like "#HL <line>:<hash>|..."`
290
+ });
181
291
  }
182
292
  function buildHashMap(content, hashLen) {
183
293
  const lines = content.split("\n");
@@ -190,50 +300,97 @@ function buildHashMap(content, hashLen) {
190
300
  }
191
301
  return map;
192
302
  }
193
- function verifyHash(lineNumber, hash, currentContent, hashLen, lines) {
303
+ function verifyHash(lineNumber, hash, currentContent, hashLen, lines, safeReapply) {
194
304
  const contentLines = lines ?? currentContent.split("\n");
195
305
  const effectiveLen = hashLen && hashLen >= 2 ? hashLen : hash.length;
196
306
  if (lineNumber < 1 || lineNumber > contentLines.length) {
197
307
  return {
198
308
  valid: false,
309
+ code: "TARGET_OUT_OF_RANGE",
199
310
  message: `Line ${lineNumber} is out of range (file has ${contentLines.length} lines)`
200
311
  };
201
312
  }
202
313
  const idx = lineNumber - 1;
203
314
  const actualHash = computeLineHash(idx, contentLines[idx], effectiveLen);
204
315
  if (actualHash !== hash) {
316
+ const candidates = findCandidateLines(lineNumber, hash, contentLines, effectiveLen);
317
+ if (safeReapply && candidates.length === 1) {
318
+ return {
319
+ valid: true,
320
+ relocatedLine: candidates[0].lineNumber,
321
+ candidates
322
+ };
323
+ }
324
+ if (safeReapply && candidates.length > 1) {
325
+ return {
326
+ valid: false,
327
+ code: "AMBIGUOUS_REAPPLY",
328
+ expected: hash,
329
+ actual: actualHash,
330
+ candidates,
331
+ message: `Hash mismatch at line ${lineNumber}: expected "${hash}", got "${actualHash}". Found ${candidates.length} candidate lines \u2014 ambiguous reapply.`
332
+ };
333
+ }
205
334
  return {
206
335
  valid: false,
336
+ code: "HASH_MISMATCH",
207
337
  expected: hash,
208
338
  actual: actualHash,
339
+ candidates,
209
340
  message: `Hash mismatch at line ${lineNumber}: expected "${hash}", got "${actualHash}". The file may have changed since it was read.`
210
341
  };
211
342
  }
212
343
  return { valid: true };
213
344
  }
214
- function resolveRange(startRef, endRef, content, hashLen) {
345
+ function resolveRange(startRef, endRef, content, hashLen, safeReapply) {
215
346
  const start = parseHashRef(startRef);
216
347
  const end = parseHashRef(endRef);
217
348
  if (start.line > end.line) {
218
- throw new Error(
219
- `Invalid range: start line ${start.line} is after end line ${end.line}`
220
- );
349
+ throw new HashlineError({
350
+ code: "INVALID_RANGE",
351
+ message: `Invalid range: start line ${start.line} is after end line ${end.line}`
352
+ });
221
353
  }
222
354
  const lineEnding = detectLineEnding(content);
223
355
  const normalized = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
224
356
  const lines = normalized.split("\n");
225
- const startVerify = verifyHash(start.line, start.hash, normalized, hashLen, lines);
357
+ const startVerify = verifyHash(start.line, start.hash, normalized, hashLen, lines, safeReapply);
226
358
  if (!startVerify.valid) {
227
- throw new Error(`Start reference invalid: ${startVerify.message}`);
359
+ throw new HashlineError({
360
+ code: startVerify.code ?? "HASH_MISMATCH",
361
+ message: `Start reference invalid: ${startVerify.message}`,
362
+ expected: startVerify.expected,
363
+ actual: startVerify.actual,
364
+ candidates: startVerify.candidates,
365
+ lineNumber: start.line,
366
+ 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."
367
+ });
228
368
  }
229
- const endVerify = verifyHash(end.line, end.hash, normalized, hashLen, lines);
369
+ const effectiveStartLine = startVerify.relocatedLine ?? start.line;
370
+ const endVerify = verifyHash(end.line, end.hash, normalized, hashLen, lines, safeReapply);
230
371
  if (!endVerify.valid) {
231
- throw new Error(`End reference invalid: ${endVerify.message}`);
372
+ throw new HashlineError({
373
+ code: endVerify.code ?? "HASH_MISMATCH",
374
+ message: `End reference invalid: ${endVerify.message}`,
375
+ expected: endVerify.expected,
376
+ actual: endVerify.actual,
377
+ candidates: endVerify.candidates,
378
+ lineNumber: end.line,
379
+ 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."
380
+ });
381
+ }
382
+ const effectiveEndLine = endVerify.relocatedLine ?? end.line;
383
+ if (effectiveStartLine > effectiveEndLine) {
384
+ throw new HashlineError({
385
+ code: "INVALID_RANGE",
386
+ message: `Invalid effective range after relocation: start line ${effectiveStartLine} is after end line ${effectiveEndLine}`,
387
+ hint: "The referenced lines may have been reordered. Re-read the file to get fresh references."
388
+ });
232
389
  }
233
- const rangeLines = lines.slice(start.line - 1, end.line);
390
+ const rangeLines = lines.slice(effectiveStartLine - 1, effectiveEndLine);
234
391
  return {
235
- startLine: start.line,
236
- endLine: end.line,
392
+ startLine: effectiveStartLine,
393
+ endLine: effectiveEndLine,
237
394
  lines: rangeLines,
238
395
  content: rangeLines.join(lineEnding)
239
396
  };
@@ -249,22 +406,37 @@ function replaceRange(startRef, endRef, content, replacement, hashLen) {
249
406
  const result = [...before, ...replacementLines, ...after].join("\n");
250
407
  return lineEnding === "\r\n" ? result.replace(/\n/g, "\r\n") : result;
251
408
  }
252
- function applyHashEdit(input, content, hashLen) {
409
+ function applyHashEdit(input, content, hashLen, safeReapply) {
253
410
  const lineEnding = detectLineEnding(content);
254
411
  const workContent = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
412
+ if (input.fileRev) {
413
+ verifyFileRev(input.fileRev, workContent);
414
+ }
255
415
  const normalizedStart = normalizeHashRef(input.startRef);
256
416
  const start = parseHashRef(normalizedStart);
257
417
  const lines = workContent.split("\n");
258
- const startVerify = verifyHash(start.line, start.hash, workContent, hashLen, lines);
418
+ const startVerify = verifyHash(start.line, start.hash, workContent, hashLen, lines, safeReapply);
259
419
  if (!startVerify.valid) {
260
- throw new Error(`Start reference invalid: ${startVerify.message}`);
420
+ throw new HashlineError({
421
+ code: startVerify.code ?? "HASH_MISMATCH",
422
+ message: `Start reference invalid: ${startVerify.message}`,
423
+ expected: startVerify.expected,
424
+ actual: startVerify.actual,
425
+ candidates: startVerify.candidates,
426
+ lineNumber: start.line,
427
+ 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."
428
+ });
261
429
  }
430
+ const effectiveStartLine = startVerify.relocatedLine ?? start.line;
262
431
  if (input.operation === "insert_before" || input.operation === "insert_after") {
263
432
  if (input.replacement === void 0) {
264
- throw new Error(`Operation "${input.operation}" requires "replacement" content`);
433
+ throw new HashlineError({
434
+ code: "MISSING_REPLACEMENT",
435
+ message: `Operation "${input.operation}" requires "replacement" content`
436
+ });
265
437
  }
266
438
  const insertionLines = input.replacement.split("\n");
267
- const insertIndex = input.operation === "insert_before" ? start.line - 1 : start.line;
439
+ const insertIndex = input.operation === "insert_before" ? effectiveStartLine - 1 : effectiveStartLine;
268
440
  const next2 = [
269
441
  ...lines.slice(0, insertIndex),
270
442
  ...insertionLines,
@@ -272,34 +444,54 @@ function applyHashEdit(input, content, hashLen) {
272
444
  ].join("\n");
273
445
  return {
274
446
  operation: input.operation,
275
- startLine: start.line,
276
- endLine: start.line,
447
+ startLine: effectiveStartLine,
448
+ endLine: effectiveStartLine,
277
449
  content: lineEnding === "\r\n" ? next2.replace(/\n/g, "\r\n") : next2
278
450
  };
279
451
  }
280
452
  const normalizedEnd = normalizeHashRef(input.endRef ?? input.startRef);
281
453
  const end = parseHashRef(normalizedEnd);
282
454
  if (start.line > end.line) {
283
- throw new Error(
284
- `Invalid range: start line ${start.line} is after end line ${end.line}`
285
- );
455
+ throw new HashlineError({
456
+ code: "INVALID_RANGE",
457
+ message: `Invalid range: start line ${start.line} is after end line ${end.line}`
458
+ });
286
459
  }
287
- const endVerify = verifyHash(end.line, end.hash, workContent, hashLen, lines);
460
+ const endVerify = verifyHash(end.line, end.hash, workContent, hashLen, lines, safeReapply);
288
461
  if (!endVerify.valid) {
289
- throw new Error(`End reference invalid: ${endVerify.message}`);
462
+ throw new HashlineError({
463
+ code: endVerify.code ?? "HASH_MISMATCH",
464
+ message: `End reference invalid: ${endVerify.message}`,
465
+ expected: endVerify.expected,
466
+ actual: endVerify.actual,
467
+ candidates: endVerify.candidates,
468
+ lineNumber: end.line,
469
+ 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."
470
+ });
471
+ }
472
+ const effectiveEndLine = endVerify.relocatedLine ?? end.line;
473
+ if (effectiveStartLine > effectiveEndLine) {
474
+ throw new HashlineError({
475
+ code: "INVALID_RANGE",
476
+ message: `Invalid effective range after relocation: start line ${effectiveStartLine} is after end line ${effectiveEndLine}`,
477
+ hint: "The referenced lines may have been reordered. Re-read the file to get fresh references."
478
+ });
290
479
  }
291
480
  const replacement = input.operation === "delete" ? "" : input.replacement;
292
481
  if (replacement === void 0) {
293
- throw new Error(`Operation "${input.operation}" requires "replacement" content`);
482
+ throw new HashlineError({
483
+ code: "MISSING_REPLACEMENT",
484
+ message: `Operation "${input.operation}" requires "replacement" content`
485
+ });
294
486
  }
295
- const before = lines.slice(0, start.line - 1);
296
- const after = lines.slice(end.line);
487
+ const before = lines.slice(0, effectiveStartLine - 1);
488
+ const after = lines.slice(effectiveEndLine);
297
489
  const replacementLines = input.operation === "delete" ? [] : replacement.split("\n");
298
490
  const next = [...before, ...replacementLines, ...after].join("\n");
299
491
  return {
300
492
  operation: input.operation,
301
- startLine: start.line,
302
- endLine: end.line,
493
+ startLine: effectiveStartLine,
494
+ endLine: effectiveEndLine,
303
495
  content: lineEnding === "\r\n" ? next.replace(/\n/g, "\r\n") : next
304
496
  };
305
497
  }
@@ -396,7 +588,7 @@ function createHashline(config) {
396
588
  const cached = cache.get(filePath, content);
397
589
  if (cached) return cached;
398
590
  }
399
- const result = formatFileWithHashes(content, hl, pfx);
591
+ const result = formatFileWithHashes(content, hl, pfx, resolved.fileRev);
400
592
  if (filePath) {
401
593
  cache.set(filePath, content, result);
402
594
  }
@@ -412,16 +604,16 @@ function createHashline(config) {
412
604
  return buildHashMap(content, hl);
413
605
  },
414
606
  verifyHash(lineNumber, hash, currentContent) {
415
- return verifyHash(lineNumber, hash, currentContent, hl);
607
+ return verifyHash(lineNumber, hash, currentContent, hl, void 0, resolved.safeReapply);
416
608
  },
417
609
  resolveRange(startRef, endRef, content) {
418
- return resolveRange(startRef, endRef, content, hl);
610
+ return resolveRange(startRef, endRef, content, hl, resolved.safeReapply);
419
611
  },
420
612
  replaceRange(startRef, endRef, content, replacement) {
421
613
  return replaceRange(startRef, endRef, content, replacement, hl);
422
614
  },
423
615
  applyHashEdit(input, content) {
424
- return applyHashEdit(input, content, hl);
616
+ return applyHashEdit(input, content, hl, resolved.safeReapply);
425
617
  },
426
618
  normalizeHashRef(ref) {
427
619
  return normalizeHashRef(ref);
@@ -431,6 +623,18 @@ function createHashline(config) {
431
623
  },
432
624
  shouldExclude(filePath) {
433
625
  return shouldExclude(filePath, resolved.exclude);
626
+ },
627
+ computeFileRev(content) {
628
+ return computeFileRev(content);
629
+ },
630
+ verifyFileRev(expectedRev, currentContent) {
631
+ return verifyFileRev(expectedRev, currentContent);
632
+ },
633
+ extractFileRev(annotatedContent) {
634
+ return extractFileRev(annotatedContent, pfx);
635
+ },
636
+ findCandidateLines(originalLineNumber, expectedHash, lines, hashLen) {
637
+ return findCandidateLines(originalLineNumber, expectedHash, lines, hashLen);
434
638
  }
435
639
  };
436
640
  }
@@ -440,8 +644,13 @@ export {
440
644
  DEFAULT_PREFIX,
441
645
  DEFAULT_CONFIG,
442
646
  resolveConfig,
647
+ HashlineError,
443
648
  getAdaptiveHashLength,
444
649
  computeLineHash,
650
+ computeFileRev,
651
+ extractFileRev,
652
+ verifyFileRev,
653
+ findCandidateLines,
445
654
  formatFileWithHashes,
446
655
  stripHashes,
447
656
  parseHashRef,
@@ -4,7 +4,7 @@ import {
4
4
  resolveConfig,
5
5
  shouldExclude,
6
6
  stripHashes
7
- } from "./chunk-I6RACR3D.js";
7
+ } from "./chunk-GKXY5ZBM.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
  };
@@ -3,11 +3,15 @@ import {
3
3
  DEFAULT_EXCLUDE_PATTERNS,
4
4
  DEFAULT_PREFIX,
5
5
  HashlineCache,
6
+ HashlineError,
6
7
  applyHashEdit,
7
8
  buildHashMap,
9
+ computeFileRev,
8
10
  computeLineHash,
9
11
  createHashline,
10
12
  detectLineEnding,
13
+ extractFileRev,
14
+ findCandidateLines,
11
15
  formatFileWithHashes,
12
16
  getAdaptiveHashLength,
13
17
  getByteLength,
@@ -19,18 +23,23 @@ import {
19
23
  resolveRange,
20
24
  shouldExclude,
21
25
  stripHashes,
26
+ verifyFileRev,
22
27
  verifyHash
23
- } from "./chunk-I6RACR3D.js";
28
+ } from "./chunk-GKXY5ZBM.js";
24
29
  export {
25
30
  DEFAULT_CONFIG,
26
31
  DEFAULT_EXCLUDE_PATTERNS,
27
32
  DEFAULT_PREFIX,
28
33
  HashlineCache,
34
+ HashlineError,
29
35
  applyHashEdit,
30
36
  buildHashMap,
37
+ computeFileRev,
31
38
  computeLineHash,
32
39
  createHashline,
33
40
  detectLineEnding,
41
+ extractFileRev,
42
+ findCandidateLines,
34
43
  formatFileWithHashes,
35
44
  getAdaptiveHashLength,
36
45
  getByteLength,
@@ -42,5 +51,6 @@ export {
42
51
  resolveRange,
43
52
  shouldExclude,
44
53
  stripHashes,
54
+ verifyFileRev,
45
55
  verifyHash
46
56
  };