@levnikolaevich/hex-line-mcp 1.30.1 → 1.31.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
@@ -32,8 +32,8 @@ Advanced / occasional:
32
32
 
33
33
  | Tool | Description | Key Feature |
34
34
  |------|-------------|-------------|
35
- | `read_file` | Read file with progressive disclosure, optional edit-ready metadata, and automatic graph hints when available | Minimal plain discovery by default, explicit `edit_ready` for verified edits |
36
- | `edit_file` | Revision-aware anchor edits (`set_line`, `replace_lines`, `insert_after`, `replace_between`) | Batched same-file edits + conservative auto-rebase |
35
+ | `read_file` | Read file with progressive disclosure, optional edit-ready metadata, and automatic graph hints when available | Minimal plain discovery by default (no hash overhead); per-line hashes are added only under `edit_ready`/`verbosity=full` for verified edits |
36
+ | `edit_file` | Revision-aware anchor edits (`set_line`, `replace_lines`, `insert_after`, `replace_between`) | Batch all hunks for one file in a single call (avoids stale-conflict churn); forgiving anchors (`tag.N`, a bare line number, or unique line content) and `range_checksum: "auto"` |
37
37
  | `write_file` | Create new file or overwrite, auto-creates parent dirs | Path validation, no hash overhead |
38
38
  | `grep_search` | Search with ripgrep, summary-first discovery, and optional edit-ready hunks | `summary` by default, capped `content` mode with explicit `allow_large_output` escape hatch |
39
39
  | `outline` | AST-based structural overview with hash anchors via tree-sitter WASM. Supports JavaScript/TypeScript, Python, C#, PHP, and fence-aware markdown headings | 95% token reduction, direct edit anchors |
@@ -47,7 +47,7 @@ Advanced / occasional:
47
47
  | Event | Trigger | Action |
48
48
  |-------|---------|--------|
49
49
  | **PreToolUse** | Read/Edit/Write/Grep/Glob on project text scope | Advises hex-line by default for project-scoped text files and file discovery; explicit `hooks.mode: "blocking"` hard redirects. Built-in tools stay available for binary/media, plan files in Plan Mode, and text paths outside the current project root |
50
- | **PreToolUse** | Bash with dangerous commands | Blocks `rm -rf /`, `git push --force`, etc. Agent must confirm with user |
50
+ | **PreToolUse** | Bash with dangerous commands | Blocks `rm -rf /`, `git push --force`, `git clean -f`, `git branch -D`, `git rebase -i`, `truncate -s`, `docker prune`, `docker rmi -f`, `chmod 777`, `DROP TABLE`, `mkfs`, `dd if=/dev/zero`, etc. Agent must confirm with user |
51
51
  | **PostToolUse** | Bash with 50+ lines output | RTK: deduplicates, truncates, shows filtered summary to Claude as feedback |
52
52
  | **SessionStart** | Session begins | Injects a short bootstrap hint; defers to the active output style when `hex-line` style is enabled |
53
53
 
@@ -70,12 +70,14 @@ Requires Node.js >= 20.19.0.
70
70
 
71
71
  ### Hooks
72
72
 
73
- Hooks and output style are auto-synced on every MCP server startup. The server compares installed files with bundled versions and updates only when content differs. First run after `npm i -g` triggers full install automatically.
73
+ Hooks and output style are auto-synced on every MCP server startup (unless disabled — see below). The server compares installed files with bundled versions and updates only when content differs. First run after `npm i -g` triggers full install automatically.
74
74
 
75
75
  Hooks are written to global `~/.claude/settings.json` with absolute path to `hook.mjs`. Output style is installed to `~/.claude/output-styles/hex-line.md` and activated if no other style is set. To activate manually: `/config` > Output style > hex-line.
76
76
 
77
77
  No extra manual setup is required after install. The startup sync uses the current Node runtime and a stable hook path under `~/.claude/hex-line`, so the hook command survives spaces in the home directory on Windows, macOS, and Linux.
78
78
 
79
+ To opt out of startup syncing entirely (hook copy, output-style install/activation, and `~/.claude/settings.json` writes), set `hooks.auto_sync: false` in the project's `.hex-skills/environment_state.json`. The output style is only auto-activated when no other style is set; it never overrides a style you chose.
80
+
79
81
  ## Validation
80
82
 
81
83
  Use the normal package checks:
