@levnikolaevich/hex-line-mcp 1.12.1 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,7 +7,7 @@ Hash-verified file editing MCP + token efficiency hook for AI coding agents.
7
7
  [![license](https://img.shields.io/npm/l/@levnikolaevich/hex-line-mcp)](./LICENSE)
8
8
  ![node](https://img.shields.io/node/v/@levnikolaevich/hex-line-mcp)
9
9
 
10
- Every line carries an FNV-1a content hash. Every edit must present those hashes back -- proving the agent is editing what it thinks it's editing. No stale context, no silent corruption.
10
+ Every line carries an FNV-1a content hash. Every edit must present those hashes back -- proving the agent is editing what it thinks it's editing. No stale context, no silent corruption. Hashing works on normalized logical text; writes preserve the file's existing line endings and trailing-newline shape.
11
11
 
12
12
  ## Features
13
13
 
@@ -126,7 +126,8 @@ If a project already has `.hex-skills/codegraph/index.db`, `hex-line` automatica
126
126
 
127
127
  1. Carry `revision` from the earlier `read_file` or `edit_file`
128
128
  2. Pass it back as `base_revision`
129
- 3. Use `verify` before rereading the file
129
+ 3. Use `verify` before delayed or mixed-tool follow-up edits
130
+ 4. If the server returns `retry_edit`, `retry_edits`, `retry_checksum`, or `retry_plan`, reuse those directly
130
131
 
131
132
  ### Rewrite a long block
132
133
 
@@ -156,9 +157,13 @@ File: lib/search.mjs
156
157
  meta: 282 lines, 10.2KB, 2 hours ago
157
158
  revision: rev-12-a1b2c3d4
158
159
  file: 1-282:beefcafe
160
+ eol: lf
161
+ trailing_newline: true
159
162
 
160
163
  block: read_range
161
164
  span: 1-3
165
+ eol: lf
166
+ trailing_newline: true
162
167
  ab.1 import { resolve } from "node:path";
163
168
  cd.2 import { readFileSync } from "node:fs";
164
169
  ef.3 ...
@@ -193,7 +198,8 @@ Discipline:
193
198
 
194
199
  - Never invent `range_checksum`. Copy it from `read_file` or `grep_search(output:"content")`.
195
200
  - First mutation in a file: prefer `grep_search` for narrow targets, or `outline -> read_file(ranges)` for structural edits.
196
- - Prefer 1-2 hunks on the first pass. Once `edit_file` returns a fresh `revision`, continue from that state.
201
+ - Prefer 1-2 hunks on the first pass. Once `edit_file` returns a fresh `revision`, continue from that state as `base_revision`.
202
+ - `hex-line` preserves existing file line endings on write; repo-level line-ending cleanup should be a separate deliberate operation, not a side effect of `edit_file`.
197
203
 
198
204
  Result footer includes:
199
205
 
package/dist/hook.mjs CHANGED
@@ -111,7 +111,7 @@ var REVERSE_TOOL_HINTS = {
111
111
  "mcp__hex-line__grep_search": "Grep (pattern, path)",
112
112
  "mcp__hex-line__inspect_path": "Path info / tree / Bash(ls,stat)",
113
113
  "mcp__hex-line__outline": "Read with offset/limit",
114
- "mcp__hex-line__verify": "Read (re-read file to check freshness)",
114
+ "mcp__hex-line__verify": "Read (check checksum/revision freshness before follow-up edits)",
115
115
  "mcp__hex-line__changes": "Bash(git diff)",
116
116
  "mcp__hex-line__bulk_replace": "Edit (text rename/refactor across files inside an explicit root path)"
117
117
  };
@@ -129,7 +129,7 @@ var TOOL_HINTS = {
129
129
  sed: "mcp__hex-line__edit_file for hash edits, or mcp__hex-line__bulk_replace with path=<project root> for text rename (not sed -i)",
130
130
  diff: "mcp__hex-line__changes (not diff). Git diff with change symbols",
131
131
  outline: "mcp__hex-line__outline (before reading large code files)",
132
- verify: "mcp__hex-line__verify (staleness / revision check without re-read)",
132
+ verify: "mcp__hex-line__verify (staleness / revision check without re-read; use before delayed same-file follow-ups)",
133
133
  changes: "mcp__hex-line__changes (git diff with change symbols)",
134
134
  bulk: "mcp__hex-line__bulk_replace with path=<project root> (multi-file search-replace)"
135
135
  };
package/dist/server.mjs CHANGED
@@ -669,6 +669,70 @@ function parseChecksum(cs) {
669
669
  return { start: parseInt(m[1], 10), end: parseInt(m[2], 10), hex: m[3] };
670
670
  }
671
671
 
672
+ // ../hex-common/src/text/file-text.mjs
673
+ import { readFileSync as readFileSync2 } from "node:fs";
674
+ function classifyEol(counts) {
675
+ const active = Object.entries(counts).filter(([, count]) => count > 0);
676
+ if (active.length === 0) return "none";
677
+ if (active.length === 1) return active[0][0];
678
+ return "mixed";
679
+ }
680
+ function chooseDefaultEol(lineEndings, counts) {
681
+ const active = Object.entries(counts).filter(([, count]) => count > 0).sort((a, b) => b[1] - a[1]);
682
+ if (active.length === 0) return "\n";
683
+ if (active.length === 1 || active[0][1] > active[1][1]) {
684
+ return active[0][0] === "crlf" ? "\r\n" : active[0][0] === "cr" ? "\r" : "\n";
685
+ }
686
+ const firstSeen = lineEndings.find((ending) => ending);
687
+ return firstSeen || "\n";
688
+ }
689
+ function normalizeSourceText(text) {
690
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
691
+ }
692
+ function parseUtf8TextWithMetadata(text) {
693
+ const lines = [];
694
+ const lineEndings = [];
695
+ const eolCounts = { lf: 0, crlf: 0, cr: 0 };
696
+ let start = 0;
697
+ for (let i = 0; i < text.length; i++) {
698
+ const ch = text[i];
699
+ if (ch === "\r") {
700
+ const isCrlf = text[i + 1] === "\n";
701
+ lines.push(text.slice(start, i));
702
+ lineEndings.push(isCrlf ? "\r\n" : "\r");
703
+ if (isCrlf) {
704
+ eolCounts.crlf++;
705
+ i++;
706
+ } else {
707
+ eolCounts.cr++;
708
+ }
709
+ start = i + 1;
710
+ continue;
711
+ }
712
+ if (ch === "\n") {
713
+ lines.push(text.slice(start, i));
714
+ lineEndings.push("\n");
715
+ eolCounts.lf++;
716
+ start = i + 1;
717
+ }
718
+ }
719
+ lines.push(text.slice(start));
720
+ lineEndings.push("");
721
+ const trailingNewline = text.endsWith("\n") || text.endsWith("\r");
722
+ return {
723
+ rawText: text,
724
+ content: normalizeSourceText(text),
725
+ lines,
726
+ lineEndings,
727
+ trailingNewline,
728
+ eol: classifyEol(eolCounts),
729
+ defaultEol: chooseDefaultEol(lineEndings, eolCounts)
730
+ };
731
+ }
732
+ function readUtf8WithMetadata(filePath) {
733
+ return parseUtf8TextWithMetadata(readFileSync2(filePath, "utf-8"));
734
+ }
735
+
672
736
  // lib/snapshot.mjs
673
737
  var MAX_FILES = 200;
674
738
  var MAX_REVISIONS_PER_FILE = 5;
@@ -705,6 +769,10 @@ function pruneExpired(now = Date.now()) {
705
769
  }
706
770
  function rememberRevisionId(filePath, revision) {
707
771
  const ids = fileRevisionIds.get(filePath) || [];
772
+ if (ids.includes(revision)) {
773
+ fileRevisionIds.set(filePath, ids);
774
+ return;
775
+ }
708
776
  ids.push(revision);
709
777
  while (ids.length > MAX_REVISIONS_PER_FILE) {
710
778
  const removed = ids.shift();
@@ -796,36 +864,42 @@ function describeChangedRanges(ranges) {
796
864
  if (!ranges?.length) return "none";
797
865
  return ranges.map((r) => `${r.start}-${r.end}${r.kind ? `(${r.kind})` : ""}`).join(", ");
798
866
  }
799
- function createSnapshot(filePath, content, mtimeMs, size, prevSnapshot = null) {
800
- const lines = content.split("\n");
867
+ function createSnapshot(filePath, parsed, mtimeMs, size, prevSnapshot = null, revisionOverride = null) {
868
+ const { content, lines, lineEndings, rawText, eol, defaultEol, trailingNewline } = parsed;
801
869
  const lineHashes = lines.map((line) => fnv1a(line));
802
870
  const fileChecksum = computeFileChecksum(lineHashes);
803
- const revision = `rev-${++revisionSeq}-${fileChecksum.split(":")[1]}`;
871
+ const revision = revisionOverride || `rev-${++revisionSeq}-${fileChecksum.split(":")[1]}`;
804
872
  return {
805
873
  revision,
806
874
  path: filePath,
807
875
  content,
876
+ rawText,
808
877
  lines,
878
+ lineEndings,
809
879
  lineHashes,
810
880
  fileChecksum,
811
881
  uniqueTagIndex: buildUniqueTagIndex(lineHashes),
812
882
  changedRangesFromPrev: prevSnapshot ? computeChangedRanges(prevSnapshot.lines, lines) : [],
813
883
  prevRevision: prevSnapshot?.revision || null,
884
+ eol,
885
+ defaultEol,
886
+ trailingNewline,
814
887
  mtimeMs,
815
888
  size,
816
889
  createdAt: Date.now()
817
890
  };
818
891
  }
819
- function rememberSnapshot(filePath, content, meta = {}) {
892
+ function rememberSnapshot(filePath, input, meta = {}) {
820
893
  pruneExpired();
821
894
  const latest = latestByFile.get(filePath);
895
+ const parsed = typeof input === "string" ? parseUtf8TextWithMetadata(input) : input;
822
896
  const mtimeMs = meta.mtimeMs ?? latest?.mtimeMs ?? Date.now();
823
- const size = meta.size ?? Buffer.byteLength(content, "utf8");
824
- if (latest && latest.content === content && latest.mtimeMs === mtimeMs && latest.size === size) {
897
+ const size = meta.size ?? Buffer.byteLength(parsed.rawText, "utf8");
898
+ if (latest && latest.content === parsed.content && latest.rawText === parsed.rawText && latest.mtimeMs === mtimeMs && latest.size === size) {
825
899
  touchFile(filePath);
826
900
  return latest;
827
901
  }
828
- const snapshot = createSnapshot(filePath, content, mtimeMs, size, latest || null);
902
+ const snapshot = latest && latest.content === parsed.content ? createSnapshot(filePath, parsed, mtimeMs, size, latest || null, latest.revision) : createSnapshot(filePath, parsed, mtimeMs, size, latest || null);
829
903
  latestByFile.set(filePath, snapshot);
830
904
  revisionsById.set(snapshot.revision, snapshot);
831
905
  rememberRevisionId(filePath, snapshot.revision);
@@ -841,8 +915,8 @@ function readSnapshot(filePath) {
841
915
  touchFile(filePath);
842
916
  return latest;
843
917
  }
844
- const content = readText(filePath);
845
- return rememberSnapshot(filePath, content, { mtimeMs: stat.mtimeMs, size: stat.size });
918
+ const parsed = readUtf8WithMetadata(filePath);
919
+ return rememberSnapshot(filePath, parsed, { mtimeMs: stat.mtimeMs, size: stat.size });
846
920
  }
847
921
  function getSnapshotByRevision(revision) {
848
922
  pruneExpired();
@@ -874,6 +948,9 @@ function renderRequestedSpan(block) {
874
948
  if (block.requestedStartLine === block.startLine && block.requestedEndLine === block.endLine) return null;
875
949
  return `requested_span: ${block.requestedStartLine}-${block.requestedEndLine}`;
876
950
  }
951
+ function renderMetaLines(meta = {}) {
952
+ return Object.entries(meta).filter(([, value]) => value !== void 0 && value !== null && value !== "").map(([key, value]) => `${key}: ${value}`);
953
+ }
877
954
  function renderBaseEntry(entry, plain = false) {
878
955
  return plain ? `${entry.lineNumber}|${entry.text}` : `${entry.tag}.${entry.lineNumber} ${entry.text}`;
879
956
  }
@@ -950,6 +1027,7 @@ function serializeReadBlock(block, opts = {}) {
950
1027
  ];
951
1028
  const requestedSpan = renderRequestedSpan(block);
952
1029
  if (requestedSpan) lines.push(requestedSpan);
1030
+ lines.push(...renderMetaLines(block.meta));
953
1031
  lines.push(...block.entries.map((entry) => serializeReadEntry(entry, opts)));
954
1032
  lines.push(`checksum: ${block.checksum}`);
955
1033
  return lines.join("\n");
@@ -967,6 +1045,9 @@ function serializeSearchBlock(block, opts = {}) {
967
1045
  lines.push(`match_lines: ${block.meta.matchLines.join(",")}`);
968
1046
  }
969
1047
  if (block.meta.summary) lines.push(`summary: ${block.meta.summary}`);
1048
+ lines.push(...renderMetaLines(Object.fromEntries(
1049
+ Object.entries(block.meta).filter(([key]) => key !== "matchLines" && key !== "summary")
1050
+ )));
970
1051
  lines.push(...block.entries.map((entry) => serializeSearchEntry(entry, opts)));
971
1052
  lines.push(`checksum: ${block.checksum}`);
972
1053
  return lines.join("\n");
@@ -1060,7 +1141,11 @@ function buildReadBlock(snapshot, range, plain, remainingChars) {
1060
1141
  kind: "read_range",
1061
1142
  entries,
1062
1143
  requestedStartLine: range.requestedStartLine,
1063
- requestedEndLine: range.requestedEndLine
1144
+ requestedEndLine: range.requestedEndLine,
1145
+ meta: {
1146
+ eol: snapshot.eol,
1147
+ trailing_newline: snapshot.trailingNewline
1148
+ }
1064
1149
  }),
1065
1150
  remainingChars: nextBudget,
1066
1151
  cappedAtLine
@@ -1166,6 +1251,8 @@ Graph: ${items.join(" | ")}`;
1166
1251
  meta: ${meta}
1167
1252
  revision: ${snapshot.revision}
1168
1253
  file: ${snapshot.fileChecksum}
1254
+ eol: ${snapshot.eol}
1255
+ trailing_newline: ${snapshot.trailingNewline}
1169
1256
 
1170
1257
  ${serializedBlocks.join("\n\n")}`.trim();
1171
1258
  }
@@ -1337,6 +1424,41 @@ function sanitizeEditText(text) {
1337
1424
  if (hadTrailingNewline && !cleaned.endsWith("\n")) cleaned += "\n";
1338
1425
  return cleaned;
1339
1426
  }
1427
+ function replaceLogicalRange(lines, lineEndings, startIdx, endIdx, newLines, defaultEol) {
1428
+ const removeCount = endIdx - startIdx + 1;
1429
+ const tailEnding = lineEndings[endIdx] ?? "";
1430
+ const lastIdx = lines.length - 1;
1431
+ if (newLines.length === 0) {
1432
+ lines.splice(startIdx, removeCount);
1433
+ lineEndings.splice(startIdx, removeCount);
1434
+ if (lines.length === 0) {
1435
+ lines.push("");
1436
+ lineEndings.push("");
1437
+ return;
1438
+ }
1439
+ if (endIdx === lastIdx && startIdx > 0) {
1440
+ lineEndings[startIdx - 1] = tailEnding;
1441
+ }
1442
+ return;
1443
+ }
1444
+ const newEndings = newLines.map((_, idx) => idx === newLines.length - 1 ? tailEnding : defaultEol);
1445
+ lines.splice(startIdx, removeCount, ...newLines);
1446
+ lineEndings.splice(startIdx, removeCount, ...newEndings);
1447
+ }
1448
+ function insertLogicalLinesAfter(lines, lineEndings, idx, newLines, defaultEol) {
1449
+ if (newLines.length === 0) return;
1450
+ let lastInsertedEnding = defaultEol;
1451
+ if ((lineEndings[idx] ?? "") === "") {
1452
+ lineEndings[idx] = defaultEol;
1453
+ lastInsertedEnding = "";
1454
+ }
1455
+ const insertedEndings = newLines.map((_, index) => index === newLines.length - 1 ? lastInsertedEnding : defaultEol);
1456
+ lines.splice(idx + 1, 0, ...newLines);
1457
+ lineEndings.splice(idx + 1, 0, ...insertedEndings);
1458
+ }
1459
+ function composeRawText(lines, lineEndings) {
1460
+ return lines.map((line, idx) => `${line}${lineEndings[idx] ?? ""}`).join("");
1461
+ }
1340
1462
  function findLine(lines, lineNum, expectedTag, hashIndex) {
1341
1463
  const idx = lineNum - 1;
1342
1464
  if (idx < 0 || idx >= lines.length) {
@@ -1994,7 +2116,7 @@ Recovery: read_file path ranges=["${csStart}-${csEnd}"], then retry edit with fr
1994
2116
  return conflicts;
1995
2117
  }
1996
2118
  function applySetLineEdit(edit, ctx) {
1997
- const { lines, opts, locateOrConflict, ensureRevisionContext } = ctx;
2119
+ const { lines, lineEndings, defaultEol, opts, locateOrConflict, ensureRevisionContext } = ctx;
1998
2120
  const { tag, line } = parseRef(edit.set_line.anchor);
1999
2121
  const idx = locateOrConflict({ tag, line }, "stale_anchor", () => buildRetryEdit(edit, lines));
2000
2122
  if (typeof idx === "string") return idx;
@@ -2004,17 +2126,17 @@ function applySetLineEdit(edit, ctx) {
2004
2126
  if (conflict) return conflict;
2005
2127
  const txt = edit.set_line.new_text;
2006
2128
  if (!txt && txt !== 0) {
2007
- lines.splice(idx, 1);
2129
+ replaceLogicalRange(lines, lineEndings, idx, idx, [], defaultEol);
2008
2130
  return null;
2009
2131
  }
2010
2132
  const origLine = [lines[idx]];
2011
2133
  const raw = sanitizeEditText(txt).split("\n");
2012
2134
  const newLines = opts.restoreIndent ? restoreIndent(origLine, raw) : raw;
2013
- lines.splice(idx, 1, ...newLines);
2135
+ replaceLogicalRange(lines, lineEndings, idx, idx, newLines, defaultEol);
2014
2136
  return null;
2015
2137
  }
2016
2138
  function applyInsertAfterEdit(edit, ctx) {
2017
- const { lines, opts, locateOrConflict, ensureRevisionContext } = ctx;
2139
+ const { lines, lineEndings, defaultEol, opts, locateOrConflict, ensureRevisionContext } = ctx;
2018
2140
  const { tag, line } = parseRef(edit.insert_after.anchor);
2019
2141
  const idx = locateOrConflict({ tag, line }, "stale_anchor", () => buildRetryEdit(edit, lines));
2020
2142
  if (typeof idx === "string") return idx;
@@ -2024,7 +2146,7 @@ function applyInsertAfterEdit(edit, ctx) {
2024
2146
  if (conflict) return conflict;
2025
2147
  let insertLines = sanitizeEditText(edit.insert_after.text).split("\n");
2026
2148
  if (opts.restoreIndent) insertLines = restoreIndent([lines[idx]], insertLines);
2027
- lines.splice(idx + 1, 0, ...insertLines);
2149
+ insertLogicalLinesAfter(lines, lineEndings, idx, insertLines, defaultEol);
2028
2150
  return null;
2029
2151
  }
2030
2152
  function applyReplaceLinesEdit(edit, ctx) {
@@ -2035,6 +2157,8 @@ function applyReplaceLinesEdit(edit, ctx) {
2035
2157
  currentSnapshot,
2036
2158
  ensureRevisionContext,
2037
2159
  hasBaseSnapshot,
2160
+ lineEndings,
2161
+ defaultEol,
2038
2162
  lines,
2039
2163
  locateOrConflict,
2040
2164
  opts,
@@ -2124,17 +2248,17 @@ Recovery: read_file path ranges=["${csStart}-${csEnd}"], then retry edit with fr
2124
2248
  }
2125
2249
  const txt = edit.replace_lines.new_text;
2126
2250
  if (!txt && txt !== 0) {
2127
- lines.splice(startIdx, endIdx - startIdx + 1);
2251
+ replaceLogicalRange(lines, lineEndings, startIdx, endIdx, [], defaultEol);
2128
2252
  return null;
2129
2253
  }
2130
2254
  const origRange = lines.slice(startIdx, endIdx + 1);
2131
2255
  let newLines = sanitizeEditText(txt).split("\n");
2132
2256
  if (opts.restoreIndent) newLines = restoreIndent(origRange, newLines);
2133
- lines.splice(startIdx, endIdx - startIdx + 1, ...newLines);
2257
+ replaceLogicalRange(lines, lineEndings, startIdx, endIdx, newLines, defaultEol);
2134
2258
  return null;
2135
2259
  }
2136
2260
  function applyReplaceBetweenEdit(edit, ctx) {
2137
- const { lines, opts, locateOrConflict, ensureRevisionContext } = ctx;
2261
+ const { lines, lineEndings, defaultEol, opts, locateOrConflict, ensureRevisionContext } = ctx;
2138
2262
  const boundaryMode = edit.replace_between.boundary_mode || "inclusive";
2139
2263
  if (boundaryMode !== "inclusive" && boundaryMode !== "exclusive") {
2140
2264
  throw new Error(`BAD_INPUT: replace_between boundary_mode must be inclusive or exclusive, got ${boundaryMode}`);
@@ -2163,7 +2287,11 @@ function applyReplaceBetweenEdit(edit, ctx) {
2163
2287
  const origRange = lines.slice(sliceStart, sliceStart + removeCount);
2164
2288
  if (opts.restoreIndent && origRange.length > 0) newLines = restoreIndent(origRange, newLines);
2165
2289
  if (txt === "" || txt === null) newLines = [];
2166
- lines.splice(sliceStart, removeCount, ...newLines);
2290
+ if (removeCount === 0) {
2291
+ insertLogicalLinesAfter(lines, lineEndings, sliceStart - 1, newLines, defaultEol);
2292
+ return null;
2293
+ }
2294
+ replaceLogicalRange(lines, lineEndings, sliceStart, sliceStart + removeCount - 1, newLines, defaultEol);
2167
2295
  return null;
2168
2296
  }
2169
2297
  function editFile(filePath, edits, opts = {}) {
@@ -2175,11 +2303,12 @@ function editFile(filePath, edits, opts = {}) {
2175
2303
  const staleRevision = !!opts.baseRevision && opts.baseRevision !== currentSnapshot.revision && hasBaseSnapshot;
2176
2304
  const changedRanges = staleRevision && hasBaseSnapshot ? computeChangedRanges(baseSnapshot.lines, currentSnapshot.lines) : [];
2177
2305
  const conflictPolicy = opts.conflictPolicy || "conservative";
2178
- const original = currentSnapshot.content;
2306
+ const originalRaw = currentSnapshot.rawText;
2179
2307
  const lines = [...currentSnapshot.lines];
2308
+ const lineEndings = [...currentSnapshot.lineEndings];
2180
2309
  const origLines = [...currentSnapshot.lines];
2181
- const hadTrailingNewline = original.endsWith("\n");
2182
2310
  const hashIndex = currentSnapshot.uniqueTagIndex;
2311
+ const defaultEol = currentSnapshot.defaultEol || "\n";
2183
2312
  let autoRebased = false;
2184
2313
  const remaps = [];
2185
2314
  const remapKeys = /* @__PURE__ */ new Set();
@@ -2300,6 +2429,8 @@ ${snip.text}`;
2300
2429
  ensureRevisionContext,
2301
2430
  hasBaseSnapshot,
2302
2431
  lines,
2432
+ lineEndings,
2433
+ defaultEol,
2303
2434
  locateOrConflict,
2304
2435
  opts,
2305
2436
  origLines,
@@ -2340,19 +2471,17 @@ ${snip.text}`;
2340
2471
  throw editErr;
2341
2472
  }
2342
2473
  }
2343
- let content = lines.join("\n");
2344
- if (hadTrailingNewline && !content.endsWith("\n")) content += "\n";
2345
- if (!hadTrailingNewline && content.endsWith("\n")) content = content.slice(0, -1);
2346
- if (original === content) {
2474
+ const content = composeRawText(lines, lineEndings);
2475
+ if (originalRaw === content) {
2347
2476
  throw new Error("NOOP_EDIT: File already contains the desired content. No changes needed.");
2348
2477
  }
2349
- const fullDiff = simpleDiff(origLines, content.split("\n"));
2478
+ const fullDiff = simpleDiff(origLines, lines);
2350
2479
  let displayDiff = fullDiff;
2351
2480
  if (displayDiff && displayDiff.length > MAX_DIFF_CHARS) {
2352
2481
  displayDiff = displayDiff.slice(0, MAX_DIFF_CHARS) + `
2353
2482
  ... (diff truncated, ${displayDiff.length} chars total)`;
2354
2483
  }
2355
- const newLinesAll = content.split("\n");
2484
+ const newLinesAll = lines;
2356
2485
  let minLine = Infinity, maxLine = 0;
2357
2486
  if (fullDiff) {
2358
2487
  for (const dl of fullDiff.split("\n")) {
@@ -2369,7 +2498,7 @@ ${snip.text}`;
2369
2498
  reason: ${REASON.DRY_RUN_PREVIEW}
2370
2499
  revision: ${currentSnapshot.revision}
2371
2500
  file: ${currentSnapshot.fileChecksum}
2372
- Dry run: ${filePath} would change (${content.split("\n").length} lines)`;
2501
+ Dry run: ${filePath} would change (${lines.length} lines)`;
2373
2502
  if (staleRevision && hasBaseSnapshot) msg2 += `
2374
2503
  changed_ranges: ${describeChangedRanges(changedRanges)}`;
2375
2504
  if (displayDiff) msg2 += `
@@ -2397,13 +2526,21 @@ remapped_refs:
2397
2526
  ${remaps.map(({ from, to }) => `${from} -> ${to}`).join("\n")}`;
2398
2527
  }
2399
2528
  msg += `
2400
- Updated ${filePath} (${content.split("\n").length} lines)`;
2529
+ Updated ${filePath} (${lines.length} lines)`;
2401
2530
  if (fullDiff && minLine <= maxLine) {
2402
2531
  const ctxStart = Math.max(0, minLine - 6) + 1;
2403
2532
  const ctxEnd = Math.min(newLinesAll.length, maxLine + 5);
2404
2533
  const entries = createSnapshotEntries(nextSnapshot, ctxStart, ctxEnd);
2405
2534
  if (entries.length > 0) {
2406
- const block = buildEditReadyBlock({ path: real, kind: "post_edit", entries });
2535
+ const block = buildEditReadyBlock({
2536
+ path: real,
2537
+ kind: "post_edit",
2538
+ entries,
2539
+ meta: {
2540
+ eol: nextSnapshot.eol,
2541
+ trailing_newline: nextSnapshot.trailingNewline
2542
+ }
2543
+ });
2407
2544
  msg += `
2408
2545
 
2409
2546
  ${serializeReadBlock(block)}`;
@@ -2728,7 +2865,7 @@ function isSupportedExtension(ext) {
2728
2865
  }
2729
2866
 
2730
2867
  // ../hex-common/src/parser/tree-sitter.mjs
2731
- import { existsSync as existsSync4, readFileSync as readFileSync2 } from "node:fs";
2868
+ import { existsSync as existsSync4, readFileSync as readFileSync3 } from "node:fs";
2732
2869
  import { dirname as dirname3, resolve as resolve3 } from "node:path";
2733
2870
  import { fileURLToPath } from "node:url";
2734
2871
  var parserInstance = null;
@@ -2755,7 +2892,7 @@ function loadArtifactManifest() {
2755
2892
  `Tree-sitter artifact manifest is missing. Checked: ${artifactDirCandidates.join(", ")}. This package now ships first-party grammar WASM artifacts; restore artifacts/tree-sitter or rerun the artifact materialization step.`
2756
2893
  );
2757
2894
  }
2758
- artifactManifest = JSON.parse(readFileSync2(manifestPath, "utf8"));
2895
+ artifactManifest = JSON.parse(readFileSync3(manifestPath, "utf8"));
2759
2896
  return artifactManifest;
2760
2897
  }
2761
2898
  function treeSitterArtifactPath(grammar) {
@@ -3117,7 +3254,7 @@ import { statSync as statSync8 } from "node:fs";
3117
3254
  import { resolve as resolve6 } from "node:path";
3118
3255
 
3119
3256
  // lib/tree.mjs
3120
- import { readdirSync as readdirSync2, readFileSync as readFileSync3, statSync as statSync6, existsSync as existsSync5 } from "node:fs";
3257
+ import { readdirSync as readdirSync2, readFileSync as readFileSync4, statSync as statSync6, existsSync as existsSync5 } from "node:fs";
3121
3258
  import { resolve as resolve4, basename, join as join4, relative as relative2 } from "node:path";
3122
3259
  import ignore from "ignore";
3123
3260
  var SKIP_DIRS = /* @__PURE__ */ new Set([
@@ -3138,7 +3275,7 @@ function loadGitignore(rootDir) {
3138
3275
  const gi = join4(rootDir, ".gitignore");
3139
3276
  if (!existsSync5(gi)) return null;
3140
3277
  try {
3141
- const content = readFileSync3(gi, "utf-8");
3278
+ const content = readFileSync4(gi, "utf-8");
3142
3279
  return ignore().add(content);
3143
3280
  } catch {
3144
3281
  return null;
@@ -3393,7 +3530,7 @@ function inspectPath(inputPath, opts = {}) {
3393
3530
  }
3394
3531
 
3395
3532
  // lib/setup.mjs
3396
- import { readFileSync as readFileSync4, writeFileSync as writeFileSync2, existsSync as existsSync6, mkdirSync, copyFileSync } from "node:fs";
3533
+ import { readFileSync as readFileSync5, writeFileSync as writeFileSync2, existsSync as existsSync6, mkdirSync, copyFileSync } from "node:fs";
3397
3534
  import { resolve as resolve7, dirname as dirname4, join as join5 } from "node:path";
3398
3535
  import { fileURLToPath as fileURLToPath2 } from "node:url";
3399
3536
  import { homedir } from "node:os";
@@ -3424,7 +3561,7 @@ var CLAUDE_HOOKS = {
3424
3561
  };
3425
3562
  function readJson(filePath) {
3426
3563
  if (!existsSync6(filePath)) return null;
3427
- return JSON.parse(readFileSync4(filePath, "utf-8"));
3564
+ return JSON.parse(readFileSync5(filePath, "utf-8"));
3428
3565
  }
3429
3566
  function writeJson(filePath, data) {
3430
3567
  mkdirSync(dirname4(filePath), { recursive: true });
@@ -3439,7 +3576,7 @@ function findEntryByCommand(entries) {
3439
3576
  }
3440
3577
  function safeRead(filePath) {
3441
3578
  try {
3442
- return readFileSync4(filePath, "utf-8");
3579
+ return readFileSync5(filePath, "utf-8");
3443
3580
  } catch {
3444
3581
  return null;
3445
3582
  }
@@ -3530,7 +3667,7 @@ import { join as join6 } from "node:path";
3530
3667
 
3531
3668
  // ../hex-common/src/git/semantic-diff.mjs
3532
3669
  import { execFileSync } from "node:child_process";
3533
- import { existsSync as existsSync7, readFileSync as readFileSync5, statSync as statSync9 } from "node:fs";
3670
+ import { existsSync as existsSync7, readFileSync as readFileSync6, statSync as statSync9 } from "node:fs";
3534
3671
  import { dirname as dirname5, extname as extname3, relative as relative3, resolve as resolve8 } from "node:path";
3535
3672
  function normalizePath2(value) {
3536
3673
  return value.replace(/\\/g, "/");
@@ -3710,7 +3847,7 @@ function compareSymbols(beforeEntries = [], afterEntries = []) {
3710
3847
  function readWorkingTreeFile(repoRoot, relPath) {
3711
3848
  const absPath = resolve8(repoRoot, relPath);
3712
3849
  if (!existsSync7(absPath)) return null;
3713
- return readFileSync5(absPath, "utf8").replace(/\r\n/g, "\n");
3850
+ return readFileSync6(absPath, "utf8").replace(/\r\n/g, "\n");
3714
3851
  }
3715
3852
  function readGitFile(repoRoot, ref, relPath) {
3716
3853
  if (!ref) return null;
@@ -4055,7 +4192,7 @@ OUTPUT_CAPPED: Output exceeded ${MAX_BULK_OUTPUT_CHARS} chars.`;
4055
4192
  }
4056
4193
 
4057
4194
  // server.mjs
4058
- var version = true ? "1.12.1" : (await null).createRequire(import.meta.url)("./package.json").version;
4195
+ var version = true ? "1.13.0" : (await null).createRequire(import.meta.url)("./package.json").version;
4059
4196
  var { server, StdioServerTransport } = await createServerRuntime({
4060
4197
  name: "hex-line-mcp",
4061
4198
  version
@@ -4080,7 +4217,7 @@ function parseReadRanges(rawRanges) {
4080
4217
  }
4081
4218
  server.registerTool("read_file", {
4082
4219
  title: "Read File",
4083
- description: "Read file with hash-annotated lines, checksums, revision metadata, and automatic graph hints when available. Default: edit-ready output. Use plain:true for non-edit workflows.",
4220
+ description: "Read file with hash-annotated lines, checksums, logical revision metadata, EOL/trailing-newline state, and graph hints when available. Default: edit-ready output.",
4084
4221
  inputSchema: z2.object({
4085
4222
  path: z2.string().optional().describe("File path"),
4086
4223
  paths: z2.array(z2.string()).optional().describe("Array of file paths to read (batch mode)"),
@@ -4115,7 +4252,7 @@ ERROR: ${e.message}`);
4115
4252
  });
4116
4253
  server.registerTool("edit_file", {
4117
4254
  title: "Edit File",
4118
- description: "Apply hash-verified partial edits to one file. Batch multiple edits in one call. Carry base_revision from prior read/edit for auto-rebase on concurrent changes. Conservative conflicts return retry_edit/retry_edits, suggested_read_call, and retry_plan when available.",
4255
+ description: "Apply hash-verified partial edits to one file. Carry base_revision on same-file follow-ups. Preserves existing line endings and trailing-newline shape; conservative conflicts return retry helpers.",
4119
4256
  inputSchema: z2.object({
4120
4257
  path: z2.string().describe("File to edit"),
4121
4258
  edits: z2.union([z2.string(), z2.array(z2.any())]).describe(
@@ -4250,7 +4387,7 @@ server.registerTool("outline", {
4250
4387
  });
4251
4388
  server.registerTool("verify", {
4252
4389
  title: "Verify Checksums",
4253
- description: "Check if held checksums are still valid without rereading. Returns canonical status, next_action, and suggested_read_call when rereading specific ranges is the right recovery.",
4390
+ description: "Check if held checksums are still valid without rereading. Use before delayed or mixed-tool follow-up edits; returns canonical status, next_action, and reread guidance.",
4254
4391
  inputSchema: z2.object({
4255
4392
  path: z2.string().describe("File path"),
4256
4393
  checksums: z2.array(z2.string()).describe('Checksum strings, e.g. ["1-50:f7e2a1b0", "51-100:abcd1234"]'),
package/output-style.md CHANGED
@@ -27,9 +27,9 @@ Prefer `hex-line` for text files you may inspect or modify. Hash-annotated reads
27
27
  | Path | Flow |
28
28
  |------|------|
29
29
  | Surgical | `grep_search -> edit_file` |
30
- | Exploratory | `outline -> read_file (ranges) -> edit_file` |
30
+ | Exploratory | `outline -> read_file (ranges) -> edit_file(base_revision)` |
31
31
  | Multi-file | `bulk_replace(path=<project root>)` |
32
- | Follow-up after delay | `verify -> reread only if STALE` |
32
+ | Follow-up after delay | `verify(base_revision) -> reread only if STALE -> retry with returned helpers` |
33
33
 
34
34
  ## Scope Discipline
35
35
 
@@ -42,10 +42,14 @@ Prefer `hex-line` for text files you may inspect or modify. Hash-annotated reads
42
42
 
43
43
  - Never invent `range_checksum`. Copy it from a fresh `read_file` or `grep_search(output:"content")` block.
44
44
  - First mutation in a file: use `grep_search` for narrow targets, or `outline -> read_file(ranges)` for structural edits.
45
+ - Preserve file conventions mentally: `hex-line` hashes normalized logical text, but `edit_file` preserves the file's existing line endings and trailing-newline shape on write.
45
46
  - Prefer `set_line` or `insert_after` for small local changes. Prefer `replace_between` for larger bounded block rewrites.
46
47
  - Use `replace_lines` only when you already hold the exact inclusive range checksum for that block.
47
- - Avoid large first-pass edit batches. Start with 1-2 hunks, then continue from the returned `revision`.
48
+ - Avoid large first-pass edit batches. Start with 1-2 hunks, then continue from the returned `revision` as `base_revision`.
49
+ - Before a delayed follow-up edit, a formatter pass, or any mixed-tool workflow on the same file, run `verify` with the last checksums and `base_revision`.
48
50
  - If `edit_file` returns `retry_edit`, `retry_edits`, or `retry_plan`, reuse those directly instead of rebuilding anchors/checksums by hand.
51
+ - Reuse `retry_checksum` when it is returned for the exact same target range.
52
+ - Once `hex-line` owns a file edit session, avoid mixing built-in `Edit`/`Write` on that file unless you intentionally want a new baseline.
49
53
  - Follow `next_action` first. Treat `summary` and `snippet` as the compact local context, not as prose to reinterpret.
50
54
 
51
55
  ## Exceptions
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@levnikolaevich/hex-line-mcp",
3
- "version": "1.12.1",
3
+ "version": "1.13.0",
4
4
  "mcpName": "io.github.levnikolaevich/hex-line-mcp",
5
5
  "type": "module",
6
6
  "description": "Hash-verified file editing MCP + token efficiency hook for AI coding agents. 9 tools: inspect_path, read, edit, write, grep, outline, verify, changes, bulk_replace.",