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/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,7 +216,49 @@ 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);
@@ -187,9 +282,14 @@ function formatFileWithHashes(content, hashLen, prefix) {
187
282
  hashes[idx] = hash;
188
283
  }
189
284
  }
190
- return lines.map((line, idx) => {
285
+ const annotatedLines = lines.map((line, idx) => {
191
286
  return `${effectivePrefix}${idx + 1}:${hashes[idx]}|${line}`;
192
- }).join("\n");
287
+ });
288
+ if (includeFileRev) {
289
+ const rev = computeFileRev(content);
290
+ annotatedLines.unshift(`${effectivePrefix}REV:${rev}`);
291
+ }
292
+ return annotatedLines.join("\n");
193
293
  }
194
294
  var stripRegexCache = /* @__PURE__ */ new Map();
195
295
  function stripHashes(content, prefix) {
@@ -200,9 +300,10 @@ function stripHashes(content, prefix) {
200
300
  hashLinePattern = new RegExp(`^([+ \\-])?${escapedPrefix}\\d+:[0-9a-f]{2,8}\\|`);
201
301
  stripRegexCache.set(escapedPrefix, hashLinePattern);
202
302
  }
303
+ const revPattern = new RegExp(`^${escapedPrefix}REV:[0-9a-f]{8}$`);
203
304
  const lineEnding = detectLineEnding(content);
204
305
  const normalized = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
205
- const result = normalized.split("\n").map((line) => {
306
+ const result = normalized.split("\n").filter((line) => !revPattern.test(line)).map((line) => {
206
307
  const match = line.match(hashLinePattern);
207
308
  if (match) {
208
309
  const patchMarker = match[1] || "";
@@ -216,7 +317,10 @@ function parseHashRef(ref) {
216
317
  const match = ref.match(/^(\d+):([0-9a-f]{2,8})$/);
217
318
  if (!match) {
218
319
  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>"`);
320
+ throw new HashlineError({
321
+ code: "INVALID_REF",
322
+ message: `Invalid hash reference: "${display}". Expected format: "<line>:<2-8 char hex>"`
323
+ });
220
324
  }
221
325
  return {
222
326
  line: parseInt(match[1], 10),
@@ -234,9 +338,10 @@ function normalizeHashRef(ref) {
234
338
  return `${parseInt(annotated[1], 10)}:${annotated[2].toLowerCase()}`;
235
339
  }
236
340
  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
- );
341
+ throw new HashlineError({
342
+ code: "INVALID_REF",
343
+ message: `Invalid hash reference: "${display}". Expected "<line>:<hash>" or an annotated line like "#HL <line>:<hash>|..."`
344
+ });
240
345
  }
241
346
  function buildHashMap(content, hashLen) {
242
347
  const lines = content.split("\n");
@@ -249,50 +354,97 @@ function buildHashMap(content, hashLen) {
249
354
  }
250
355
  return map;
251
356
  }
252
- function verifyHash(lineNumber, hash, currentContent, hashLen, lines) {
357
+ function verifyHash(lineNumber, hash, currentContent, hashLen, lines, safeReapply) {
253
358
  const contentLines = lines ?? currentContent.split("\n");
254
359
  const effectiveLen = hashLen && hashLen >= 2 ? hashLen : hash.length;
255
360
  if (lineNumber < 1 || lineNumber > contentLines.length) {
256
361
  return {
257
362
  valid: false,
363
+ code: "TARGET_OUT_OF_RANGE",
258
364
  message: `Line ${lineNumber} is out of range (file has ${contentLines.length} lines)`
259
365
  };
260
366
  }
261
367
  const idx = lineNumber - 1;
262
368
  const actualHash = computeLineHash(idx, contentLines[idx], effectiveLen);
263
369
  if (actualHash !== hash) {
370
+ const candidates = findCandidateLines(lineNumber, hash, contentLines, effectiveLen);
371
+ if (safeReapply && candidates.length === 1) {
372
+ return {
373
+ valid: true,
374
+ relocatedLine: candidates[0].lineNumber,
375
+ candidates
376
+ };
377
+ }
378
+ if (safeReapply && candidates.length > 1) {
379
+ return {
380
+ valid: false,
381
+ code: "AMBIGUOUS_REAPPLY",
382
+ expected: hash,
383
+ actual: actualHash,
384
+ candidates,
385
+ message: `Hash mismatch at line ${lineNumber}: expected "${hash}", got "${actualHash}". Found ${candidates.length} candidate lines \u2014 ambiguous reapply.`
386
+ };
387
+ }
264
388
  return {
265
389
  valid: false,
390
+ code: "HASH_MISMATCH",
266
391
  expected: hash,
267
392
  actual: actualHash,
393
+ candidates,
268
394
  message: `Hash mismatch at line ${lineNumber}: expected "${hash}", got "${actualHash}". The file may have changed since it was read.`
269
395
  };
270
396
  }
271
397
  return { valid: true };
272
398
  }
273
- function resolveRange(startRef, endRef, content, hashLen) {
399
+ function resolveRange(startRef, endRef, content, hashLen, safeReapply) {
274
400
  const start = parseHashRef(startRef);
275
401
  const end = parseHashRef(endRef);
276
402
  if (start.line > end.line) {
277
- throw new Error(
278
- `Invalid range: start line ${start.line} is after end line ${end.line}`
279
- );
403
+ throw new HashlineError({
404
+ code: "INVALID_RANGE",
405
+ message: `Invalid range: start line ${start.line} is after end line ${end.line}`
406
+ });
280
407
  }
281
408
  const lineEnding = detectLineEnding(content);
282
409
  const normalized = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
283
410
  const lines = normalized.split("\n");
284
- const startVerify = verifyHash(start.line, start.hash, normalized, hashLen, lines);
411
+ const startVerify = verifyHash(start.line, start.hash, normalized, hashLen, lines, safeReapply);
285
412
  if (!startVerify.valid) {
286
- throw new Error(`Start reference invalid: ${startVerify.message}`);
413
+ throw new HashlineError({
414
+ code: startVerify.code ?? "HASH_MISMATCH",
415
+ message: `Start reference invalid: ${startVerify.message}`,
416
+ expected: startVerify.expected,
417
+ actual: startVerify.actual,
418
+ candidates: startVerify.candidates,
419
+ lineNumber: start.line,
420
+ 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."
421
+ });
287
422
  }
288
- const endVerify = verifyHash(end.line, end.hash, normalized, hashLen, lines);
423
+ const effectiveStartLine = startVerify.relocatedLine ?? start.line;
424
+ const endVerify = verifyHash(end.line, end.hash, normalized, hashLen, lines, safeReapply);
289
425
  if (!endVerify.valid) {
290
- throw new Error(`End reference invalid: ${endVerify.message}`);
426
+ throw new HashlineError({
427
+ code: endVerify.code ?? "HASH_MISMATCH",
428
+ message: `End reference invalid: ${endVerify.message}`,
429
+ expected: endVerify.expected,
430
+ actual: endVerify.actual,
431
+ candidates: endVerify.candidates,
432
+ lineNumber: end.line,
433
+ 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."
434
+ });
291
435
  }
292
- const rangeLines = lines.slice(start.line - 1, end.line);
436
+ const effectiveEndLine = endVerify.relocatedLine ?? end.line;
437
+ if (effectiveStartLine > effectiveEndLine) {
438
+ throw new HashlineError({
439
+ code: "INVALID_RANGE",
440
+ message: `Invalid effective range after relocation: start line ${effectiveStartLine} is after end line ${effectiveEndLine}`,
441
+ hint: "The referenced lines may have been reordered. Re-read the file to get fresh references."
442
+ });
443
+ }
444
+ const rangeLines = lines.slice(effectiveStartLine - 1, effectiveEndLine);
293
445
  return {
294
- startLine: start.line,
295
- endLine: end.line,
446
+ startLine: effectiveStartLine,
447
+ endLine: effectiveEndLine,
296
448
  lines: rangeLines,
297
449
  content: rangeLines.join(lineEnding)
298
450
  };
@@ -308,22 +460,37 @@ function replaceRange(startRef, endRef, content, replacement, hashLen) {
308
460
  const result = [...before, ...replacementLines, ...after].join("\n");
309
461
  return lineEnding === "\r\n" ? result.replace(/\n/g, "\r\n") : result;
310
462
  }
311
- function applyHashEdit(input, content, hashLen) {
463
+ function applyHashEdit(input, content, hashLen, safeReapply) {
312
464
  const lineEnding = detectLineEnding(content);
313
465
  const workContent = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
466
+ if (input.fileRev) {
467
+ verifyFileRev(input.fileRev, workContent);
468
+ }
314
469
  const normalizedStart = normalizeHashRef(input.startRef);
315
470
  const start = parseHashRef(normalizedStart);
316
471
  const lines = workContent.split("\n");
317
- const startVerify = verifyHash(start.line, start.hash, workContent, hashLen, lines);
472
+ const startVerify = verifyHash(start.line, start.hash, workContent, hashLen, lines, safeReapply);
318
473
  if (!startVerify.valid) {
319
- throw new Error(`Start reference invalid: ${startVerify.message}`);
474
+ throw new HashlineError({
475
+ code: startVerify.code ?? "HASH_MISMATCH",
476
+ message: `Start reference invalid: ${startVerify.message}`,
477
+ expected: startVerify.expected,
478
+ actual: startVerify.actual,
479
+ candidates: startVerify.candidates,
480
+ lineNumber: start.line,
481
+ 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."
482
+ });
320
483
  }
484
+ const effectiveStartLine = startVerify.relocatedLine ?? start.line;
321
485
  if (input.operation === "insert_before" || input.operation === "insert_after") {
322
486
  if (input.replacement === void 0) {
323
- throw new Error(`Operation "${input.operation}" requires "replacement" content`);
487
+ throw new HashlineError({
488
+ code: "MISSING_REPLACEMENT",
489
+ message: `Operation "${input.operation}" requires "replacement" content`
490
+ });
324
491
  }
325
492
  const insertionLines = input.replacement.split("\n");
326
- const insertIndex = input.operation === "insert_before" ? start.line - 1 : start.line;
493
+ const insertIndex = input.operation === "insert_before" ? effectiveStartLine - 1 : effectiveStartLine;
327
494
  const next2 = [
328
495
  ...lines.slice(0, insertIndex),
329
496
  ...insertionLines,
@@ -331,34 +498,54 @@ function applyHashEdit(input, content, hashLen) {
331
498
  ].join("\n");
332
499
  return {
333
500
  operation: input.operation,
334
- startLine: start.line,
335
- endLine: start.line,
501
+ startLine: effectiveStartLine,
502
+ endLine: effectiveStartLine,
336
503
  content: lineEnding === "\r\n" ? next2.replace(/\n/g, "\r\n") : next2
337
504
  };
338
505
  }
339
506
  const normalizedEnd = normalizeHashRef(input.endRef ?? input.startRef);
340
507
  const end = parseHashRef(normalizedEnd);
341
508
  if (start.line > end.line) {
342
- throw new Error(
343
- `Invalid range: start line ${start.line} is after end line ${end.line}`
344
- );
509
+ throw new HashlineError({
510
+ code: "INVALID_RANGE",
511
+ message: `Invalid range: start line ${start.line} is after end line ${end.line}`
512
+ });
345
513
  }
346
- const endVerify = verifyHash(end.line, end.hash, workContent, hashLen, lines);
514
+ const endVerify = verifyHash(end.line, end.hash, workContent, hashLen, lines, safeReapply);
347
515
  if (!endVerify.valid) {
348
- throw new Error(`End reference invalid: ${endVerify.message}`);
516
+ throw new HashlineError({
517
+ code: endVerify.code ?? "HASH_MISMATCH",
518
+ message: `End reference invalid: ${endVerify.message}`,
519
+ expected: endVerify.expected,
520
+ actual: endVerify.actual,
521
+ candidates: endVerify.candidates,
522
+ lineNumber: end.line,
523
+ 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."
524
+ });
525
+ }
526
+ const effectiveEndLine = endVerify.relocatedLine ?? end.line;
527
+ if (effectiveStartLine > effectiveEndLine) {
528
+ throw new HashlineError({
529
+ code: "INVALID_RANGE",
530
+ message: `Invalid effective range after relocation: start line ${effectiveStartLine} is after end line ${effectiveEndLine}`,
531
+ hint: "The referenced lines may have been reordered. Re-read the file to get fresh references."
532
+ });
349
533
  }
350
534
  const replacement = input.operation === "delete" ? "" : input.replacement;
351
535
  if (replacement === void 0) {
352
- throw new Error(`Operation "${input.operation}" requires "replacement" content`);
536
+ throw new HashlineError({
537
+ code: "MISSING_REPLACEMENT",
538
+ message: `Operation "${input.operation}" requires "replacement" content`
539
+ });
353
540
  }
354
- const before = lines.slice(0, start.line - 1);
355
- const after = lines.slice(end.line);
541
+ const before = lines.slice(0, effectiveStartLine - 1);
542
+ const after = lines.slice(effectiveEndLine);
356
543
  const replacementLines = input.operation === "delete" ? [] : replacement.split("\n");
357
544
  const next = [...before, ...replacementLines, ...after].join("\n");
358
545
  return {
359
546
  operation: input.operation,
360
- startLine: start.line,
361
- endLine: end.line,
547
+ startLine: effectiveStartLine,
548
+ endLine: effectiveEndLine,
362
549
  content: lineEnding === "\r\n" ? next.replace(/\n/g, "\r\n") : next
363
550
  };
364
551
  }
@@ -455,7 +642,7 @@ function createHashline(config) {
455
642
  const cached = cache.get(filePath, content);
456
643
  if (cached) return cached;
457
644
  }
458
- const result = formatFileWithHashes(content, hl, pfx);
645
+ const result = formatFileWithHashes(content, hl, pfx, resolved.fileRev);
459
646
  if (filePath) {
460
647
  cache.set(filePath, content, result);
461
648
  }
@@ -471,16 +658,16 @@ function createHashline(config) {
471
658
  return buildHashMap(content, hl);
472
659
  },
473
660
  verifyHash(lineNumber, hash, currentContent) {
474
- return verifyHash(lineNumber, hash, currentContent, hl);
661
+ return verifyHash(lineNumber, hash, currentContent, hl, void 0, resolved.safeReapply);
475
662
  },
476
663
  resolveRange(startRef, endRef, content) {
477
- return resolveRange(startRef, endRef, content, hl);
664
+ return resolveRange(startRef, endRef, content, hl, resolved.safeReapply);
478
665
  },
479
666
  replaceRange(startRef, endRef, content, replacement) {
480
667
  return replaceRange(startRef, endRef, content, replacement, hl);
481
668
  },
482
669
  applyHashEdit(input, content) {
483
- return applyHashEdit(input, content, hl);
670
+ return applyHashEdit(input, content, hl, resolved.safeReapply);
484
671
  },
485
672
  normalizeHashRef(ref) {
486
673
  return normalizeHashRef(ref);
@@ -490,6 +677,18 @@ function createHashline(config) {
490
677
  },
491
678
  shouldExclude(filePath) {
492
679
  return shouldExclude(filePath, resolved.exclude);
680
+ },
681
+ computeFileRev(content) {
682
+ return computeFileRev(content);
683
+ },
684
+ verifyFileRev(expectedRev, currentContent) {
685
+ return verifyFileRev(expectedRev, currentContent);
686
+ },
687
+ extractFileRev(annotatedContent) {
688
+ return extractFileRev(annotatedContent, pfx);
689
+ },
690
+ findCandidateLines(originalLineNumber, expectedHash, lines, hashLen) {
691
+ return findCandidateLines(originalLineNumber, expectedHash, lines, hashLen);
493
692
  }
494
693
  };
495
694
  }
@@ -579,7 +778,7 @@ function createFileReadAfterHook(cache, config) {
579
778
  return;
580
779
  }
581
780
  }
582
- const annotated = formatFileWithHashes(content, hashLen || void 0, prefix);
781
+ const annotated = formatFileWithHashes(content, hashLen || void 0, prefix, resolved.fileRev);
583
782
  output.output = annotated;
584
783
  debug("annotated", typeof filePath === "string" ? filePath : input.tool, "lines:", content.split("\n").length);
585
784
  if (cache && typeof filePath === "string") {
@@ -691,10 +890,32 @@ function createSystemPromptHook(config) {
691
890
  '- Hash references include both the line number AND the content hash, so `2:f1c` means "line 2 with hash f1c".',
692
891
  "- If you see a mismatch, do NOT proceed with the edit \u2014 re-read the file to get fresh references.",
693
892
  "",
893
+ "### File revision (`#HL REV:<hash>`):",
894
+ "- When files are read, the first line may contain a file revision header: `" + prefix + "REV:<8-char-hex>`.",
895
+ "- 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.",
896
+ "- If the file was modified between read and edit, the revision check fails with `FILE_REV_MISMATCH` \u2014 re-read the file.",
897
+ "",
898
+ "### Safe reapply (`safeReapply`):",
899
+ "- Pass `safeReapply: true` to `hashline_edit` to enable automatic line relocation.",
900
+ "- If a line moved (e.g., due to insertions above), safe reapply finds it by content hash.",
901
+ "- If exactly one match is found, the edit proceeds at the new location.",
902
+ "- If multiple matches exist, the edit fails with `AMBIGUOUS_REAPPLY` \u2014 re-read the file.",
903
+ "",
904
+ "### Structured error codes:",
905
+ "- `HASH_MISMATCH` \u2014 line content changed since last read",
906
+ "- `FILE_REV_MISMATCH` \u2014 file was modified since last read",
907
+ "- `AMBIGUOUS_REAPPLY` \u2014 multiple candidate lines found during safe reapply",
908
+ "- `TARGET_OUT_OF_RANGE` \u2014 line number exceeds file length",
909
+ "- `INVALID_REF` \u2014 malformed hash reference",
910
+ "- `INVALID_RANGE` \u2014 start line is after end line",
911
+ "- `MISSING_REPLACEMENT` \u2014 replace/insert operation without replacement content",
912
+ "",
694
913
  "### Best practices:",
695
914
  "- Use hash references for all edit operations to ensure precision.",
696
915
  "- 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."
916
+ "- For large replacements, use range references (e.g., `1:a3f to 10:b2c`) instead of individual lines.",
917
+ "- Use `fileRev` to guard against stale edits on critical files.",
918
+ "- Use `safeReapply: true` when editing files that may have shifted due to earlier edits."
698
919
  ].join("\n")
699
920
  );
700
921
  };
@@ -705,13 +926,17 @@ function createSystemPromptHook(config) {
705
926
  DEFAULT_EXCLUDE_PATTERNS,
706
927
  DEFAULT_PREFIX,
707
928
  HashlineCache,
929
+ HashlineError,
708
930
  applyHashEdit,
709
931
  buildHashMap,
932
+ computeFileRev,
710
933
  computeLineHash,
711
934
  createFileEditBeforeHook,
712
935
  createFileReadAfterHook,
713
936
  createHashline,
714
937
  createSystemPromptHook,
938
+ extractFileRev,
939
+ findCandidateLines,
715
940
  formatFileWithHashes,
716
941
  getAdaptiveHashLength,
717
942
  getByteLength,
@@ -724,5 +949,6 @@ function createSystemPromptHook(config) {
724
949
  resolveRange,
725
950
  shouldExclude,
726
951
  stripHashes,
952
+ verifyFileRev,
727
953
  verifyHash
728
954
  });
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-7KUPGN4M.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-DOR4YDIS.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.0",
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",