package/dist/hook.mjs CHANGED
@@ -54,8 +54,8 @@ function normalizeOutput(text, opts = {}) {
54
54
  }
55
55
 
56
56
  // hook.mjs
57
- import { readFileSync, writeSync } from "node:fs";
58
- import { resolve } from "node:path";
57
+ import { readFileSync, writeSync, realpathSync, existsSync } from "node:fs";
58
+ import { resolve, dirname, basename, join } from "node:path";
59
59
  import { homedir } from "node:os";
60
60
 
61
61
  // lib/hook-policy.mjs
@@ -160,9 +160,15 @@ var DANGEROUS_PATTERNS = [
160
160
  { regex: /git\s+push\s+(-f|--force)/, reason: "force push can overwrite remote history" },
161
161
  { regex: /git\s+reset\s+--hard/, reason: "hard reset discards uncommitted changes" },
162
162
  { regex: /DROP\s+(TABLE|DATABASE)/i, reason: "DROP destroys data permanently" },
163
- { regex: /chmod\s+777/, reason: "chmod 777 removes all access restrictions" },
163
+ { regex: /chmod\s+(-[a-zA-Z]+\s+)*(777|a\+rwx)\b/i, reason: "chmod 777 / a+rwx removes all access restrictions" },
164
164
  { regex: /mkfs/, reason: "filesystem format destroys all data" },
165
- { regex: /dd\s+if=\/dev\/zero/, reason: "direct disk write destroys data" }
165
+ { regex: /dd\s+if=\/dev\/zero/, reason: "direct disk write destroys data" },
166
+ { regex: /git\s+branch\s+-D\b/, reason: "git branch -D force-deletes branches" },
167
+ { regex: /git\s+clean\s+(-[a-z]*f|--force)/i, reason: "git clean -f deletes untracked files permanently" },
168
+ { regex: /git\s+rebase\s+(-i|--interactive)\b/, reason: "interactive rebase rewrites history and blocks on an editor" },
169
+ { regex: /\btruncate\s+-s/i, reason: "truncate -s can zero out file contents" },
170
+ { regex: /docker\s+(system|volume|image|container|network)\s+prune\b/i, reason: "docker prune destroys unused data permanently" },
171
+ { regex: /docker\s+rmi\s+(-f|--force)/i, reason: "docker rmi -f force-removes images" }
166
172
  ];
167
173
  var COMPOUND_OPERATORS = /[|]|>>?|&&|\|\||;/;
