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.
package/dist/utils.cjs CHANGED
@@ -34,13 +34,17 @@ __export(utils_exports, {
34
34
  DEFAULT_EXCLUDE_PATTERNS: () => DEFAULT_EXCLUDE_PATTERNS,
35
35
  DEFAULT_PREFIX: () => DEFAULT_PREFIX,
36
36
  HashlineCache: () => HashlineCache,
37
+ HashlineError: () => HashlineError,
37
38
  applyHashEdit: () => applyHashEdit,
38
39
  buildHashMap: () => buildHashMap,
40
+ computeFileRev: () => computeFileRev,
39
41
  computeLineHash: () => computeLineHash,
40
42
  createFileEditBeforeHook: () => createFileEditBeforeHook,
41
43
  createFileReadAfterHook: () => createFileReadAfterHook,
42
44
  createHashline: () => createHashline,
43
45
  createSystemPromptHook: () => createSystemPromptHook,
46
+ extractFileRev: () => extractFileRev,
47
+ findCandidateLines: () => findCandidateLines,
44
48
  formatFileWithHashes: () => formatFileWithHashes,
45
49
  getAdaptiveHashLength: () => getAdaptiveHashLength,
46
50
  getByteLength: () => getByteLength,
@@ -53,6 +57,7 @@ __export(utils_exports, {
53
57
  resolveRange: () => resolveRange,
54
58
  shouldExclude: () => shouldExclude,
55
59
  stripHashes: () => stripHashes,
60
+ verifyFileRev: () => verifyFileRev,
56
61
  verifyHash: () => verifyHash
57
62
  });
58
63
  module.exports = __toCommonJS(utils_exports);
@@ -115,7 +120,9 @@ var DEFAULT_CONFIG = {
115
120
  // 0 = adaptive
116
121
  cacheSize: 100,
117
122
  prefix: DEFAULT_PREFIX,
118
- debug: false
123
+ debug: false,
124
+ fileRev: true,
125
+ safeReapply: false
119
126
  };
120
127
  function resolveConfig(config, pluginConfig) {
121
128
  const merged = {
@@ -131,9 +138,55 @@ function resolveConfig(config, pluginConfig) {
131
138
  hashLength: merged.hashLength ?? DEFAULT_CONFIG.hashLength,
132
139
  cacheSize: merged.cacheSize ?? DEFAULT_CONFIG.cacheSize,
133
140
  prefix: merged.prefix !== void 0 ? merged.prefix : DEFAULT_CONFIG.prefix,
134
- debug: merged.debug ?? DEFAULT_CONFIG.debug
141
+ debug: merged.debug ?? DEFAULT_CONFIG.debug,
142
+ fileRev: merged.fileRev ?? DEFAULT_CONFIG.fileRev,
143
+ safeReapply: merged.safeReapply ?? DEFAULT_CONFIG.safeReapply
135
144
  };
136
145
  }
146
+ var HashlineError = class extends Error {
147
+ code;
148
+ expected;
149
+ actual;
150
+ candidates;
151
+ hint;
152
+ lineNumber;
153
+ filePath;
154
+ constructor(opts) {
155
+ super(opts.message);
156
+ this.name = "HashlineError";
157
+ this.code = opts.code;
158
+ this.expected = opts.expected;
159
+ this.actual = opts.actual;
160
+ this.candidates = opts.candidates;
161
+ this.hint = opts.hint;
162
+ this.lineNumber = opts.lineNumber;
163
+ this.filePath = opts.filePath;
164
+ }
165
+ toDiagnostic() {
166
+ const parts = [`[${this.code}] ${this.message}`];
167
+ if (this.filePath) {
168
+ parts.push(` File: ${this.filePath}`);
169
+ }
170
+ if (this.lineNumber !== void 0) {
171
+ parts.push(` Line: ${this.lineNumber}`);
172
+ }
173
+ if (this.expected !== void 0 && this.actual !== void 0) {
174
+ parts.push(` Expected hash: ${this.expected}`);
175
+ parts.push(` Actual hash: ${this.actual}`);
176
+ }
177
+ if (this.candidates && this.candidates.length > 0) {
178
+ parts.push(` Candidates (${this.candidates.length}):`);
179
+ for (const c of this.candidates) {
180
+ const preview = c.content.length > 60 ? c.content.slice(0, 60) + "..." : c.content;
181
+ parts.push(` - line ${c.lineNumber}: ${preview}`);
182
+ }
183
+ }
184
+ if (this.hint) {
185
+ parts.push(` Hint: ${this.hint}`);
186
+ }
187
+ return parts.join("\n");
188
+ }
189
+ };
137
190
  function fnv1aHash(str) {
138
191
  let hash = 2166136261;
139
192
  for (let i = 0; i < str.length; i++) {
@@ -163,33 +216,90 @@ function computeLineHash(idx, line, hashLen = 3) {
163
216
  const hash = raw % modulus;
164
217
  return hash.toString(16).padStart(hashLen, "0");
165
218
  }
166
- function formatFileWithHashes(content, hashLen, prefix) {
219
+ function computeFileRev(content) {
220
+ const normalized = content.includes("\r\n") ? content.replace(/\r\n/g, "\n") : content;
221
+ const hash = fnv1aHash(normalized);
222
+ return hash.toString(16).padStart(8, "0");
223
+ }
224
+ function extractFileRev(annotatedContent, prefix) {
225
+ const effectivePrefix = prefix === void 0 ? DEFAULT_PREFIX : prefix === false ? "" : prefix;
226
+ const escapedPrefix = effectivePrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
227
+ const pattern = new RegExp(`^${escapedPrefix}REV:([0-9a-f]{8})$`);
228
+ const firstLine = annotatedContent.split("\n")[0];
229
+ const match = firstLine.match(pattern);
230
+ return match ? match[1] : null;
231
+ }
232
+ function verifyFileRev(expectedRev, currentContent) {
233
+ const actualRev = computeFileRev(currentContent);
234
+ if (actualRev !== expectedRev) {
235
+ throw new HashlineError({
236
+ code: "FILE_REV_MISMATCH",
237
+ message: `File revision mismatch: expected "${expectedRev}", got "${actualRev}". The file has changed since it was last read.`,
238
+ expected: expectedRev,
239
+ actual: actualRev,
240
+ hint: "Re-read the file to get fresh hash references and a new file revision."
241
+ });
242
+ }
243
+ }
244
+ function findCandidateLines(originalLineNumber, expectedHash, lines, hashLen) {
245
+ const effectiveLen = hashLen && hashLen >= 2 ? hashLen : expectedHash.length;
246
+ const originalIdx = originalLineNumber - 1;
247
+ const candidates = [];
248
+ for (let i = 0; i < lines.length; i++) {
249
+ if (i === originalIdx) continue;
250
+ const candidateHash = computeLineHash(originalIdx, lines[i], effectiveLen);
251
+ if (candidateHash === expectedHash) {
252
+ candidates.push({
253
+ lineNumber: i + 1,
254
+ // 1-based
255
+ content: lines[i]
256
+ });
257
+ }
258
+ }
259
+ return candidates;
260
+ }
261
+ function formatFileWithHashes(content, hashLen, prefix, includeFileRev) {
167
262
  const normalized = content.includes("\r\n") ? content.replace(/\r\n/g, "\n") : content;
168
263
  const lines = normalized.split("\n");
169
264
  const effectiveLen = hashLen && hashLen >= 3 ? hashLen : getAdaptiveHashLength(lines.length);
170
265
  const effectivePrefix = prefix === void 0 ? DEFAULT_PREFIX : prefix === false ? "" : prefix;
266
+ const hashLens = new Array(lines.length).fill(effectiveLen);
171
267
  const hashes = new Array(lines.length);
172
- const seen = /* @__PURE__ */ new Map();
173
- const upgraded = /* @__PURE__ */ new Set();
174
268
  for (let idx = 0; idx < lines.length; idx++) {
175
- const hash = computeLineHash(idx, lines[idx], effectiveLen);
176
- if (seen.has(hash)) {
177
- const longerLen = Math.min(effectiveLen + 1, 8);
178
- const prevIdx = seen.get(hash);
179
- if (!upgraded.has(prevIdx)) {
180
- hashes[prevIdx] = computeLineHash(prevIdx, lines[prevIdx], longerLen);
181
- upgraded.add(prevIdx);
269
+ hashes[idx] = computeLineHash(idx, lines[idx], effectiveLen);
270
+ }
271
+ let hasCollisions = true;
272
+ while (hasCollisions) {
273
+ hasCollisions = false;
274
+ const seen = /* @__PURE__ */ new Map();
275
+ for (let idx = 0; idx < lines.length; idx++) {
276
+ const h = hashes[idx];
277
+ const group = seen.get(h);
278
+ if (group) {
279
+ group.push(idx);
280
+ } else {
281
+ seen.set(h, [idx]);
282
+ }
283
+ }
284
+ for (const [, group] of seen) {
285
+ if (group.length < 2) continue;
286
+ for (const idx of group) {
287
+ const newLen = Math.min(hashLens[idx] + 1, 8);
288
+ if (newLen === hashLens[idx]) continue;
289
+ hashLens[idx] = newLen;
290
+ hashes[idx] = computeLineHash(idx, lines[idx], newLen);
291
+ hasCollisions = true;
182
292
  }
183
- hashes[idx] = computeLineHash(idx, lines[idx], longerLen);
184
- upgraded.add(idx);
185
- } else {
186
- seen.set(hash, idx);
187
- hashes[idx] = hash;
188
293
  }
189
294
  }
190
- return lines.map((line, idx) => {
295
+ const annotatedLines = lines.map((line, idx) => {
191
296
  return `${effectivePrefix}${idx + 1}:${hashes[idx]}|${line}`;
192
- }).join("\n");
297
+ });
298
+ if (includeFileRev) {
299
+ const rev = computeFileRev(content);
300
+ annotatedLines.unshift(`${effectivePrefix}REV:${rev}`);
301
+ }
302
+ return annotatedLines.join("\n");
193
303
  }
194
304
  var stripRegexCache = /* @__PURE__ */ new Map();
195
305
  function stripHashes(content, prefix) {
@@ -200,9 +310,10 @@ function stripHashes(content, prefix) {
200
310
  hashLinePattern = new RegExp(`^([+ \\-])?${escapedPrefix}\\d+:[0-9a-f]{2,8}\\|`);
201
311
  stripRegexCache.set(escapedPrefix, hashLinePattern);
202
312
  }
313
+ const revPattern = new RegExp(`^${escapedPrefix}REV:[0-9a-f]{8}$`);
203
314
  const lineEnding = detectLineEnding(content);
204
315
  const normalized = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
205
- const result = normalized.split("\n").map((line) => {
316
+ const result = normalized.split("\n").filter((line) => !revPattern.test(line)).map((line) => {
206
317
  const match = line.match(hashLinePattern);
207
318
  if (match) {
208
319
  const patchMarker = match[1] || "";
@@ -216,7 +327,10 @@ function parseHashRef(ref) {
216
327
  const match = ref.match(/^(\d+):([0-9a-f]{2,8})$/);
217
328
  if (!match) {
218
329
  const display = ref.length > 100 ? `${ref.slice(0, 100)}\u2026` : ref;
219
- throw new Error(`Invalid hash reference: "${display}". Expected format: "<line>:<2-8 char hex>"`);
330
+ throw new HashlineError({
331
+ code: "INVALID_REF",
332
+ message: `Invalid hash reference: "${display}". Expected format: "<line>:<2-8 char hex>"`
333
+ });
220
334
  }
221
335
  return {
222
336
  line: parseInt(match[1], 10),
@@ -234,9 +348,10 @@ function normalizeHashRef(ref) {
234
348
  return `${parseInt(annotated[1], 10)}:${annotated[2].toLowerCase()}`;
235
349
  }
236
350
  const display = ref.length > 100 ? `${ref.slice(0, 100)}\u2026` : ref;
237
- throw new Error(
238
- `Invalid hash reference: "${display}". Expected "<line>:<hash>" or an annotated line like "#HL <line>:<hash>|..."`
239
- );
351
+ throw new HashlineError({
352
+ code: "INVALID_REF",
353
+ message: `Invalid hash reference: "${display}". Expected "<line>:<hash>" or an annotated line like "#HL <line>:<hash>|..."`
354
+ });
240
355
  }
241
356
  function buildHashMap(content, hashLen) {
242
357
  const lines = content.split("\n");
@@ -249,50 +364,97 @@ function buildHashMap(content, hashLen) {
249
364
  }
250
365
  return map;
251
366
  }
252
- function verifyHash(lineNumber, hash, currentContent, hashLen, lines) {
367
+ function verifyHash(lineNumber, hash, currentContent, hashLen, lines, safeReapply) {
253
368
  const contentLines = lines ?? currentContent.split("\n");
254
369
  const effectiveLen = hashLen && hashLen >= 2 ? hashLen : hash.length;
255
370
  if (lineNumber < 1 || lineNumber > contentLines.length) {
256
371
  return {
257
372
  valid: false,
373
+ code: "TARGET_OUT_OF_RANGE",
258
374
  message: `Line ${lineNumber} is out of range (file has ${contentLines.length} lines)`
259
375
  };
260
376
  }
261
377
  const idx = lineNumber - 1;
262
378
  const actualHash = computeLineHash(idx, contentLines[idx], effectiveLen);
263
379
  if (actualHash !== hash) {
380
+ const candidates = findCandidateLines(lineNumber, hash, contentLines, effectiveLen);
381
+ if (safeReapply && candidates.length === 1) {
382
+ return {
383
+ valid: true,
384
+ relocatedLine: candidates[0].lineNumber,
385
+ candidates
386
+ };
387
+ }
388
+ if (safeReapply && candidates.length > 1) {
389
+ return {
390
+ valid: false,
391
+ code: "AMBIGUOUS_REAPPLY",
392
+ expected: hash,
393
+ actual: actualHash,
394
+ candidates,
395
+ message: `Hash mismatch at line ${lineNumber}: expected "${hash}", got "${actualHash}". Found ${candidates.length} candidate lines \u2014 ambiguous reapply.`
396
+ };
397
+ }
264
398
  return {
265
399
  valid: false,
400
+ code: "HASH_MISMATCH",
266
401
  expected: hash,
267
402
  actual: actualHash,
403
+ candidates,
268
404
  message: `Hash mismatch at line ${lineNumber}: expected "${hash}", got "${actualHash}". The file may have changed since it was read.`
269
405
  };
270
406
  }
271
407
  return { valid: true };
272
408
  }
273
- function resolveRange(startRef, endRef, content, hashLen) {
409
+ function resolveRange(startRef, endRef, content, hashLen, safeReapply) {
274
410
  const start = parseHashRef(startRef);
275
411
  const end = parseHashRef(endRef);
276
412
  if (start.line > end.line) {
277
- throw new Error(
278
- `Invalid range: start line ${start.line} is after end line ${end.line}`
279
- );
413
+ throw new HashlineError({
414
+ code: "INVALID_RANGE",
415
+ message: `Invalid range: start line ${start.line} is after end line ${end.line}`
416
+ });
280
417
  }
281
418
  const lineEnding = detectLineEnding(content);
282
419
  const normalized = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
283
420
  const lines = normalized.split("\n");
284
- const startVerify = verifyHash(start.line, start.hash, normalized, hashLen, lines);
421
+ const startVerify = verifyHash(start.line, start.hash, normalized, hashLen, lines, safeReapply);
285
422
  if (!startVerify.valid) {
286
- throw new Error(`Start reference invalid: ${startVerify.message}`);
423
+ throw new HashlineError({
424
+ code: startVerify.code ?? "HASH_MISMATCH",
425
+ message: `Start reference invalid: ${startVerify.message}`,
426
+ expected: startVerify.expected,
427
+ actual: startVerify.actual,
428
+ candidates: startVerify.candidates,
429
+ lineNumber: start.line,
430
+ 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."
431
+ });
287
432
  }
288
- const endVerify = verifyHash(end.line, end.hash, normalized, hashLen, lines);
433
+ const effectiveStartLine = startVerify.relocatedLine ?? start.line;
434
+ const endVerify = verifyHash(end.line, end.hash, normalized, hashLen, lines, safeReapply);
289
435
  if (!endVerify.valid) {
290
- throw new Error(`End reference invalid: ${endVerify.message}`);
436
+ throw new HashlineError({
437
+ code: endVerify.code ?? "HASH_MISMATCH",
438
+ message: `End reference invalid: ${endVerify.message}`,
439
+ expected: endVerify.expected,
440
+ actual: endVerify.actual,
441
+ candidates: endVerify.candidates,
442
+ lineNumber: end.line,
443
+ 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."
444
+ });
445
+ }
446
+ const effectiveEndLine = endVerify.relocatedLine ?? end.line;
447
+ if (effectiveStartLine > effectiveEndLine) {
448
+ throw new HashlineError({
449
+ code: "INVALID_RANGE",
450
+ message: `Invalid effective range after relocation: start line ${effectiveStartLine} is after end line ${effectiveEndLine}`,
451
+ hint: "The referenced lines may have been reordered. Re-read the file to get fresh references."
452
+ });
291
453
  }
292
- const rangeLines = lines.slice(start.line - 1, end.line);
454
+ const rangeLines = lines.slice(effectiveStartLine - 1, effectiveEndLine);
293
455
  return {
294
- startLine: start.line,
295
- endLine: end.line,
456
+ startLine: effectiveStartLine,
457
+ endLine: effectiveEndLine,
296
458
  lines: rangeLines,
297
459
  content: rangeLines.join(lineEnding)
298
460
  };
@@ -308,22 +470,37 @@ function replaceRange(startRef, endRef, content, replacement, hashLen) {
308
470
  const result = [...before, ...replacementLines, ...after].join("\n");
309
471
  return lineEnding === "\r\n" ? result.replace(/\n/g, "\r\n") : result;
310
472
  }
311
- function applyHashEdit(input, content, hashLen) {
473
+ function applyHashEdit(input, content, hashLen, safeReapply) {
312
474
  const lineEnding = detectLineEnding(content);
313
475
  const workContent = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
476
+ if (input.fileRev) {
477
+ verifyFileRev(input.fileRev, workContent);
478
+ }
314
479
  const normalizedStart = normalizeHashRef(input.startRef);
315
480
  const start = parseHashRef(normalizedStart);
316
481
  const lines = workContent.split("\n");
317
- const startVerify = verifyHash(start.line, start.hash, workContent, hashLen, lines);
482
+ const startVerify = verifyHash(start.line, start.hash, workContent, hashLen, lines, safeReapply);
318
483
  if (!startVerify.valid) {
319
- throw new Error(`Start reference invalid: ${startVerify.message}`);
484
+ throw new HashlineError({
485
+ code: startVerify.code ?? "HASH_MISMATCH",
486
+ message: `Start reference invalid: ${startVerify.message}`,
487
+ expected: startVerify.expected,
488
+ actual: startVerify.actual,
489
+ candidates: startVerify.candidates,
490
+ lineNumber: start.line,
491
+ 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."
492
+ });
320
493
  }
494
+ const effectiveStartLine = startVerify.relocatedLine ?? start.line;
321
495
  if (input.operation === "insert_before" || input.operation === "insert_after") {
322
496
  if (input.replacement === void 0) {
323
- throw new Error(`Operation "${input.operation}" requires "replacement" content`);
497
+ throw new HashlineError({
498
+ code: "MISSING_REPLACEMENT",
499
+ message: `Operation "${input.operation}" requires "replacement" content`
500
+ });
324
501
  }
325
502
  const insertionLines = input.replacement.split("\n");
326
- const insertIndex = input.operation === "insert_before" ? start.line - 1 : start.line;
503
+ const insertIndex = input.operation === "insert_before" ? effectiveStartLine - 1 : effectiveStartLine;
327
504
  const next2 = [
328
505
  ...lines.slice(0, insertIndex),
329
506
  ...insertionLines,
@@ -331,34 +508,54 @@ function applyHashEdit(input, content, hashLen) {
331
508
  ].join("\n");
332
509
  return {
333
510
  operation: input.operation,
334
- startLine: start.line,
335
- endLine: start.line,
511
+ startLine: effectiveStartLine,
512
+ endLine: effectiveStartLine,
336
513
  content: lineEnding === "\r\n" ? next2.replace(/\n/g, "\r\n") : next2
337
514
  };
338
515
  }
339
516
  const normalizedEnd = normalizeHashRef(input.endRef ?? input.startRef);
340
517
  const end = parseHashRef(normalizedEnd);
341
518
  if (start.line > end.line) {
342
- throw new Error(
343
- `Invalid range: start line ${start.line} is after end line ${end.line}`
344
- );
519
+ throw new HashlineError({
520
+ code: "INVALID_RANGE",
521
+ message: `Invalid range: start line ${start.line} is after end line ${end.line}`
522
+ });
345
523
  }
346
- const endVerify = verifyHash(end.line, end.hash, workContent, hashLen, lines);
524
+ const endVerify = verifyHash(end.line, end.hash, workContent, hashLen, lines, safeReapply);
347
525
  if (!endVerify.valid) {
348
- throw new Error(`End reference invalid: ${endVerify.message}`);
526
+ throw new HashlineError({
527
+ code: endVerify.code ?? "HASH_MISMATCH",
528
+ message: `End reference invalid: ${endVerify.message}`,
529
+ expected: endVerify.expected,
530
+ actual: endVerify.actual,
531
+ candidates: endVerify.candidates,
532
+ lineNumber: end.line,
533
+ 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."
534
+ });
535
+ }
536
+ const effectiveEndLine = endVerify.relocatedLine ?? end.line;
537
+ if (effectiveStartLine > effectiveEndLine) {
538
+ throw new HashlineError({
539
+ code: "INVALID_RANGE",
540
+ message: `Invalid effective range after relocation: start line ${effectiveStartLine} is after end line ${effectiveEndLine}`,
541
+ hint: "The referenced lines may have been reordered. Re-read the file to get fresh references."
542
+ });
349
543
  }
350
544
  const replacement = input.operation === "delete" ? "" : input.replacement;
351
545
  if (replacement === void 0) {
352
- throw new Error(`Operation "${input.operation}" requires "replacement" content`);
546
+ throw new HashlineError({
547
+ code: "MISSING_REPLACEMENT",
548
+ message: `Operation "${input.operation}" requires "replacement" content`
549
+ });
353
550
  }
354
- const before = lines.slice(0, start.line - 1);
355
- const after = lines.slice(end.line);
551
+ const before = lines.slice(0, effectiveStartLine - 1);
552
+ const after = lines.slice(effectiveEndLine);
356
553
  const replacementLines = input.operation === "delete" ? [] : replacement.split("\n");
357
554
  const next = [...before, ...replacementLines, ...after].join("\n");
358
555
  return {
359
556
  operation: input.operation,
360
- startLine: start.line,
361
- endLine: end.line,
557
+ startLine: effectiveStartLine,
558
+ endLine: effectiveEndLine,
362
559
  content: lineEnding === "\r\n" ? next.replace(/\n/g, "\r\n") : next
363
560
  };
364
561
  }
@@ -455,7 +652,7 @@ function createHashline(config) {
455
652
  const cached = cache.get(filePath, content);
456
653
  if (cached) return cached;
457
654
  }
458
- const result = formatFileWithHashes(content, hl, pfx);
655
+ const result = formatFileWithHashes(content, hl, pfx, resolved.fileRev);
459
656
  if (filePath) {
460
657
  cache.set(filePath, content, result);
461
658
  }
@@ -471,16 +668,16 @@ function createHashline(config) {
471
668
  return buildHashMap(content, hl);
472
669
  },
473
670
  verifyHash(lineNumber, hash, currentContent) {
474
- return verifyHash(lineNumber, hash, currentContent, hl);
671
+ return verifyHash(lineNumber, hash, currentContent, hl, void 0, resolved.safeReapply);
475
672
  },
476
673
  resolveRange(startRef, endRef, content) {
477
- return resolveRange(startRef, endRef, content, hl);
674
+ return resolveRange(startRef, endRef, content, hl, resolved.safeReapply);
478
675
  },
479
676
  replaceRange(startRef, endRef, content, replacement) {
480
677
  return replaceRange(startRef, endRef, content, replacement, hl);
481
678
  },
482
679
  applyHashEdit(input, content) {
483
- return applyHashEdit(input, content, hl);
680
+ return applyHashEdit(input, content, hl, resolved.safeReapply);
484
681
  },
485
682
  normalizeHashRef(ref) {
486
683
  return normalizeHashRef(ref);
@@ -490,6 +687,18 @@ function createHashline(config) {
490
687
  },
491
688
  shouldExclude(filePath) {
492
689
  return shouldExclude(filePath, resolved.exclude);
690
+ },
691
+ computeFileRev(content) {
692
+ return computeFileRev(content);
693
+ },
694
+ verifyFileRev(expectedRev, currentContent) {
695
+ return verifyFileRev(expectedRev, currentContent);
696
+ },
697
+ extractFileRev(annotatedContent) {
698
+ return extractFileRev(annotatedContent, pfx);
699
+ },
700
+ findCandidateLines(originalLineNumber, expectedHash, lines, hashLen) {
701
+ return findCandidateLines(originalLineNumber, expectedHash, lines, hashLen);
493
702
  }
494
703
  };
495
704
  }
@@ -579,7 +788,7 @@ function createFileReadAfterHook(cache, config) {
579
788
  return;
580
789
  }
581
790
  }
582
- const annotated = formatFileWithHashes(content, hashLen || void 0, prefix);
791
+ const annotated = formatFileWithHashes(content, hashLen || void 0, prefix, resolved.fileRev);
583
792
  output.output = annotated;
584
793
  debug("annotated", typeof filePath === "string" ? filePath : input.tool, "lines:", content.split("\n").length);
585
794
  if (cache && typeof filePath === "string") {
@@ -691,10 +900,32 @@ function createSystemPromptHook(config) {
691
900
  '- Hash references include both the line number AND the content hash, so `2:f1c` means "line 2 with hash f1c".',
692
901
  "- If you see a mismatch, do NOT proceed with the edit \u2014 re-read the file to get fresh references.",
693
902
  "",
903
+ "### File revision (`#HL REV:<hash>`):",
904
+ "- When files are read, the first line may contain a file revision header: `" + prefix + "REV:<8-char-hex>`.",
905
+ "- 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.",
906
+ "- If the file was modified between read and edit, the revision check fails with `FILE_REV_MISMATCH` \u2014 re-read the file.",
907
+ "",
908
+ "### Safe reapply (`safeReapply`):",
909
+ "- Pass `safeReapply: true` to `hashline_edit` to enable automatic line relocation.",
910
+ "- If a line moved (e.g., due to insertions above), safe reapply finds it by content hash.",
911
+ "- If exactly one match is found, the edit proceeds at the new location.",
912
+ "- If multiple matches exist, the edit fails with `AMBIGUOUS_REAPPLY` \u2014 re-read the file.",
913
+ "",
914
+ "### Structured error codes:",
915
+ "- `HASH_MISMATCH` \u2014 line content changed since last read",
916
+ "- `FILE_REV_MISMATCH` \u2014 file was modified since last read",
917
+ "- `AMBIGUOUS_REAPPLY` \u2014 multiple candidate lines found during safe reapply",
918
+ "- `TARGET_OUT_OF_RANGE` \u2014 line number exceeds file length",
919
+ "- `INVALID_REF` \u2014 malformed hash reference",
920
+ "- `INVALID_RANGE` \u2014 start line is after end line",
921
+ "- `MISSING_REPLACEMENT` \u2014 replace/insert operation without replacement content",
922
+ "",
694
923
  "### Best practices:",
695
924
  "- Use hash references for all edit operations to ensure precision.",
696
925
  "- When making multiple edits, work from bottom to top to avoid line number shifts.",
697
- "- For large replacements, use range references (e.g., `1:a3f to 10:b2c`) instead of individual lines."
926
+ "- For large replacements, use range references (e.g., `1:a3f to 10:b2c`) instead of individual lines.",
927
+ "- Use `fileRev` to guard against stale edits on critical files.",
928
+ "- Use `safeReapply: true` when editing files that may have shifted due to earlier edits."
698
929
  ].join("\n")
699
930
  );
700
931
  };
@@ -705,13 +936,17 @@ function createSystemPromptHook(config) {
705
936
  DEFAULT_EXCLUDE_PATTERNS,
706
937
  DEFAULT_PREFIX,
707
938
  HashlineCache,
939
+ HashlineError,
708
940
  applyHashEdit,
709
941
  buildHashMap,
942
+ computeFileRev,
710
943
  computeLineHash,
711
944
  createFileEditBeforeHook,
712
945
  createFileReadAfterHook,
713
946
  createHashline,
714
947
  createSystemPromptHook,
948
+ extractFileRev,
949
+ findCandidateLines,
715
950
  formatFileWithHashes,
716
951
  getAdaptiveHashLength,
717
952
  getByteLength,
@@ -724,5 +959,6 @@ function createSystemPromptHook(config) {
724
959
  resolveRange,
725
960
  shouldExclude,
726
961
  stripHashes,
962
+ verifyFileRev,
727
963
  verifyHash
728
964
  });
package/dist/utils.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { H as HashlineConfig, e as HashlineCache } from './hashline-yhMw1Abs.cjs';
2
- export { D as DEFAULT_CONFIG, f as DEFAULT_EXCLUDE_PATTERNS, g as DEFAULT_PREFIX, a as HashEditInput, b as HashEditOperation, c as HashEditResult, d as HashlineInstance, R as ResolvedRange, V as VerifyHashResult, h as applyHashEdit, i as buildHashMap, j as computeLineHash, k as createHashline, l as formatFileWithHashes, m as getAdaptiveHashLength, n as getByteLength, o as matchesGlob, p as normalizeHashRef, q as parseHashRef, r as replaceRange, s as resolveConfig, t as resolveRange, u as shouldExclude, v as stripHashes, w as verifyHash } from './hashline-yhMw1Abs.cjs';
1
+ import { H as HashlineConfig, f as HashlineCache } from './hashline-A7k2yn3G.cjs';
2
+ export { C as CandidateLine, D as DEFAULT_CONFIG, g as DEFAULT_EXCLUDE_PATTERNS, h as DEFAULT_PREFIX, a as HashEditInput, b as HashEditOperation, c as HashEditResult, i as HashlineError, d as HashlineErrorCode, e as HashlineInstance, R as ResolvedRange, V as VerifyHashResult, j as applyHashEdit, k as buildHashMap, l as computeFileRev, m as computeLineHash, n as createHashline, o as extractFileRev, p as findCandidateLines, q as formatFileWithHashes, r as getAdaptiveHashLength, s as getByteLength, t as matchesGlob, u as normalizeHashRef, v as parseHashRef, w as replaceRange, x as resolveConfig, y as resolveRange, z as shouldExclude, A as stripHashes, B as verifyFileRev, E as verifyHash } from './hashline-A7k2yn3G.cjs';
3
3
  import { Hooks } from '@opencode-ai/plugin';
4
4
 
5
5
  /**
package/dist/utils.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { H as HashlineConfig, e as HashlineCache } from './hashline-yhMw1Abs.js';
2
- export { D as DEFAULT_CONFIG, f as DEFAULT_EXCLUDE_PATTERNS, g as DEFAULT_PREFIX, a as HashEditInput, b as HashEditOperation, c as HashEditResult, d as HashlineInstance, R as ResolvedRange, V as VerifyHashResult, h as applyHashEdit, i as buildHashMap, j as computeLineHash, k as createHashline, l as formatFileWithHashes, m as getAdaptiveHashLength, n as getByteLength, o as matchesGlob, p as normalizeHashRef, q as parseHashRef, r as replaceRange, s as resolveConfig, t as resolveRange, u as shouldExclude, v as stripHashes, w as verifyHash } from './hashline-yhMw1Abs.js';
1
+ import { H as HashlineConfig, f as HashlineCache } from './hashline-A7k2yn3G.js';
2
+ export { C as CandidateLine, D as DEFAULT_CONFIG, g as DEFAULT_EXCLUDE_PATTERNS, h as DEFAULT_PREFIX, a as HashEditInput, b as HashEditOperation, c as HashEditResult, i as HashlineError, d as HashlineErrorCode, e as HashlineInstance, R as ResolvedRange, V as VerifyHashResult, j as applyHashEdit, k as buildHashMap, l as computeFileRev, m as computeLineHash, n as createHashline, o as extractFileRev, p as findCandidateLines, q as formatFileWithHashes, r as getAdaptiveHashLength, s as getByteLength, t as matchesGlob, u as normalizeHashRef, v as parseHashRef, w as replaceRange, x as resolveConfig, y as resolveRange, z as shouldExclude, A as stripHashes, B as verifyFileRev, E as verifyHash } from './hashline-A7k2yn3G.js';
3
3
  import { Hooks } from '@opencode-ai/plugin';
4
4
 
5
5
  /**
package/dist/utils.js CHANGED
@@ -3,16 +3,20 @@ import {
3
3
  createFileReadAfterHook,
4
4
  createSystemPromptHook,
5
5
  isFileReadTool
6
- } from "./chunk-VPCMHCTB.js";
6
+ } from "./chunk-VSVVWPET.js";
7
7
  import {
8
8
  DEFAULT_CONFIG,
9
9
  DEFAULT_EXCLUDE_PATTERNS,
10
10
  DEFAULT_PREFIX,
11
11
  HashlineCache,
12
+ HashlineError,
12
13
  applyHashEdit,
13
14
  buildHashMap,
15
+ computeFileRev,
14
16
  computeLineHash,
15
17
  createHashline,
18
+ extractFileRev,
19
+ findCandidateLines,
16
20
  formatFileWithHashes,
17
21
  getAdaptiveHashLength,
18
22
  getByteLength,
@@ -24,20 +28,25 @@ import {
24
28
  resolveRange,
25
29
  shouldExclude,
26
30
  stripHashes,
31
+ verifyFileRev,
27
32
  verifyHash
28
- } from "./chunk-I6RACR3D.js";
33
+ } from "./chunk-GKXY5ZBM.js";
29
34
  export {
30
35
  DEFAULT_CONFIG,
31
36
  DEFAULT_EXCLUDE_PATTERNS,
32
37
  DEFAULT_PREFIX,
33
38
  HashlineCache,
39
+ HashlineError,
34
40
  applyHashEdit,
35
41
  buildHashMap,
42
+ computeFileRev,
36
43
  computeLineHash,
37
44
  createFileEditBeforeHook,
38
45
  createFileReadAfterHook,
39
46
  createHashline,
40
47
  createSystemPromptHook,
48
+ extractFileRev,
49
+ findCandidateLines,
41
50
  formatFileWithHashes,
42
51
  getAdaptiveHashLength,
43
52
  getByteLength,
@@ -50,5 +59,6 @@ export {
50
59
  resolveRange,
51
60
  shouldExclude,
52
61
  stripHashes,
62
+ verifyFileRev,
53
63
  verifyHash
54
64
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-hashline",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "description": "Hashline plugin for OpenCode — content-addressable line hashing for precise AI code editing",
5
5
  "main": "dist/opencode-hashline.cjs",
6
6
  "module": "dist/opencode-hashline.js",