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.
@@ -37,11 +37,15 @@ __export(hashline_exports, {
37
37
  DEFAULT_EXCLUDE_PATTERNS: () => DEFAULT_EXCLUDE_PATTERNS,
38
38
  DEFAULT_PREFIX: () => DEFAULT_PREFIX,
39
39
  HashlineCache: () => HashlineCache,
40
+ HashlineError: () => HashlineError,
40
41
  applyHashEdit: () => applyHashEdit,
41
42
  buildHashMap: () => buildHashMap,
43
+ computeFileRev: () => computeFileRev,
42
44
  computeLineHash: () => computeLineHash,
43
45
  createHashline: () => createHashline,
44
46
  detectLineEnding: () => detectLineEnding,
47
+ extractFileRev: () => extractFileRev,
48
+ findCandidateLines: () => findCandidateLines,
45
49
  formatFileWithHashes: () => formatFileWithHashes,
46
50
  getAdaptiveHashLength: () => getAdaptiveHashLength,
47
51
  getByteLength: () => getByteLength,
@@ -53,6 +57,7 @@ __export(hashline_exports, {
53
57
  resolveRange: () => resolveRange,
54
58
  shouldExclude: () => shouldExclude,
55
59
  stripHashes: () => stripHashes,
60
+ verifyFileRev: () => verifyFileRev,
56
61
  verifyHash: () => verifyHash
57
62
  });
58
63
  function resolveConfig(config, pluginConfig) {
@@ -69,7 +74,9 @@ function resolveConfig(config, pluginConfig) {
69
74
  hashLength: merged.hashLength ?? DEFAULT_CONFIG.hashLength,
70
75
  cacheSize: merged.cacheSize ?? DEFAULT_CONFIG.cacheSize,
71
76
  prefix: merged.prefix !== void 0 ? merged.prefix : DEFAULT_CONFIG.prefix,
72
- 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
73
80
  };
74
81
  }
75
82
  function fnv1aHash(str) {
@@ -100,33 +107,90 @@ function computeLineHash(idx, line, hashLen = 3) {
100
107
  const hash = raw % modulus;
101
108
  return hash.toString(16).padStart(hashLen, "0");
102
109
  }
103
- function formatFileWithHashes(content, hashLen, prefix) {
110
+ function computeFileRev(content) {
111
+ const normalized = content.includes("\r\n") ? content.replace(/\r\n/g, "\n") : content;
112
+ const hash = fnv1aHash(normalized);
113
+ return hash.toString(16).padStart(8, "0");
114
+ }
115
+ function extractFileRev(annotatedContent, prefix) {
116
+ const effectivePrefix = prefix === void 0 ? DEFAULT_PREFIX : prefix === false ? "" : prefix;
117
+ const escapedPrefix = effectivePrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
118
+ const pattern = new RegExp(`^${escapedPrefix}REV:([0-9a-f]{8})$`);
119
+ const firstLine = annotatedContent.split("\n")[0];
120
+ const match = firstLine.match(pattern);
121
+ return match ? match[1] : null;
122
+ }
123
+ function verifyFileRev(expectedRev, currentContent) {
124
+ const actualRev = computeFileRev(currentContent);
125
+ if (actualRev !== expectedRev) {
126
+ throw new HashlineError({
127
+ code: "FILE_REV_MISMATCH",
128
+ message: `File revision mismatch: expected "${expectedRev}", got "${actualRev}". The file has changed since it was last read.`,
129
+ expected: expectedRev,
130
+ actual: actualRev,
131
+ hint: "Re-read the file to get fresh hash references and a new file revision."
132
+ });
133
+ }
134
+ }
135
+ function findCandidateLines(originalLineNumber, expectedHash, lines, hashLen) {
136
+ const effectiveLen = hashLen && hashLen >= 2 ? hashLen : expectedHash.length;
137
+ const originalIdx = originalLineNumber - 1;
138
+ const candidates = [];
139
+ for (let i = 0; i < lines.length; i++) {
140
+ if (i === originalIdx) continue;
141
+ const candidateHash = computeLineHash(originalIdx, lines[i], effectiveLen);
142
+ if (candidateHash === expectedHash) {
143
+ candidates.push({
144
+ lineNumber: i + 1,
145
+ // 1-based
146
+ content: lines[i]
147
+ });
148
+ }
149
+ }
150
+ return candidates;
151
+ }
152
+ function formatFileWithHashes(content, hashLen, prefix, includeFileRev) {
104
153
  const normalized = content.includes("\r\n") ? content.replace(/\r\n/g, "\n") : content;
105
154
  const lines = normalized.split("\n");
106
155
  const effectiveLen = hashLen && hashLen >= 3 ? hashLen : getAdaptiveHashLength(lines.length);
107
156
  const effectivePrefix = prefix === void 0 ? DEFAULT_PREFIX : prefix === false ? "" : prefix;
157
+ const hashLens = new Array(lines.length).fill(effectiveLen);
108
158
  const hashes = new Array(lines.length);
109
- const seen = /* @__PURE__ */ new Map();
110
- const upgraded = /* @__PURE__ */ new Set();
111
159
  for (let idx = 0; idx < lines.length; idx++) {
112
- const hash = computeLineHash(idx, lines[idx], effectiveLen);
113
- if (seen.has(hash)) {
114
- const longerLen = Math.min(effectiveLen + 1, 8);
115
- const prevIdx = seen.get(hash);
116
- if (!upgraded.has(prevIdx)) {
117
- hashes[prevIdx] = computeLineHash(prevIdx, lines[prevIdx], longerLen);
118
- upgraded.add(prevIdx);
160
+ hashes[idx] = computeLineHash(idx, lines[idx], effectiveLen);
161
+ }
162
+ let hasCollisions = true;
163
+ while (hasCollisions) {
164
+ hasCollisions = false;
165
+ const seen = /* @__PURE__ */ new Map();
166
+ for (let idx = 0; idx < lines.length; idx++) {
167
+ const h = hashes[idx];
168
+ const group = seen.get(h);
169
+ if (group) {
170
+ group.push(idx);
171
+ } else {
172
+ seen.set(h, [idx]);
173
+ }
174
+ }
175
+ for (const [, group] of seen) {
176
+ if (group.length < 2) continue;
177
+ for (const idx of group) {
178
+ const newLen = Math.min(hashLens[idx] + 1, 8);
179
+ if (newLen === hashLens[idx]) continue;
180
+ hashLens[idx] = newLen;
181
+ hashes[idx] = computeLineHash(idx, lines[idx], newLen);
182
+ hasCollisions = true;
119
183
  }
120
- hashes[idx] = computeLineHash(idx, lines[idx], longerLen);
121
- upgraded.add(idx);
122
- } else {
123
- seen.set(hash, idx);
124
- hashes[idx] = hash;
125
184
  }
126
185
  }
127
- return lines.map((line, idx) => {
186
+ const annotatedLines = lines.map((line, idx) => {
128
187
  return `${effectivePrefix}${idx + 1}:${hashes[idx]}|${line}`;
129
- }).join("\n");
188
+ });
189
+ if (includeFileRev) {
190
+ const rev = computeFileRev(content);
191
+ annotatedLines.unshift(`${effectivePrefix}REV:${rev}`);
192
+ }
193
+ return annotatedLines.join("\n");
130
194
  }
131
195
  function stripHashes(content, prefix) {
132
196
  const effectivePrefix = prefix === void 0 ? DEFAULT_PREFIX : prefix === false ? "" : prefix;
@@ -136,9 +200,10 @@ function stripHashes(content, prefix) {
136
200
  hashLinePattern = new RegExp(`^([+ \\-])?${escapedPrefix}\\d+:[0-9a-f]{2,8}\\|`);
137
201
  stripRegexCache.set(escapedPrefix, hashLinePattern);
138
202
  }
203
+ const revPattern = new RegExp(`^${escapedPrefix}REV:[0-9a-f]{8}$`);
139
204
  const lineEnding = detectLineEnding(content);
140
205
  const normalized = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
141
- const result = normalized.split("\n").map((line) => {
206
+ const result = normalized.split("\n").filter((line) => !revPattern.test(line)).map((line) => {
142
207
  const match = line.match(hashLinePattern);
143
208
  if (match) {
144
209
  const patchMarker = match[1] || "";
@@ -152,7 +217,10 @@ function parseHashRef(ref) {
152
217
  const match = ref.match(/^(\d+):([0-9a-f]{2,8})$/);
153
218
  if (!match) {
154
219
  const display = ref.length > 100 ? `${ref.slice(0, 100)}\u2026` : ref;
155
- throw new Error(`Invalid hash reference: "${display}". Expected format: "<line>:<2-8 char hex>"`);
220
+ throw new HashlineError({
221
+ code: "INVALID_REF",
222
+ message: `Invalid hash reference: "${display}". Expected format: "<line>:<2-8 char hex>"`
223
+ });
156
224
  }
157
225
  return {
158
226
  line: parseInt(match[1], 10),
@@ -170,9 +238,10 @@ function normalizeHashRef(ref) {
170
238
  return `${parseInt(annotated[1], 10)}:${annotated[2].toLowerCase()}`;
171
239
  }
172
240
  const display = ref.length > 100 ? `${ref.slice(0, 100)}\u2026` : ref;
173
- throw new Error(
174
- `Invalid hash reference: "${display}". Expected "<line>:<hash>" or an annotated line like "#HL <line>:<hash>|..."`
175
- );
241
+ throw new HashlineError({
242
+ code: "INVALID_REF",
243
+ message: `Invalid hash reference: "${display}". Expected "<line>:<hash>" or an annotated line like "#HL <line>:<hash>|..."`
244
+ });
176
245
  }
177
246
  function buildHashMap(content, hashLen) {
178
247
  const lines = content.split("\n");
@@ -185,50 +254,97 @@ function buildHashMap(content, hashLen) {
185
254
  }
186
255
  return map;
187
256
  }
188
- function verifyHash(lineNumber, hash, currentContent, hashLen, lines) {
257
+ function verifyHash(lineNumber, hash, currentContent, hashLen, lines, safeReapply) {
189
258
  const contentLines = lines ?? currentContent.split("\n");
190
259
  const effectiveLen = hashLen && hashLen >= 2 ? hashLen : hash.length;
191
260
  if (lineNumber < 1 || lineNumber > contentLines.length) {
192
261
  return {
193
262
  valid: false,
263
+ code: "TARGET_OUT_OF_RANGE",
194
264
  message: `Line ${lineNumber} is out of range (file has ${contentLines.length} lines)`
195
265
  };
196
266
  }
197
267
  const idx = lineNumber - 1;
198
268
  const actualHash = computeLineHash(idx, contentLines[idx], effectiveLen);
199
269
  if (actualHash !== hash) {
270
+ const candidates = findCandidateLines(lineNumber, hash, contentLines, effectiveLen);
271
+ if (safeReapply && candidates.length === 1) {
272
+ return {
273
+ valid: true,
274
+ relocatedLine: candidates[0].lineNumber,
275
+ candidates
276
+ };
277
+ }
278
+ if (safeReapply && candidates.length > 1) {
279
+ return {
280
+ valid: false,
281
+ code: "AMBIGUOUS_REAPPLY",
282
+ expected: hash,
283
+ actual: actualHash,
284
+ candidates,
285
+ message: `Hash mismatch at line ${lineNumber}: expected "${hash}", got "${actualHash}". Found ${candidates.length} candidate lines \u2014 ambiguous reapply.`
286
+ };
287
+ }
200
288
  return {
201
289
  valid: false,
290
+ code: "HASH_MISMATCH",
202
291
  expected: hash,
203
292
  actual: actualHash,
293
+ candidates,
204
294
  message: `Hash mismatch at line ${lineNumber}: expected "${hash}", got "${actualHash}". The file may have changed since it was read.`
205
295
  };
206
296
  }
207
297
  return { valid: true };
208
298
  }
209
- function resolveRange(startRef, endRef, content, hashLen) {
299
+ function resolveRange(startRef, endRef, content, hashLen, safeReapply) {
210
300
  const start = parseHashRef(startRef);
211
301
  const end = parseHashRef(endRef);
212
302
  if (start.line > end.line) {
213
- throw new Error(
214
- `Invalid range: start line ${start.line} is after end line ${end.line}`
215
- );
303
+ throw new HashlineError({
304
+ code: "INVALID_RANGE",
305
+ message: `Invalid range: start line ${start.line} is after end line ${end.line}`
306
+ });
216
307
  }
217
308
  const lineEnding = detectLineEnding(content);
218
309
  const normalized = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
219
310
  const lines = normalized.split("\n");
220
- const startVerify = verifyHash(start.line, start.hash, normalized, hashLen, lines);
311
+ const startVerify = verifyHash(start.line, start.hash, normalized, hashLen, lines, safeReapply);
221
312
  if (!startVerify.valid) {
222
- throw new Error(`Start reference invalid: ${startVerify.message}`);
313
+ throw new HashlineError({
314
+ code: startVerify.code ?? "HASH_MISMATCH",
315
+ message: `Start reference invalid: ${startVerify.message}`,
316
+ expected: startVerify.expected,
317
+ actual: startVerify.actual,
318
+ candidates: startVerify.candidates,
319
+ lineNumber: start.line,
320
+ 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."
321
+ });
223
322
  }
224
- const endVerify = verifyHash(end.line, end.hash, normalized, hashLen, lines);
323
+ const effectiveStartLine = startVerify.relocatedLine ?? start.line;
324
+ const endVerify = verifyHash(end.line, end.hash, normalized, hashLen, lines, safeReapply);
225
325
  if (!endVerify.valid) {
226
- throw new Error(`End reference invalid: ${endVerify.message}`);
326
+ throw new HashlineError({
327
+ code: endVerify.code ?? "HASH_MISMATCH",
328
+ message: `End reference invalid: ${endVerify.message}`,
329
+ expected: endVerify.expected,
330
+ actual: endVerify.actual,
331
+ candidates: endVerify.candidates,
332
+ lineNumber: end.line,
333
+ 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."
334
+ });
227
335
  }
228
- const rangeLines = lines.slice(start.line - 1, end.line);
336
+ const effectiveEndLine = endVerify.relocatedLine ?? end.line;
337
+ if (effectiveStartLine > effectiveEndLine) {
338
+ throw new HashlineError({
339
+ code: "INVALID_RANGE",
340
+ message: `Invalid effective range after relocation: start line ${effectiveStartLine} is after end line ${effectiveEndLine}`,
341
+ hint: "The referenced lines may have been reordered. Re-read the file to get fresh references."
342
+ });
343
+ }
344
+ const rangeLines = lines.slice(effectiveStartLine - 1, effectiveEndLine);
229
345
  return {
230
- startLine: start.line,
231
- endLine: end.line,
346
+ startLine: effectiveStartLine,
347
+ endLine: effectiveEndLine,
232
348
  lines: rangeLines,
233
349
  content: rangeLines.join(lineEnding)
234
350
  };
@@ -244,22 +360,37 @@ function replaceRange(startRef, endRef, content, replacement, hashLen) {
244
360
  const result = [...before, ...replacementLines, ...after].join("\n");
245
361
  return lineEnding === "\r\n" ? result.replace(/\n/g, "\r\n") : result;
246
362
  }
247
- function applyHashEdit(input, content, hashLen) {
363
+ function applyHashEdit(input, content, hashLen, safeReapply) {
248
364
  const lineEnding = detectLineEnding(content);
249
365
  const workContent = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
366
+ if (input.fileRev) {
367
+ verifyFileRev(input.fileRev, workContent);
368
+ }
250
369
  const normalizedStart = normalizeHashRef(input.startRef);
251
370
  const start = parseHashRef(normalizedStart);
252
371
  const lines = workContent.split("\n");
253
- const startVerify = verifyHash(start.line, start.hash, workContent, hashLen, lines);
372
+ const startVerify = verifyHash(start.line, start.hash, workContent, hashLen, lines, safeReapply);
254
373
  if (!startVerify.valid) {
255
- throw new Error(`Start reference invalid: ${startVerify.message}`);
374
+ throw new HashlineError({
375
+ code: startVerify.code ?? "HASH_MISMATCH",
376
+ message: `Start reference invalid: ${startVerify.message}`,
377
+ expected: startVerify.expected,
378
+ actual: startVerify.actual,
379
+ candidates: startVerify.candidates,
380
+ lineNumber: start.line,
381
+ 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."
382
+ });
256
383
  }
384
+ const effectiveStartLine = startVerify.relocatedLine ?? start.line;
257
385
  if (input.operation === "insert_before" || input.operation === "insert_after") {
258
386
  if (input.replacement === void 0) {
259
- throw new Error(`Operation "${input.operation}" requires "replacement" content`);
387
+ throw new HashlineError({
388
+ code: "MISSING_REPLACEMENT",
389
+ message: `Operation "${input.operation}" requires "replacement" content`
390
+ });
260
391
  }
261
392
  const insertionLines = input.replacement.split("\n");
262
- const insertIndex = input.operation === "insert_before" ? start.line - 1 : start.line;
393
+ const insertIndex = input.operation === "insert_before" ? effectiveStartLine - 1 : effectiveStartLine;
263
394
  const next2 = [
264
395
  ...lines.slice(0, insertIndex),
265
396
  ...insertionLines,
@@ -267,34 +398,54 @@ function applyHashEdit(input, content, hashLen) {
267
398
  ].join("\n");
268
399
  return {
269
400
  operation: input.operation,
270
- startLine: start.line,
271
- endLine: start.line,
401
+ startLine: effectiveStartLine,
402
+ endLine: effectiveStartLine,
272
403
  content: lineEnding === "\r\n" ? next2.replace(/\n/g, "\r\n") : next2
273
404
  };
274
405
  }
275
406
  const normalizedEnd = normalizeHashRef(input.endRef ?? input.startRef);
276
407
  const end = parseHashRef(normalizedEnd);
277
408
  if (start.line > end.line) {
278
- throw new Error(
279
- `Invalid range: start line ${start.line} is after end line ${end.line}`
280
- );
409
+ throw new HashlineError({
410
+ code: "INVALID_RANGE",
411
+ message: `Invalid range: start line ${start.line} is after end line ${end.line}`
412
+ });
281
413
  }
282
- const endVerify = verifyHash(end.line, end.hash, workContent, hashLen, lines);
414
+ const endVerify = verifyHash(end.line, end.hash, workContent, hashLen, lines, safeReapply);
283
415
  if (!endVerify.valid) {
284
- throw new Error(`End reference invalid: ${endVerify.message}`);
416
+ throw new HashlineError({
417
+ code: endVerify.code ?? "HASH_MISMATCH",
418
+ message: `End reference invalid: ${endVerify.message}`,
419
+ expected: endVerify.expected,
420
+ actual: endVerify.actual,
421
+ candidates: endVerify.candidates,
422
+ lineNumber: end.line,
423
+ 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."
424
+ });
425
+ }
426
+ const effectiveEndLine = endVerify.relocatedLine ?? end.line;
427
+ if (effectiveStartLine > effectiveEndLine) {
428
+ throw new HashlineError({
429
+ code: "INVALID_RANGE",
430
+ message: `Invalid effective range after relocation: start line ${effectiveStartLine} is after end line ${effectiveEndLine}`,
431
+ hint: "The referenced lines may have been reordered. Re-read the file to get fresh references."
432
+ });
285
433
  }
286
434
  const replacement = input.operation === "delete" ? "" : input.replacement;
287
435
  if (replacement === void 0) {
288
- throw new Error(`Operation "${input.operation}" requires "replacement" content`);
436
+ throw new HashlineError({
437
+ code: "MISSING_REPLACEMENT",
438
+ message: `Operation "${input.operation}" requires "replacement" content`
439
+ });
289
440
  }
290
- const before = lines.slice(0, start.line - 1);
291
- const after = lines.slice(end.line);
441
+ const before = lines.slice(0, effectiveStartLine - 1);
442
+ const after = lines.slice(effectiveEndLine);
292
443
  const replacementLines = input.operation === "delete" ? [] : replacement.split("\n");
293
444
  const next = [...before, ...replacementLines, ...after].join("\n");
294
445
  return {
295
446
  operation: input.operation,
296
- startLine: start.line,
297
- endLine: end.line,
447
+ startLine: effectiveStartLine,
448
+ endLine: effectiveEndLine,
298
449
  content: lineEnding === "\r\n" ? next.replace(/\n/g, "\r\n") : next
299
450
  };
300
451
  }
@@ -330,7 +481,7 @@ function createHashline(config) {
330
481
  const cached = cache.get(filePath, content);
331
482
  if (cached) return cached;
332
483
  }
333
- const result = formatFileWithHashes(content, hl, pfx);
484
+ const result = formatFileWithHashes(content, hl, pfx, resolved.fileRev);
334
485
  if (filePath) {
335
486
  cache.set(filePath, content, result);
336
487
  }
@@ -346,16 +497,16 @@ function createHashline(config) {
346
497
  return buildHashMap(content, hl);
347
498
  },
348
499
  verifyHash(lineNumber, hash, currentContent) {
349
- return verifyHash(lineNumber, hash, currentContent, hl);
500
+ return verifyHash(lineNumber, hash, currentContent, hl, void 0, resolved.safeReapply);
350
501
  },
351
502
  resolveRange(startRef, endRef, content) {
352
- return resolveRange(startRef, endRef, content, hl);
503
+ return resolveRange(startRef, endRef, content, hl, resolved.safeReapply);
353
504
  },
354
505
  replaceRange(startRef, endRef, content, replacement) {
355
506
  return replaceRange(startRef, endRef, content, replacement, hl);
356
507
  },
357
508
  applyHashEdit(input, content) {
358
- return applyHashEdit(input, content, hl);
509
+ return applyHashEdit(input, content, hl, resolved.safeReapply);
359
510
  },
360
511
  normalizeHashRef(ref) {
361
512
  return normalizeHashRef(ref);
@@ -365,10 +516,22 @@ function createHashline(config) {
365
516
  },
366
517
  shouldExclude(filePath) {
367
518
  return shouldExclude(filePath, resolved.exclude);
519
+ },
520
+ computeFileRev(content) {
521
+ return computeFileRev(content);
522
+ },
523
+ verifyFileRev(expectedRev, currentContent) {
524
+ return verifyFileRev(expectedRev, currentContent);
525
+ },
526
+ extractFileRev(annotatedContent) {
527
+ return extractFileRev(annotatedContent, pfx);
528
+ },
529
+ findCandidateLines(originalLineNumber, expectedHash, lines, hashLen) {
530
+ return findCandidateLines(originalLineNumber, expectedHash, lines, hashLen);
368
531
  }
369
532
  };
370
533
  }
371
- var import_picomatch, DEFAULT_EXCLUDE_PATTERNS, DEFAULT_PREFIX, DEFAULT_CONFIG, modulusCache, stripRegexCache, HashlineCache, globMatcherCache, textEncoder;
534
+ var import_picomatch, DEFAULT_EXCLUDE_PATTERNS, DEFAULT_PREFIX, DEFAULT_CONFIG, HashlineError, modulusCache, stripRegexCache, HashlineCache, globMatcherCache, textEncoder;
372
535
  var init_hashline = __esm({
373
536
  "src/hashline.ts"() {
374
537
  "use strict";
@@ -429,7 +592,53 @@ var init_hashline = __esm({
429
592
  // 0 = adaptive
430
593
  cacheSize: 100,
431
594
  prefix: DEFAULT_PREFIX,
432
- debug: false
595
+ debug: false,
596
+ fileRev: true,
597
+ safeReapply: false
598
+ };
599
+ HashlineError = class extends Error {
600
+ code;
601
+ expected;
602
+ actual;
603
+ candidates;
604
+ hint;
605
+ lineNumber;
606
+ filePath;
607
+ constructor(opts) {
608
+ super(opts.message);
609
+ this.name = "HashlineError";
610
+ this.code = opts.code;
611
+ this.expected = opts.expected;
612
+ this.actual = opts.actual;
613
+ this.candidates = opts.candidates;
614
+ this.hint = opts.hint;
615
+ this.lineNumber = opts.lineNumber;
616
+ this.filePath = opts.filePath;
617
+ }
618
+ toDiagnostic() {
619
+ const parts = [`[${this.code}] ${this.message}`];
620
+ if (this.filePath) {
621
+ parts.push(` File: ${this.filePath}`);
622
+ }
623
+ if (this.lineNumber !== void 0) {
624
+ parts.push(` Line: ${this.lineNumber}`);
625
+ }
626
+ if (this.expected !== void 0 && this.actual !== void 0) {
627
+ parts.push(` Expected hash: ${this.expected}`);
628
+ parts.push(` Actual hash: ${this.actual}`);
629
+ }
630
+ if (this.candidates && this.candidates.length > 0) {
631
+ parts.push(` Candidates (${this.candidates.length}):`);
632
+ for (const c of this.candidates) {
633
+ const preview = c.content.length > 60 ? c.content.slice(0, 60) + "..." : c.content;
634
+ parts.push(` - line ${c.lineNumber}: ${preview}`);
635
+ }
636
+ }
637
+ if (this.hint) {
638
+ parts.push(` Hint: ${this.hint}`);
639
+ }
640
+ return parts.join("\n");
641
+ }
433
642
  };
434
643
  modulusCache = /* @__PURE__ */ new Map();
435
644
  stripRegexCache = /* @__PURE__ */ new Map();
@@ -508,6 +717,7 @@ module.exports = __toCommonJS(src_exports);
508
717
  var import_fs3 = require("fs");
509
718
  var import_path3 = require("path");
510
719
  var import_os2 = require("os");
720
+ var import_crypto = require("crypto");
511
721
  var import_url = require("url");
512
722
 
513
723
  // src/hooks.ts
@@ -599,7 +809,7 @@ function createFileReadAfterHook(cache, config) {
599
809
  return;
600
810
  }
601
811
  }
602
- const annotated = formatFileWithHashes(content, hashLen || void 0, prefix);
812
+ const annotated = formatFileWithHashes(content, hashLen || void 0, prefix, resolved.fileRev);
603
813
  output.output = annotated;
604
814
  debug("annotated", typeof filePath === "string" ? filePath : input.tool, "lines:", content.split("\n").length);
605
815
  if (cache && typeof filePath === "string") {
@@ -711,10 +921,32 @@ function createSystemPromptHook(config) {
711
921
  '- Hash references include both the line number AND the content hash, so `2:f1c` means "line 2 with hash f1c".',
712
922
  "- If you see a mismatch, do NOT proceed with the edit \u2014 re-read the file to get fresh references.",
713
923
  "",
924
+ "### File revision (`#HL REV:<hash>`):",
925
+ "- When files are read, the first line may contain a file revision header: `" + prefix + "REV:<8-char-hex>`.",
926
+ "- 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.",
927
+ "- If the file was modified between read and edit, the revision check fails with `FILE_REV_MISMATCH` \u2014 re-read the file.",
928
+ "",
929
+ "### Safe reapply (`safeReapply`):",
930
+ "- Pass `safeReapply: true` to `hashline_edit` to enable automatic line relocation.",
931
+ "- If a line moved (e.g., due to insertions above), safe reapply finds it by content hash.",
932
+ "- If exactly one match is found, the edit proceeds at the new location.",
933
+ "- If multiple matches exist, the edit fails with `AMBIGUOUS_REAPPLY` \u2014 re-read the file.",
934
+ "",
935
+ "### Structured error codes:",
936
+ "- `HASH_MISMATCH` \u2014 line content changed since last read",
937
+ "- `FILE_REV_MISMATCH` \u2014 file was modified since last read",
938
+ "- `AMBIGUOUS_REAPPLY` \u2014 multiple candidate lines found during safe reapply",
939
+ "- `TARGET_OUT_OF_RANGE` \u2014 line number exceeds file length",
940
+ "- `INVALID_REF` \u2014 malformed hash reference",
941
+ "- `INVALID_RANGE` \u2014 start line is after end line",
942
+ "- `MISSING_REPLACEMENT` \u2014 replace/insert operation without replacement content",
943
+ "",
714
944
  "### Best practices:",
715
945
  "- Use hash references for all edit operations to ensure precision.",
716
946
  "- When making multiple edits, work from bottom to top to avoid line number shifts.",
717
- "- For large replacements, use range references (e.g., `1:a3f to 10:b2c`) instead of individual lines."
947
+ "- For large replacements, use range references (e.g., `1:a3f to 10:b2c`) instead of individual lines.",
948
+ "- Use `fileRev` to guard against stale edits on critical files.",
949
+ "- Use `safeReapply: true` when editing files that may have shifted due to earlier edits."
718
950
  ].join("\n")
719
951
  );
720
952
  };
@@ -736,10 +968,12 @@ function createHashlineEditTool(config, cache) {
736
968
  operation: import_zod.z.enum(["replace", "delete", "insert_before", "insert_after"]).describe("Edit operation"),
737
969
  startRef: import_zod.z.string().describe('Start hash reference, e.g. "5:a3f" or "#HL 5:a3f|const x = 1;"'),
738
970
  endRef: import_zod.z.string().optional().describe("End hash reference for range operations. Defaults to startRef when omitted."),
739
- replacement: import_zod.z.string().max(1e7).optional().describe("Replacement/inserted content. Required for replace/insert operations.")
971
+ replacement: import_zod.z.string().max(1e7).optional().describe("Replacement/inserted content. Required for replace/insert operations."),
972
+ fileRev: import_zod.z.string().optional().describe("File revision hash (8-char hex from #HL REV:<hash>). When provided, verifies the file hasn't changed before editing."),
973
+ safeReapply: import_zod.z.boolean().optional().describe("Enable safe reapply: if a line moved, attempt to find it by content hash. Fails on ambiguous matches.")
740
974
  },
741
975
  async execute(args, context) {
742
- const { path, operation, startRef, endRef, replacement } = args;
976
+ const { path, operation, startRef, endRef, replacement, fileRev, safeReapply } = args;
743
977
  const absPath = (0, import_path2.isAbsolute)(path) ? path : (0, import_path2.resolve)(context.directory, path);
744
978
  const realDirectory = (0, import_fs2.realpathSync)((0, import_path2.resolve)(context.directory));
745
979
  const realWorktree = (0, import_fs2.realpathSync)((0, import_path2.resolve)(context.worktree));
@@ -793,15 +1027,21 @@ function createHashlineEditTool(config, cache) {
793
1027
  operation,
794
1028
  startRef,
795
1029
  endRef,
796
- replacement
1030
+ replacement,
1031
+ fileRev
797
1032
  },
798
1033
  current,
799
- config.hashLength || void 0
1034
+ config.hashLength || void 0,
1035
+ safeReapply ?? config.safeReapply
800
1036
  );
801
1037
  nextContent = result.content;
802
1038
  startLine = result.startLine;
803
1039
  endLine = result.endLine;
804
1040
  } catch (error) {
1041
+ if (error instanceof HashlineError) {
1042
+ throw new Error(`Hashline edit failed for "${displayPath}":
1043
+ ${error.toDiagnostic()}`);
1044
+ }
805
1045
  const reason = error instanceof Error ? error.message : String(error);
806
1046
  throw new Error(`Hashline edit failed for "${displayPath}": ${reason}`);
807
1047
  }
@@ -838,6 +1078,33 @@ function createHashlineEditTool(config, cache) {
838
1078
 
839
1079
  // src/index.ts
840
1080
  var CONFIG_FILENAME = "opencode-hashline.json";
1081
+ var tempDirs = /* @__PURE__ */ new Set();
1082
+ var exitListenerRegistered = false;
1083
+ function registerTempDir(dir) {
1084
+ tempDirs.add(dir);
1085
+ if (!exitListenerRegistered) {
1086
+ exitListenerRegistered = true;
1087
+ process.on("exit", () => {
1088
+ for (const d of tempDirs) {
1089
+ try {
1090
+ (0, import_fs3.rmSync)(d, { recursive: true, force: true });
1091
+ } catch {
1092
+ }
1093
+ }
1094
+ });
1095
+ }
1096
+ }
1097
+ function writeTempFile(tempDir, content) {
1098
+ const name = `hl-${(0, import_crypto.randomBytes)(16).toString("hex")}.txt`;
1099
+ const tmpPath = (0, import_path3.join)(tempDir, name);
1100
+ const fd = (0, import_fs3.openSync)(tmpPath, import_fs3.constants.O_WRONLY | import_fs3.constants.O_CREAT | import_fs3.constants.O_EXCL, 384);
1101
+ try {
1102
+ (0, import_fs3.writeFileSync)(fd, content, "utf-8");
1103
+ } finally {
1104
+ (0, import_fs3.closeSync)(fd);
1105
+ }
1106
+ return tmpPath;
1107
+ }
841
1108
  function sanitizeConfig(raw) {
842
1109
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {};
843
1110
  const r = raw;
@@ -866,6 +1133,12 @@ function sanitizeConfig(raw) {
866
1133
  if (typeof r.debug === "boolean") {
867
1134
  result.debug = r.debug;
868
1135
  }
1136
+ if (typeof r.fileRev === "boolean") {
1137
+ result.fileRev = r.fileRev;
1138
+ }
1139
+ if (typeof r.safeReapply === "boolean") {
1140
+ result.safeReapply = r.safeReapply;
1141
+ }
869
1142
  return result;
870
1143
  }
871
1144
  function loadConfigFile(filePath) {
@@ -906,17 +1179,8 @@ function createHashlinePlugin(userConfig) {
906
1179
  } catch {
907
1180
  }
908
1181
  }
909
- const tempFiles = /* @__PURE__ */ new Set();
910
- const cleanupTempFiles = () => {
911
- for (const f of tempFiles) {
912
- try {
913
- (0, import_fs3.unlinkSync)(f);
914
- } catch {
915
- }
916
- }
917
- tempFiles.clear();
918
- };
919
- process.on("exit", cleanupTempFiles);
1182
+ const instanceTmpDir = (0, import_fs3.mkdtempSync)((0, import_path3.join)((0, import_os2.tmpdir)(), "hashline-"));
1183
+ registerTempDir(instanceTmpDir);
920
1184
  return {
921
1185
  tool: {
922
1186
  hashline_edit: createHashlineEditTool(config, cache)
@@ -959,9 +1223,7 @@ function createHashlinePlugin(userConfig) {
959
1223
  if (config.maxFileSize > 0 && getByteLength2(content) > config.maxFileSize) continue;
960
1224
  const cached = cache.get(filePath, content);
961
1225
  if (cached) {
962
- const tmpPath2 = (0, import_path3.join)((0, import_os2.tmpdir)(), `hashline-${p.id}.txt`);
963
- (0, import_fs3.writeFileSync)(tmpPath2, cached, "utf-8");
964
- tempFiles.add(tmpPath2);
1226
+ const tmpPath2 = writeTempFile(instanceTmpDir, cached);
965
1227
  p.url = `file://${tmpPath2}`;
966
1228
  if (config.debug) {
967
1229
  try {
@@ -972,10 +1234,9 @@ function createHashlinePlugin(userConfig) {
972
1234
  }
973
1235
  continue;
974
1236
  }
975
- const annotated = formatFileWithHashes2(content, hashLen || void 0, prefix);
1237
+ const annotated = formatFileWithHashes2(content, hashLen || void 0, prefix, config.fileRev);
976
1238
  cache.set(filePath, content, annotated);
977
- const tmpPath = (0, import_path3.join)((0, import_os2.tmpdir)(), `hashline-${p.id}.txt`);
978
- (0, import_fs3.writeFileSync)(tmpPath, annotated, "utf-8");
1239
+ const tmpPath = writeTempFile(instanceTmpDir, annotated);
979
1240
  p.url = `file://${tmpPath}`;
980
1241
  if (config.debug) {
981
1242
  try {