168
174
  var CMD_PATTERNS = [
@@ -197,8 +203,8 @@ var HEX_LINE_MUTATING = /* @__PURE__ */ new Set([
197
203
  "mcp__hex-line__bulk_replace"
198
204
  ]);
199
205
  var PLAN_SAFE_FOLDERS = [
200
- ".hex-skills/",
201
- ".claude/"
206
+ ".hex-skills",
207
+ ".claude"
202
208
  ];
203
209
  function extOf(filePath) {
204
210
  const dot = filePath.lastIndexOf(".");
@@ -284,8 +290,27 @@ function collectPositionals(tokens, { optionValueFlags = [], slashOptions = fals
284
290
  function resolveCandidatePaths(pathTokens) {
285
291
  return pathTokens.map(resolveToolPath).filter(Boolean).map(normalizePolicyPath);
286
292
  }
293
+ function canonicalizeForScope(absPath) {
294
+ try {
295
+ return realpathSync(absPath);
296
+ } catch {
297
+ try {
298
+ let dir = absPath;
299
+ const tail = [];
300
+ while (dir && dir !== dirname(dir) && !existsSync(dir)) {
301
+ tail.unshift(basename(dir));
302
+ dir = dirname(dir);
303
+ }
304
+ if (existsSync(dir)) return tail.length ? join(realpathSync(dir), ...tail) : realpathSync(dir);
305
+ } catch {
306
+ }
307
+ return absPath;
308
+ }
309
+ }
287
310
  function isProjectScopedPath(filePath) {
288
- return !!filePath && isWithinDir(resolveToolPath(filePath), process.cwd());
311
+ if (!filePath) return false;
312
+ const resolved = canonicalizeForScope(resolveToolPath(filePath));
313
+ return isWithinDir(resolved, canonicalizeForScope(process.cwd()));
289
314
  }
290
315
  function getBashPathCandidates(spec, tokens, isFirstSegment) {
291
316
  const command = (tokens[0] || "").toLowerCase();
@@ -453,7 +478,8 @@ function handlePreToolUse(data) {
453
478
  const toolInput = data.tool_input || {};
454
479
  if (data.permission_mode === "plan" && HEX_LINE_MUTATING.has(toolName)) {
455
480
  const targetPath = getMutatingTargetPath(toolInput).replace(/\\/g, "/");
456
- const isPlanSafe = PLAN_SAFE_FOLDERS.some((folder) => targetPath.includes(folder));
481
+ const planParts = targetPath.split("/").filter(Boolean);
482
+ const isPlanSafe = PLAN_SAFE_FOLDERS.some((folder) => planParts.includes(folder));
457
483
  if (!isPlanSafe) {
458
484
  block(
459
485
  "PLAN_MODE: You are in planning mode. Write your plan to the plan file, then call ExitPlanMode to get approval before making changes.",
package/dist/server.mjs CHANGED
@@ -1680,6 +1680,11 @@ function requireOptionalString(value, path) {
1680
1680
  throw badInput(`${path} must be a string when provided`);
1681
1681
  }
1682
1682
  }
1683
+ function requireChecksumField(value, path) {
1684
+ if (typeof value !== "string") {
1685
+ throw badInput(`${path} must be a string: either "<start>-<end>:<hex>" copied from a fresh read, or "auto" to compute it for the current anchor range`);
1686
+ }
1687
+ }
1683
1688
  function validateEditShape(edit) {
1684
1689
  requirePlainObject(edit, "edit");
1685
1690
  const kinds = ["set_line", "replace_lines", "insert_after", "replace_between"].filter((kind2) => edit[kind2] !== void 0);
@@ -1707,7 +1712,7 @@ function validateEditShape(edit) {
1707
1712
  requirePlainObject(edit.replace_lines, "replace_lines");
1708
1713
  requireString(edit.replace_lines.start_anchor, "replace_lines.start_anchor");
1709
1714
  requireString(edit.replace_lines.end_anchor, "replace_lines.end_anchor");
1710
- requireString(edit.replace_lines.range_checksum, "replace_lines.range_checksum");
1715
+ requireChecksumField(edit.replace_lines.range_checksum, "replace_lines.range_checksum");
1711
1716
  requireString(edit.replace_lines.new_text, "replace_lines.new_text");
1712
1717
  return;
1713
1718
  }
@@ -2937,6 +2942,92 @@ Recovery: read_file path ranges=["${csStart}-${csEnd}"], then retry edit with fr
2937
2942
  }
2938
2943
  return null;
2939
2944
  }
2945
+ var CANONICAL_REF_RE = /^[a-z2-7]{2}\.\d+$/;
2946
+ var CANONICAL_CHECKSUM_RE = /^\d+-\d+:[0-9a-f]{8}$/;
2947
+ function uniqueLineIndex(lines, pred) {
2948
+ let found = -1;
2949
+ for (let i = 0; i < lines.length; i++) {
2950
+ if (!pred(lines[i])) continue;
2951
+ if (found >= 0) return -1;
2952
+ found = i;
2953
+ }
2954
+ return found;
2955
+ }
2956
+ function refLineOrNull(anchor) {
2957
+ const m = typeof anchor === "string" ? anchor.trim().match(/^[a-z2-7]{2}\.(\d+)$/) : null;
2958
+ return m ? parseInt(m[1], 10) : null;
2959
+ }
2960
+ function lineContentMatches(line, candidate) {
2961
+ if (line === candidate) return true;
2962
+ const c = candidate.trim();
2963
+ return c.length > 0 && line.replace(/\s+/g, "") === candidate.replace(/\s+/g, "");
2964
+ }
2965
+ function reconcileAnchorRef(rawAnchor, lines) {
2966
+ if (typeof rawAnchor !== "string") return rawAnchor;
2967
+ const s = rawAnchor.trim();
2968
+ if (!s || CANONICAL_REF_RE.test(s)) return rawAnchor;
2969
+ const tagAt = (n) => `${lineTag(fnv1a(lines[n - 1]))}.${n}`;
2970
+ if (/^\d+$/.test(s)) {
2971
+ const n = parseInt(s, 10);
2972
+ return n >= 1 && n <= lines.length ? tagAt(n) : rawAnchor;
2973
+ }
2974
+ const dotN = s.match(/^([\s\S]*)\.(\d+)$/);
2975
+ if (dotN) {
2976
+ const n = parseInt(dotN[2], 10);
2977
+ if (n >= 1 && n <= lines.length && lineContentMatches(lines[n - 1], dotN[1])) return tagAt(n);
2978
+ }
2979
+ const exact = uniqueLineIndex(lines, (l) => l === s);
2980
+ if (exact >= 0) return tagAt(exact + 1);
2981
+ const compact = s.replace(/\s+/g, "");
2982
+ if (compact) {
2983
+ const stripped = uniqueLineIndex(lines, (l) => l.replace(/\s+/g, "") === compact);
2984
+ if (stripped >= 0) return tagAt(stripped + 1);
2985
+ }
2986
+ return rawAnchor;
2987
+ }
2988
+ function reconcileRangeChecksum(rawChecksum, startLine, endLine, snapshot) {
2989
+ if (rawChecksum === void 0) return rawChecksum;
2990
+ const s = String(rawChecksum).trim();
2991
+ if (CANONICAL_CHECKSUM_RE.test(s)) return rawChecksum;
2992
+ if (s === "" || /^(?:\d+-\d+:)?auto$/i.test(s)) {
2993
+ return buildRangeChecksum(snapshot, startLine, endLine) || rawChecksum;
2994
+ }
2995
+ return rawChecksum;
2996
+ }
2997
+ function reconcileEdits(edits, snapshot, corrections) {
2998
+ if (!Array.isArray(edits)) return edits;
2999
+ const lines = snapshot.lines;
3000
+ const note = (from, to) => {
3001
+ if (to !== from && typeof from === "string" && from.trim() !== to) corrections.push({ from: from.trim(), to });
3002
+ };
3003
+ return edits.map((edit) => {
3004
+ if (!edit || typeof edit !== "object" || Array.isArray(edit)) return edit;
3005
+ const out = { ...edit };
3006
+ for (const kind of ["set_line", "insert_after"]) {
3007
+ const blk = out[kind];
3008
+ if (!blk || typeof blk !== "object" || Array.isArray(blk)) continue;
3009
+ const anchor = reconcileAnchorRef(blk.anchor, lines);
3010
+ note(blk.anchor, anchor);
3011
+ out[kind] = { ...blk, anchor };
3012
+ }
3013
+ for (const kind of ["replace_lines", "replace_between"]) {
3014
+ const blk = out[kind];
3015
+ if (!blk || typeof blk !== "object" || Array.isArray(blk)) continue;
3016
+ const start_anchor = reconcileAnchorRef(blk.start_anchor, lines);
3017
+ const end_anchor = reconcileAnchorRef(blk.end_anchor, lines);
3018
+ note(blk.start_anchor, start_anchor);
3019
+ note(blk.end_anchor, end_anchor);
3020
+ const next = { ...blk, start_anchor, end_anchor };
3021
+ const sLine = refLineOrNull(start_anchor);
3022
+ const eLine = refLineOrNull(end_anchor);
3023
+ if (sLine && eLine && (kind === "replace_lines" || blk.range_checksum !== void 0)) {
3024
+ next.range_checksum = reconcileRangeChecksum(blk.range_checksum, sLine, eLine, snapshot);
3025
+ }
3026
+ out[kind] = next;
3027
+ }
3028
+ return out;
3029
+ });
3030
+ }
2940
3031
  function editFile(filePath, edits, opts = {}) {
2941
3032
  filePath = normalizePath(filePath);
2942
3033
  const real = validatePath(filePath);
@@ -2955,7 +3046,15 @@ function editFile(filePath, edits, opts = {}) {
2955
3046
  let autoRebased = false;
2956
3047
  const remaps = [];
2957
3048
  const remapKeys = /* @__PURE__ */ new Set();
2958
- const anchored = normalizeAnchoredEdits(edits);
3049
+ const reconcileCorrections = [];
3050
+ const reconciledEdits = reconcileEdits(edits, currentSnapshot, reconcileCorrections);
3051
+ for (const c of reconcileCorrections) {
3052
+ const key = `${c.from}->${c.to}`;
3053
+ if (remapKeys.has(key)) continue;
3054
+ remapKeys.add(key);
3055
+ remaps.push(c);
3056
+ }
3057
+ const anchored = normalizeAnchoredEdits(reconciledEdits);
2959
3058
  assertNonOverlappingTargets(collectEditTargets(anchored));
2960
3059
  const sorted = sortEditsForApply(anchored);
2961
3060
  const buildStrictConflictError = (reason, centerIdx, details, recoveryRanges = null, retryChecksum = null, retryEdit = null) => {
@@ -3325,14 +3424,18 @@ function applyListModeTotalLimit(lines, totalLimit) {
3325
3424
  visible.push(`OUTPUT_CAPPED: ${lines.length - totalLimit} more result line(s) omitted. Narrow with path= or glob=, or raise head_limit.`);
3326
3425
  return visible.join("\n");
3327
3426
  }
3427
+ function appendCommonRgFlags(args, opts) {
3428
+ if (opts.caseInsensitive) args.push("-i");
3429
+ else if (opts.smartCase) args.push("-S");
3430
+ if (opts.literal) args.push("-F");
3431
+ if (opts.multiline) args.push("-U", "--multiline-dotall");
3432
+ if (opts.glob) args.push("--glob", opts.glob);
3433
+ if (opts.type) args.push("--type", opts.type);
3434
+ return args;
3435
+ }
3328
3436
  async function filesMode(pattern, target, opts, totalLimit) {
3329
3437
  const realArgs = ["-l"];
3330
- if (opts.caseInsensitive) realArgs.push("-i");
3331
- else if (opts.smartCase) realArgs.push("-S");
3332
- if (opts.literal) realArgs.push("-F");
3333
- if (opts.multiline) realArgs.push("-U", "--multiline-dotall");
3334
- if (opts.glob) realArgs.push("--glob", opts.glob);
3335
- if (opts.type) realArgs.push("--type", opts.type);
3438
+ appendCommonRgFlags(realArgs, opts);
3336
3439
  realArgs.push("--", pattern, target);
3337
3440
  const { stdout, code, stderr, killed } = await spawnRg(realArgs);
3338
3441
  if (killed) return "GREP_OUTPUT_TRUNCATED: exceeded 10MB. Use specific glob/path.";
@@ -3344,12 +3447,7 @@ async function filesMode(pattern, target, opts, totalLimit) {
3344
3447
  }
3345
3448
  async function countMode(pattern, target, opts, totalLimit) {
3346
3449
  const realArgs = ["-c"];
3347
- if (opts.caseInsensitive) realArgs.push("-i");
3348
- else if (opts.smartCase) realArgs.push("-S");
3349
- if (opts.literal) realArgs.push("-F");
3350
- if (opts.multiline) realArgs.push("-U", "--multiline-dotall");
3351
- if (opts.glob) realArgs.push("--glob", opts.glob);
3352
- if (opts.type) realArgs.push("--type", opts.type);
3450
+ appendCommonRgFlags(realArgs, opts);
3353
3451
  realArgs.push("--", pattern, target);
3354
3452
  const { stdout, code, stderr, killed } = await spawnRg(realArgs);
3355
3453
  if (killed) return "GREP_OUTPUT_TRUNCATED: exceeded 10MB. Use specific glob/path.";
@@ -3361,12 +3459,7 @@ async function countMode(pattern, target, opts, totalLimit) {
3361
3459
  }
3362
3460
  async function summaryMode(pattern, target, opts, totalLimit) {
3363
3461
  const realArgs = ["-n", "-H", "--no-heading", "--color", "never"];
3364
- if (opts.caseInsensitive) realArgs.push("-i");
3365
- else if (opts.smartCase) realArgs.push("-S");
3366
- if (opts.literal) realArgs.push("-F");
3367
- if (opts.multiline) realArgs.push("-U", "--multiline-dotall");
3368
- if (opts.glob) realArgs.push("--glob", opts.glob);
3369
- if (opts.type) realArgs.push("--type", opts.type);
3462
+ appendCommonRgFlags(realArgs, opts);
3370
3463
  const limit = opts.limit && opts.limit > 0 ? opts.limit : 20;
3371
3464
  realArgs.push("-m", String(limit));
3372
3465
  realArgs.push("--", pattern, target);
@@ -3415,12 +3508,7 @@ async function contentMode(pattern, target, opts, plain, editReady, totalLimit,
3415
3508
  const shouldUseGraph = editReady && !plain;
3416
3509
  const contentBlockLimit = allowLargeOutput ? Number.POSITIVE_INFINITY : DEFAULT_CONTENT_BLOCK_LIMIT;
3417
3510
  const outputCharBudget = allowLargeOutput ? MAX_SEARCH_OUTPUT_CHARS : DEFAULT_CONTENT_OUTPUT_CHARS;
3418
- if (opts.caseInsensitive) realArgs.push("-i");
3419
- else if (opts.smartCase) realArgs.push("-S");
3420
- if (opts.literal) realArgs.push("-F");
3421
- if (opts.multiline) realArgs.push("-U", "--multiline-dotall");
3422
- if (opts.glob) realArgs.push("--glob", opts.glob);
3423
- if (opts.type) realArgs.push("--type", opts.type);
3511
+ appendCommonRgFlags(realArgs, opts);
3424
3512
  if (opts.context && opts.context > 0) realArgs.push("-C", String(opts.context));
3425
3513
  if (opts.contextBefore && opts.contextBefore > 0) realArgs.push("-B", String(opts.contextBefore));
3426
3514
  if (opts.contextAfter && opts.contextAfter > 0) realArgs.push("-A", String(opts.contextAfter));
@@ -4389,6 +4477,11 @@ function safeRead(filePath) {
4389
4477
  return null;
4390
4478
  }
4391
4479
  }
4480
+ function isAutoSyncDisabled() {
4481
+ const stateFile = resolve7(process.cwd(), ".hex-skills/environment_state.json");
4482
+ const config = readJson(stateFile);
4483
+ return config?.hooks?.auto_sync === false;
4484
+ }
4392
4485
  function writeHooksToFile(settingsPath) {
4393
4486
  const config = readJson(settingsPath) || {};
4394
4487
  if (!config.hooks || typeof config.hooks !== "object") config.hooks = {};
@@ -4456,6 +4549,7 @@ function syncOutputStyle() {
4456
4549
  function autoSync() {
4457
4550
  const hookSource = existsSync6(DIST_HOOK) ? DIST_HOOK : existsSync6(BUILT_HOOK) ? BUILT_HOOK : null;
4458
4551
  if (!hookSource) return;
4552
+ if (isAutoSyncDisabled()) return;
4459
4553
  const changes = [];
4460
4554
  const srcHook = safeRead(hookSource);
4461
4555
  const dstHook = safeRead(STABLE_HOOK_PATH);
@@ -5130,7 +5224,7 @@ function errorResult(code, message, recovery, { large = false, extra = null } =
5130
5224
  }
5131
5225
 
5132
5226
  // server.mjs
5133
- var version = true ? "1.30.1" : (await null).createRequire(import.meta.url)("./package.json").version;
5227
+ var version = true ? "1.31.0" : (await null).createRequire(import.meta.url)("./package.json").version;
5134
5228
  var STATUS_ENUM = z2.enum(STATUS_VALUES);
5135
5229
  var ERROR_SHAPE = z2.object({ code: z2.string(), message: z2.string(), recovery: z2.string() }).optional();
5136
5230
  var ERROR_RESULT_FIELDS = {
@@ -5324,11 +5418,11 @@ ERROR: ${e.message}`);
5324
5418
  });
5325
5419
  server.registerTool("edit_file", {
5326
5420
  title: "Edit File",
5327
- 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. boundary_mode=inclusive deletes the anchor lines themselves; new_text must close any delimiter whose opening falls inside the replaced range.",
5421
+ description: 'Apply hash-verified partial edits to one file. Batch ALL hunks for the same file into ONE call via the `edits` array -- separate sequential edit_file calls on one file go stale and conflict (the most common edit failure). Carry base_revision only for a genuinely later follow-up after the file changed. Anchors accept tag.N, a bare line number, or unique line content (auto-resolved); range_checksum accepts "auto" to compute it for the current range. Preserves existing line endings and trailing-newline shape; conservative conflicts return retry helpers. boundary_mode=inclusive deletes the anchor lines themselves; new_text must close any delimiter whose opening falls inside the replaced range.',
5328
5422
  inputSchema: z2.object({
5329
5423
  file_path: z2.string().describe("File to edit"),
5330
5424
  edits: z2.union([z2.string(), z2.array(z2.any())]).describe(
5331
- 'JSON array of canonical edits.\n[{"set_line":{"anchor":"ab.12","new_text":"x"}}]\n[{"replace_lines":{"start_anchor":"ab.10","end_anchor":"cd.15","new_text":"x","range_checksum":"10-15:a1b2"}}]\n[{"insert_after":{"anchor":"ab.20","text":"x"}}]\n[{"replace_between":{"start_anchor":"ab.10","end_anchor":"cd.40","new_text":"x","boundary_mode":"inclusive","range_checksum":"10-40:a1b2"}}]\nPrefer replace_lines with range_checksum when either anchor is a lone delimiter (}, ), ]) \u2014 replace_between anchors use short line-content hashes and may fuzzy-match a sibling delimiter.'
5425
+ 'JSON array of canonical edits.\n[{"set_line":{"anchor":"ab.12","new_text":"x"}}]\n[{"replace_lines":{"start_anchor":"ab.10","end_anchor":"cd.15","new_text":"x","range_checksum":"10-15:a1b2"}}]\n[{"insert_after":{"anchor":"ab.20","text":"x"}}]\n[{"replace_between":{"start_anchor":"ab.10","end_anchor":"cd.40","new_text":"x","boundary_mode":"inclusive","range_checksum":"10-40:a1b2"}}]\nPrefer replace_lines with range_checksum when either anchor is a lone delimiter (}, ), ]) \u2014 replace_between anchors use short line-content hashes and may fuzzy-match a sibling delimiter. Anchors also accept a bare line number or exact line content (auto-resolved to tag.N); range_checksum accepts "auto" to compute it for the current anchor range.'
5332
5426
  ),
5333
5427
  dry_run: flexBool().describe("Preview changes without writing"),
5334
5428
  restore_indent: flexBool().describe("Auto-fix indentation to match anchor (default: false)"),
package/output-style.md CHANGED
@@ -43,15 +43,15 @@ Prefer `hex-line` for text files you may inspect or modify. Hash-annotated reads
43
43
 
44
44
  ## Edit Discipline
45
45
 
46
- - Never invent `range_checksum`. Copy it from a fresh `read_file` or `grep_search(output_mode:"content", edit_ready=true)` block.
46
+ - Prefer a real `range_checksum` copied from a fresh `read_file` or `grep_search(output_mode:"content", edit_ready=true)` block. If you don't have one for the exact range, pass `"auto"` and the server computes it for the current anchor range.
47
47
  - First mutation in a file: use `grep_search(output_mode="summary")` for narrow targets, or `outline -> read_file(ranges)` for structural edits. Escalate to `grep_search(output_mode="content", edit_ready=true)` only when the next edit needs canonical hunks.
48
48
  - 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.
49
49
  - Prefer `set_line` or `insert_after` for small local changes. Prefer `replace_between` for larger bounded block rewrites.
50
50
  - When either anchor of `replace_between` is a lone delimiter (`}`, `)`, `]`, `});`), switch to `replace_lines` with `range_checksum`, or pass `range_checksum` to `replace_between` directly. `replace_between` anchors use short line-content hashes and may fuzzy-match a sibling closing delimiter.
51
51
  - For inclusive `replace_between`: enumerate every `{`, `(`, `[` opened inside the replaced range and ensure `new_text` closes them all. If the range crosses a method/class/namespace boundary, prefer `set_line` + `insert_after` for each hunk.
52
52
  - After `replace_between` on C#/Java/Go/C++/Rust files, run the language build or type-check once before proceeding. Brace drift is invisible at edit time.
53
- - Use `replace_lines` only when you already hold the exact inclusive range checksum for that block.
54
- - Avoid large first-pass edit batches. Start with 1-2 hunks, then continue from the returned `revision` as `base_revision`.
53
+ - Use `replace_lines` for bounded block replacement; supply its inclusive `range_checksum`, or pass `"auto"` to have the server compute it.
54
+ - Batch ALL hunks for the same file into ONE `edit_file` call (the `edits` array). Separate sequential edits on one file go stale and conflict — this is the single most common edit failure. Use `base_revision` only for a genuinely later follow-up after the file already changed.
55
55
  - 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`.
56
56
  - If `edit_file` returns `retry_edit`, `retry_edits`, or `retry_plan`, reuse those directly instead of rebuilding anchors/checksums by hand.
57
57
  - Reuse `retry_checksum` when it is returned for the exact same target range.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@levnikolaevich/hex-line-mcp",
3
- "version": "1.30.1",
3
+ "version": "1.31.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.",