@levnikolaevich/hex-line-mcp 1.3.1 → 1.3.3

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.
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Hex-line workflow benchmark with optional diagnostics.
4
+ *
5
+ * Public benchmark mode reports only comparative multi-step workflows.
6
+ * Synthetic tool-level scenarios are available behind --diagnostics and
7
+ * should not be treated as headline benchmark results.
8
+ *
9
+ * Usage:
10
+ * node mcp/hex-line-mcp/benchmark/index.mjs [--repo /path/to/repo]
11
+ * node mcp/hex-line-mcp/benchmark/index.mjs --diagnostics [--with-graph]
12
+ */
13
+
14
+ import { writeFileSync, unlinkSync } from "node:fs";
15
+ import { resolve, basename } from "node:path";
16
+ import { tmpdir } from "node:os";
17
+ import {
18
+ walkDir, getFileLines, categorize, generateTempCode,
19
+ fmt, pctSavings, RUNS,
20
+ } from "../lib/benchmark-helpers.mjs";
21
+ import { runAtomic } from "./atomic.mjs";
22
+ import { runGraph } from "./graph.mjs";
23
+ import { runWorkflows } from "./workflows.mjs";
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // CLI
27
+ // ---------------------------------------------------------------------------
28
+
29
+ const args = process.argv.slice(2);
30
+ let repoRoot = process.cwd();
31
+ const repoIdx = args.indexOf("--repo");
32
+ if (repoIdx !== -1 && args[repoIdx + 1]) {
33
+ repoRoot = resolve(args[repoIdx + 1]);
34
+ }
35
+
36
+ const diagnostics = args.includes("--diagnostics");
37
+ const withGraph = args.includes("--with-graph");
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Main
41
+ // ---------------------------------------------------------------------------
42
+
43
+ async function main() {
44
+ const allFiles = walkDir(repoRoot);
45
+ if (allFiles.length === 0) {
46
+ console.log(`No code files found in ${repoRoot}`);
47
+ process.exit(1);
48
+ }
49
+
50
+ const totalLines = allFiles.reduce((sum, f) => {
51
+ const lines = getFileLines(f);
52
+ return lines ? sum + lines.length : sum;
53
+ }, 0);
54
+
55
+ const cats = categorize(allFiles);
56
+ const repoName = basename(repoRoot);
57
+
58
+ // Top 3 largest code files for realistic tests
59
+ const sorted = allFiles.map(f => ({ f, lines: getFileLines(f)?.length || 0 }))
60
+ .sort((a, b) => b.lines - a.lines);
61
+ const largeFiles = sorted.slice(0, 3).map(s => s.f);
62
+
63
+ // Temp file setup
64
+ const ts = Date.now();
65
+ const tmpPath = resolve(tmpdir(), `hex-line-bench-${ts}.js`);
66
+ const tmpLines = generateTempCode();
67
+ const tmpContent = tmpLines.join("\n");
68
+ writeFileSync(tmpPath, tmpContent, "utf-8");
69
+
70
+ // Build config shared across all benchmark modules
71
+ const config = { allFiles, cats, largeFiles, tmpPath, tmpContent, tmpLines, repoRoot, ts };
72
+
73
+ // Run benchmark suites
74
+ const workflowResults = await runWorkflows(config);
75
+ const results = diagnostics ? await runAtomic(config) : [];
76
+ const graphOut = diagnostics && withGraph ? await runGraph(config) : [];
77
+
78
+ // Cleanup
79
+ try { unlinkSync(tmpPath); } catch { /* ok */ }
80
+
81
+ // ===================================================================
82
+ // Report
83
+ // ===================================================================
84
+ const out = [];
85
+ out.push("# Hex-line Workflow Benchmark");
86
+ out.push("");
87
+ out.push(`Repository: ${repoName} (${fmt(allFiles.length)} code files, ${fmt(totalLines)} lines) `);
88
+ out.push(`Temp file: ${tmpPath} (200 lines) `);
89
+ out.push(`Date: ${new Date().toISOString().slice(0, 10)} `);
90
+ out.push(`Runs per scenario: ${RUNS} (median) `);
91
+ out.push("");
92
+ out.push("Mode: comparative workflow benchmark");
93
+ out.push("");
94
+ out.push("## Workflow Scenarios");
95
+ out.push("");
96
+ out.push("| # | Scenario | Built-in | Hex-line | Savings | Ops |");
97
+ out.push("|---|----------|----------|----------|---------|-----|");
98
+ for (const w of workflowResults) {
99
+ out.push(`| ${w.id} | ${w.scenario} | ${fmt(w.without)} chars | ${fmt(w.withSL)} chars | ${pctSavings(w.without, w.withSL)} | ${w.opsWithout}\u2192${w.opsWith} |`);
100
+ }
101
+ out.push("");
102
+
103
+ if (workflowResults.length > 0) {
104
+ const avgWorkflowSavings = workflowResults.reduce((sum, w) => {
105
+ if (w.without === 0) return sum;
106
+ return sum + (((w.without - w.withSL) / w.without) * 100);
107
+ }, 0) / workflowResults.length;
108
+ const totalWorkflowOpsWithout = workflowResults.reduce((sum, w) => sum + w.opsWithout, 0);
109
+ const totalWorkflowOpsWith = workflowResults.reduce((sum, w) => sum + w.opsWith, 0);
110
+ const workflowOpsPct = totalWorkflowOpsWithout > 0
111
+ ? (((totalWorkflowOpsWithout - totalWorkflowOpsWith) / totalWorkflowOpsWithout) * 100).toFixed(0)
112
+ : "0";
113
+ out.push(`Workflow summary: ${avgWorkflowSavings.toFixed(0)}% average token savings | ${totalWorkflowOpsWithout}\u2192${totalWorkflowOpsWith} ops (${workflowOpsPct}% fewer)`);
114
+ out.push("");
115
+ }
116
+
117
+ out.push("Note: benchmark mode reports only multi-step comparative workflows. Synthetic tool-level scenarios live under diagnostics and are not headline results.");
118
+ out.push("");
119
+
120
+ if (diagnostics) {
121
+ out.push("## Diagnostics");
122
+ out.push("");
123
+ out.push("These rows are modeled tool-level comparisons for engineering inspection only. They are not part of the public workflow benchmark score.");
124
+ out.push("");
125
+ out.push("| # | Scenario | Baseline | Hex-line | Savings |");
126
+ out.push("|---|----------|----------|----------|---------|");
127
+ for (const r of results) {
128
+ out.push(`| ${r.num} | ${r.scenario} | ${fmt(r.without)} chars | ${fmt(r.withSL)} chars | ${r.savings} |`);
129
+ }
130
+ out.push("");
131
+ if (graphOut.length > 0) {
132
+ out.push("### Graph Enrichment Diagnostics");
133
+ out.push("");
134
+ out.push("Graph-enrichment rows remain diagnostics only. Run with `--with-graph` to inspect them alongside the atomic rows.");
135
+ out.push("");
136
+ out.push(...graphOut);
137
+ out.push("");
138
+ }
139
+ }
140
+
141
+ console.log(out.join("\n"));
142
+ }
143
+
144
+ main();
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Session-derived workflow scenarios built from recent real Claude usage.
3
+ *
4
+ * These are still local, reproducible benchmarks, but the tasks are framed
5
+ * after actual day-to-day workflows observed in recent sessions:
6
+ * - debugging hex-line hook behavior
7
+ * - adjusting setup/output guidance
8
+ * - repo-wide benchmark wording refactor
9
+ * - targeted edit inside a large smoke test
10
+ */
11
+
12
+ import { copyFileSync, mkdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
13
+ import { resolve, dirname } from "node:path";
14
+ import { tmpdir } from "node:os";
15
+ import { fnv1a, lineTag, rangeChecksum } from "../lib/hash.mjs";
16
+ import { readFile } from "../lib/read.mjs";
17
+ import { verifyChecksums } from "../lib/verify.mjs";
18
+ import { editFile } from "../lib/edit.mjs";
19
+ import { bulkReplace } from "../lib/bulk-replace.mjs";
20
+ import { fileOutline } from "../lib/outline.mjs";
21
+ import {
22
+ getFileLines,
23
+ simBuiltInReadFull,
24
+ simBuiltInGrep,
25
+ simBuiltInEdit,
26
+ simHexLineEditDiff,
27
+ runN,
28
+ } from "../lib/benchmark-helpers.mjs";
29
+
30
+ function ensureLine(lines, matcher, label) {
31
+ const idx = lines.findIndex((line) => matcher(line));
32
+ if (idx === -1) throw new Error(`Benchmark fixture missing line for ${label}`);
33
+ return idx;
34
+ }
35
+
36
+ function copyIntoTemp(tempRoot, sourceRoot, relPath) {
37
+ const src = resolve(sourceRoot, relPath);
38
+ const dst = resolve(tempRoot, relPath);
39
+ mkdirSync(dirname(dst), { recursive: true });
40
+ copyFileSync(src, dst);
41
+ return dst;
42
+ }
43
+
44
+ export async function runWorkflows(config) {
45
+ const { repoRoot, allFiles, largeFiles } = config;
46
+ const workflowResults = [];
47
+
48
+ // W1: derived from "Debug hex line formatting in file listings"
49
+ {
50
+ const sourcePath = resolve(repoRoot, "hook.mjs");
51
+ const sourceLines = getFileLines(sourcePath);
52
+ if (!sourceLines) throw new Error("Unable to load hook.mjs for benchmark workflow W1");
53
+
54
+ const targetIdx = ensureLine(
55
+ sourceLines,
56
+ (line) => line.includes("ls -R, ls -laR (recursive only)"),
57
+ "hook redirect comment",
58
+ );
59
+ const tempPath = resolve(tmpdir(), `hex-line-wf1-${Date.now()}.mjs`);
60
+ copyFileSync(sourcePath, tempPath);
61
+
62
+ const updatedLine = sourceLines[targetIdx].replace("recursive only", "recursive listing only");
63
+ const updatedLines = [...sourceLines];
64
+ updatedLines[targetIdx] = updatedLine;
65
+
66
+ const { value: without } = runN(() => {
67
+ let total = 0;
68
+ total += simBuiltInGrep("ls -R", tempPath).length;
69
+ total += simBuiltInReadFull(tempPath, sourceLines).length;
70
+ total += simBuiltInEdit(tempPath, sourceLines, updatedLines).length;
71
+ return total;
72
+ });
73
+
74
+ const { value: withSL } = runN(() => {
75
+ let total = 0;
76
+ const tag = lineTag(fnv1a(sourceLines[targetIdx]));
77
+ total += `${tempPath}:>>${tag}.${targetIdx + 1}\t${sourceLines[targetIdx]}`.length;
78
+ try {
79
+ total += editFile(tempPath, [{ set_line: { anchor: `${tag}.${targetIdx + 1}`, new_text: updatedLine } }]).length;
80
+ } catch (e) {
81
+ total += e.message.length;
82
+ }
83
+ return total;
84
+ });
85
+
86
+ workflowResults.push({
87
+ id: "W1",
88
+ scenario: "Debug hook file-listing redirect",
89
+ without,
90
+ withSL,
91
+ opsWithout: 3,
92
+ opsWith: 2,
93
+ });
94
+ try { unlinkSync(tempPath); } catch {}
95
+ }
96
+
97
+ // W2: derived from setup / guidance updates in repo tooling sessions
98
+ {
99
+ const sourcePath = resolve(repoRoot, "lib", "setup.mjs");
100
+ const sourceLines = getFileLines(sourcePath);
101
+ if (!sourceLines) throw new Error("Unable to load lib/setup.mjs for benchmark workflow W2");
102
+
103
+ const targetIdx = ensureLine(
104
+ sourceLines,
105
+ (line) => line.includes("Codex: Not supported"),
106
+ "setup guidance line",
107
+ );
108
+ const tempPath = resolve(tmpdir(), `hex-line-wf2-${Date.now()}.mjs`);
109
+ copyFileSync(sourcePath, tempPath);
110
+
111
+ const updatedLine = sourceLines[targetIdx].replace(
112
+ "Add MCP Tool Preferences to AGENTS.md instead",
113
+ "Document MCP Tool Preferences in AGENTS.md instead",
114
+ );
115
+ const updatedLines = [...sourceLines];
116
+ updatedLines[targetIdx] = updatedLine;
117
+ const windowStart = Math.max(1, targetIdx - 3);
118
+ const windowLimit = Math.min(sourceLines.length - windowStart + 1, 10);
119
+ const hashes = sourceLines.map((line) => fnv1a(line));
120
+ const checksum = rangeChecksum(hashes, windowStart, windowStart + windowLimit - 1);
121
+
122
+ const { value: without } = runN(() => {
123
+ let total = 0;
124
+ total += simBuiltInReadFull(tempPath, sourceLines).length;
125
+ total += simBuiltInEdit(tempPath, sourceLines, updatedLines).length;
126
+ total += simBuiltInReadFull(tempPath, sourceLines).length;
127
+ return total;
128
+ });
129
+
130
+ const { value: withSL } = runN(() => {
131
+ let total = 0;
132
+ total += readFile(tempPath, { offset: windowStart, limit: windowLimit }).length;
133
+ copyFileSync(sourcePath, tempPath);
134
+ try {
135
+ const tag = lineTag(fnv1a(sourceLines[targetIdx]));
136
+ total += editFile(tempPath, [{ set_line: { anchor: `${tag}.${targetIdx + 1}`, new_text: updatedLine } }]).length;
137
+ } catch (e) {
138
+ total += e.message.length;
139
+ }
140
+ try {
141
+ total += verifyChecksums(tempPath, [checksum]).length;
142
+ } catch (e) {
143
+ total += e.message.length;
144
+ }
145
+ return total;
146
+ });
147
+
148
+ workflowResults.push({
149
+ id: "W2",
150
+ scenario: "Adjust setup_hooks guidance and verify",
151
+ without,
152
+ withSL,
153
+ opsWithout: 3,
154
+ opsWith: 3,
155
+ });
156
+ try { unlinkSync(tempPath); } catch {}
157
+ }
158
+
159
+ // W3: derived from repo-wide benchmark wording refactors
160
+ {
161
+ const tempRoot = resolve(tmpdir(), `hex-line-wf3-${Date.now()}`);
162
+ mkdirSync(tempRoot, { recursive: true });
163
+ const fixtureFiles = [
164
+ "README.md",
165
+ "package.json",
166
+ "benchmark/index.mjs",
167
+ "benchmark/atomic.mjs",
168
+ "benchmark/workflows.mjs",
169
+ ];
170
+ const copiedFiles = fixtureFiles.map((relPath) => copyIntoTemp(tempRoot, repoRoot, relPath));
171
+ const fileLines = copiedFiles.map((filePath) => getFileLines(filePath));
172
+ const replacements = [{ old: "benchmark", new: "workflow benchmark" }];
173
+
174
+ const { value: without } = runN(() => {
175
+ let total = 0;
176
+ for (let i = 0; i < copiedFiles.length; i++) {
177
+ const filePath = copiedFiles[i];
178
+ const lines = fileLines[i];
179
+ if (!lines) continue;
180
+ total += simBuiltInGrep("benchmark", filePath).length;
181
+ total += simBuiltInReadFull(filePath, lines).length;
182
+ const updated = lines.map((line) => line.split("benchmark").join("workflow benchmark"));
183
+ total += simBuiltInEdit(filePath, lines, updated).length;
184
+ }
185
+ return total;
186
+ });
187
+
188
+ const { value: withSL } = runN(() => {
189
+ return bulkReplace(
190
+ tempRoot,
191
+ "**/*.{md,json,mjs}",
192
+ replacements,
193
+ { dryRun: true, maxFiles: 10 },
194
+ ).length;
195
+ });
196
+
197
+ workflowResults.push({
198
+ id: "W3",
199
+ scenario: "Repo-wide benchmark wording refresh",
200
+ without,
201
+ withSL,
202
+ opsWithout: copiedFiles.length * 3,
203
+ opsWith: 1,
204
+ });
205
+ try { rmSync(tempRoot, { recursive: true }); } catch {}
206
+ }
207
+
208
+ // W4: derived from reviewing large smoke tests before a focused change
209
+ {
210
+ const preferredLarge = allFiles.find((filePath) => filePath.endsWith("test\\smoke.mjs"))
211
+ || largeFiles[0]
212
+ || allFiles[0];
213
+ const largeLines = getFileLines(preferredLarge);
214
+ if (largeLines && largeLines.length > 100) {
215
+ const targetIdx = ensureLine(
216
+ largeLines,
217
+ (line) => line.includes("describe(\"hook — ls redirect\""),
218
+ "large smoke test anchor",
219
+ );
220
+ const sliceStart = Math.max(0, targetIdx - 5);
221
+ const sliceEnd = Math.min(largeLines.length, targetIdx + 15);
222
+ const editedSlice = largeLines.slice(sliceStart, sliceEnd).map((line, idx) =>
223
+ idx === (targetIdx - sliceStart) ? `${line} // benchmark-note` : line,
224
+ );
225
+
226
+ const { value: without } = runN(() => {
227
+ let total = 0;
228
+ total += simBuiltInReadFull(preferredLarge, largeLines).length;
229
+ total += simBuiltInGrep("hook — ls redirect", preferredLarge).length;
230
+ const updatedLines = [...largeLines];
231
+ updatedLines[targetIdx] = `${updatedLines[targetIdx]} // benchmark-note`;
232
+ total += simBuiltInEdit(preferredLarge, largeLines, updatedLines).length;
233
+ return total;
234
+ });
235
+
236
+ let outlineLen = 500;
237
+ try { outlineLen = (await fileOutline(preferredLarge)).length; } catch {}
238
+
239
+ const { value: withSL } = runN(() => {
240
+ let total = 0;
241
+ total += outlineLen;
242
+ total += readFile(preferredLarge, { offset: sliceStart + 1, limit: sliceEnd - sliceStart }).length;
243
+ total += simHexLineEditDiff(largeLines.slice(sliceStart, sliceEnd), editedSlice).length;
244
+ return total;
245
+ });
246
+
247
+ workflowResults.push({
248
+ id: "W4",
249
+ scenario: `Inspect large smoke test before edit (${largeLines.length}L)`,
250
+ without,
251
+ withSL,
252
+ opsWithout: 3,
253
+ opsWith: 3,
254
+ });
255
+ }
256
+ }
257
+
258
+ // W5: revision-aware follow-up edit after unrelated line shift
259
+ {
260
+ const tempPath = resolve(tmpdir(), `hex-line-wf5-${Date.now()}.mjs`);
261
+ const prefix = Array.from({ length: 80 }, (_, i) => `pre-${i}`);
262
+ const suffix = Array.from({ length: 80 }, (_, i) => `post-${i}`);
263
+ const sourceLines = [
264
+ ...prefix,
265
+ "head1",
266
+ "head2",
267
+ "targetA",
268
+ "targetB",
269
+ "tail",
270
+ ...suffix,
271
+ "",
272
+ ];
273
+ const sourceText = sourceLines.join("\n");
274
+ mkdirSync(dirname(tempPath), { recursive: true });
275
+ writeFileSync(tempPath, sourceText, "utf-8");
276
+
277
+ const head1Idx = prefix.length;
278
+ const targetAIdx = prefix.length + 2;
279
+ const targetBIdx = prefix.length + 3;
280
+ const withInsert = [
281
+ ...prefix,
282
+ "head1",
283
+ "inserted",
284
+ "head2",
285
+ "targetA",
286
+ "targetB",
287
+ "tail",
288
+ ...suffix,
289
+ "",
290
+ ];
291
+ const updatedLines = [
292
+ ...prefix,
293
+ "head1",
294
+ "inserted",
295
+ "head2",
296
+ "targetA",
297
+ "updatedB",
298
+ "tail",
299
+ ...suffix,
300
+ "",
301
+ ];
302
+
303
+ const { value: without } = runN(() => {
304
+ let total = 0;
305
+ total += simBuiltInReadFull(tempPath, sourceLines).length;
306
+ total += simBuiltInEdit(tempPath, sourceLines, withInsert).length;
307
+ total += simBuiltInReadFull(tempPath, withInsert).length;
308
+ total += simBuiltInEdit(tempPath, withInsert, updatedLines).length;
309
+ return total;
310
+ });
311
+
312
+ const { value: withSL } = runN(() => {
313
+ let total = 0;
314
+ writeFileSync(tempPath, sourceText, "utf-8");
315
+ const baseRead = readFile(tempPath, { offset: head1Idx + 1, limit: 8 });
316
+ total += baseRead.length;
317
+ const baseRevision = baseRead.match(/revision: (\S+)/)?.[1];
318
+ const headTag = lineTag(fnv1a(sourceLines[head1Idx]));
319
+ total += editFile(tempPath, [{ insert_after: { anchor: `${headTag}.${head1Idx + 1}`, text: "inserted" } }]).length;
320
+ const startTag = lineTag(fnv1a(sourceLines[targetAIdx]));
321
+ const endTag = lineTag(fnv1a(sourceLines[targetBIdx]));
322
+ const rc = rangeChecksum(
323
+ [fnv1a(sourceLines[targetAIdx]), fnv1a(sourceLines[targetBIdx])],
324
+ targetAIdx + 1,
325
+ targetBIdx + 1,
326
+ );
327
+ total += editFile(tempPath, [{
328
+ replace_lines: {
329
+ start_anchor: `${startTag}.${targetAIdx + 1}`,
330
+ end_anchor: `${endTag}.${targetBIdx + 1}`,
331
+ new_text: "targetA\nupdatedB",
332
+ range_checksum: rc,
333
+ }
334
+ }], { baseRevision, conflictPolicy: "conservative" }).length;
335
+ return total;
336
+ });
337
+
338
+ workflowResults.push({
339
+ id: "W5",
340
+ scenario: "Follow-up edit after unrelated line shift",
341
+ without,
342
+ withSL,
343
+ opsWithout: 4,
344
+ opsWith: 3,
345
+ });
346
+ try { unlinkSync(tempPath); } catch {}
347
+ }
348
+
349
+ return workflowResults;
350
+ }
package/hook.mjs CHANGED
@@ -14,16 +14,17 @@
14
14
  *
15
15
  * PostToolUse:
16
16
  * - RTK output filter: compresses verbose Bash output
17
- * (npm install, test, build, pip, git) to save context tokens.
17
+ * (npm install, test, build, pip, git). Stderr shown to
18
+ * Claude as feedback.
18
19
  *
19
20
  * SessionStart:
20
21
  * - Injects tool preference list into agent context.
21
22
  *
22
23
  * Exit 0 = approve / no feedback / systemMessage
23
- * Exit 2 = block (PreToolUse) or feedback via stderr (PostToolUse)
24
+ * Exit 2 = block (PreToolUse) or stderr feedback (PostToolUse)
24
25
  */
25
26
 
26
- import { normalizeOutput } from "./lib/normalize.mjs";
27
+ import { normalizeOutput } from "@levnikolaevich/hex-common/output/normalize";
27
28
  import { readFileSync } from "node:fs";
28
29
  import { resolve } from "node:path";
29
30
  import { homedir } from "node:os";
@@ -41,21 +42,21 @@ const BINARY_EXT = new Set([
41
42
 
42
43
  const REVERSE_TOOL_HINTS = {
43
44
  "mcp__hex-line__read_file": "Read (file_path, offset, limit)",
44
- "mcp__hex-line__edit_file": "Edit (file_path, old_string, new_string)",
45
+ "mcp__hex-line__edit_file": "Edit (revision-aware hash edits, block rewrite, auto-rebase)",
45
46
  "mcp__hex-line__write_file": "Write (file_path, content)",
46
47
  "mcp__hex-line__grep_search": "Grep (pattern, path)",
47
48
  "mcp__hex-line__directory_tree": "Glob (pattern) or Bash(ls)",
48
49
  "mcp__hex-line__get_file_info": "Bash(stat/wc)",
49
50
  "mcp__hex-line__outline": "Read with offset/limit",
50
- "mcp__hex-line__verify": "Read the file again with Read",
51
+ "mcp__hex-line__verify": "Verify held checksums / revision without reread",
51
52
  "mcp__hex-line__changes": "Bash(git diff)",
52
- "mcp__hex-line__bulk_replace": "Edit for each file",
53
+ "mcp__hex-line__bulk_replace": "Edit (text rename/refactor across files)",
53
54
  "mcp__hex-line__setup_hooks": "Not available (hex-line disabled)",
54
55
  };
55
56
 
56
57
  const TOOL_HINTS = {
57
58
  Read: "mcp__hex-line__read_file (not Read). For writing: write_file (no prior Read needed)",
58
- Edit: "mcp__hex-line__edit_file (not Edit, not sed -i). read_file first for hashes",
59
+ Edit: "mcp__hex-line__edit_file for revision-aware hash edits. Batch same-file hunks, carry base_revision, use replace_between for block rewrites",
59
60
  Write: "mcp__hex-line__write_file (not Write). No prior Read needed",
60
61
  Grep: "mcp__hex-line__grep_search (not Grep). Params: output, literal, context_before, context_after, multiline",
61
62
  cat: "mcp__hex-line__read_file (not cat/head/tail/less/more)",
@@ -64,10 +65,10 @@ const TOOL_HINTS = {
64
65
  ls: "mcp__hex-line__directory_tree with pattern param (not ls/find/tree). E.g. pattern='*-mcp' type='dir'",
65
66
  stat: "mcp__hex-line__get_file_info (not stat/wc/file)",
66
67
  grep: "mcp__hex-line__grep_search (not grep/rg). Params: output, literal, context_before, context_after, multiline",
67
- sed: "mcp__hex-line__edit_file (not sed -i). read_file first for hashes",
68
+ sed: "mcp__hex-line__edit_file for hash edits, or mcp__hex-line__bulk_replace for text rename (not sed -i)",
68
69
  diff: "mcp__hex-line__changes (not diff). Git-based semantic diff",
69
70
  outline: "mcp__hex-line__outline (before reading large code files)",
70
- verify: "mcp__hex-line__verify (staleness check without re-read)",
71
+ verify: "mcp__hex-line__verify (staleness / revision check without re-read)",
71
72
  changes: "mcp__hex-line__changes (semantic AST diff)",
72
73
  bulk: "mcp__hex-line__bulk_replace (multi-file search-replace)",
73
74
  setup: "mcp__hex-line__setup_hooks (configure hooks for agents)",
@@ -78,7 +79,8 @@ const BASH_REDIRECTS = [
78
79
  { regex: /^head\s+/, key: "head" },
79
80
  { regex: /^tail\s+(?!-[fF])/, key: "tail" },
80
81
  { regex: /^(less|more)\s+/, key: "cat" },
81
- { regex: /^(ls|dir)(\s+-\S+)*\s+/, key: "ls" },
82
+ { regex: /^ls\s+-\S*R(\s|$)/, key: "ls" }, // ls -R, ls -laR (recursive only)
83
+ { regex: /^dir\s+\/[sS](\s|$)/, key: "ls" }, // dir /s, dir /S (recursive only)
82
84
  { regex: /^tree\s+/, key: "ls" },
83
85
  { regex: /^find\s+/, key: "ls" },
84
86
  { regex: /^(stat|wc)\s+/, key: "stat" },
@@ -152,6 +154,18 @@ function detectCommandType(cmd) {
152
154
  return "generic";
153
155
  }
154
156
 
157
+ function extractBashText(response) {
158
+ if (typeof response === "string") return response;
159
+ if (response && typeof response === "object") {
160
+ // Combine stdout + stderr in stable order
161
+ const parts = [];
162
+ if (response.stdout) parts.push(response.stdout);
163
+ if (response.stderr) parts.push(response.stderr);
164
+ return parts.join("\n") || "";
165
+ }
166
+ return ""; // unknown shape \u2192 fail open
167
+ }
168
+
155
169
  /** Cache: null = not computed yet */
156
170
  let _hexLineDisabled = null;
157
171
 
@@ -225,6 +239,25 @@ function handlePreToolUse(data) {
225
239
  process.exit(0);
226
240
  }
227
241
 
242
+ // Skip Claude config files (settings.json, settings.local.json)
243
+ const ALLOWED_CONFIGS = new Set(["settings.json", "settings.local.json"]);
244
+ const fileName = normalPath.split("/").pop();
245
+ if (ALLOWED_CONFIGS.has(fileName)) {
246
+ let candidate = filePath;
247
+ if (candidate.startsWith("~/")) {
248
+ candidate = homedir().replace(/\\/g, "/") + candidate.slice(1);
249
+ }
250
+ const absPath = resolve(process.cwd(), candidate).replace(/\\/g, "/");
251
+ const projectClaude = resolve(process.cwd(), ".claude").replace(/\\/g, "/") + "/";
252
+ const globalClaude = resolve(homedir(), ".claude").replace(/\\/g, "/") + "/";
253
+ const cmp = process.platform === "win32"
254
+ ? (a, b) => a.toLowerCase().startsWith(b.toLowerCase())
255
+ : (a, b) => a.startsWith(b);
256
+ if (cmp(absPath, projectClaude) || cmp(absPath, globalClaude)) {
257
+ process.exit(0);
258
+ }
259
+ }
260
+
228
261
  // Block with redirect — include extracted path for instant retry
229
262
  const hint = TOOL_HINTS[hintKey];
230
263
  const toolName2 = hint.split(" (")[0];
@@ -317,15 +350,15 @@ function handlePostToolUse(data) {
317
350
  }
318
351
 
319
352
  const toolInput = data.tool_input || {};
320
- const toolResult = data.tool_result;
353
+ const rawText = extractBashText(data.tool_response);
321
354
  const command = toolInput.command || "";
322
355
 
323
356
  // Nothing to filter
324
- if (!toolResult || typeof toolResult !== "string") {
357
+ if (!rawText) {
325
358
  process.exit(0);
326
359
  }
327
360
 
328
- const lines = toolResult.split("\n");
361
+ const lines = rawText.split("\n");
329
362
  const originalCount = lines.length;
330
363
 
331
364
  // Short output - no filtering
@@ -390,9 +423,9 @@ function handleSessionStart() {
390
423
  lines.push(`- ${hint}`);
391
424
  }
392
425
  }
393
- lines.push("Exceptions: images, PDFs, notebooks \u2192 built-in Read");
426
+ lines.push("Exceptions: images, PDFs, notebooks, .claude/settings.json, .claude/settings.local.json \u2192 built-in Read; Glob always OK");
394
427
  lines.push("Bash OK for: npm/node/git/docker/curl, pipes, scripts");
395
- const msg = "Hex-line MCP available. ALWAYS prefer:\n" + lines.join("\n");
428
+ const msg = "Hex-line MCP available. Workflow:\n- Discovery: read_file, grep_search, outline, directory_tree\n- Same-file edits: prefer ONE edit_file call per file, carry revision/base_revision\n- Hash edits: edit_file (set_line, replace_lines, insert_after, replace_between)\n- Large rewrites: replace_between instead of reciting old blocks\n- Text rename: bulk_replace (multi-file search-replace)\n- Verify staleness: verify before considering reread\n- Write new: write_file\n" + lines.join("\n");
396
429
  process.stdout.write(JSON.stringify({ systemMessage: msg }));
397
430
  process.exit(0);
398
431
  }
@@ -2,7 +2,7 @@ import { readFileSync, statSync, readdirSync } from "node:fs";
2
2
  import { execSync } from "node:child_process";
3
3
  import { performance } from "node:perf_hooks";
4
4
  import { resolve, extname, join } from "node:path";
5
- import { fnv1a, lineTag } from "./hash.mjs";
5
+ import { fnv1a, lineTag } from "@levnikolaevich/hex-common/text-protocol/hash";
6
6
  import { readFile } from "./read.mjs";
7
7
 
8
8
  // ---------------------------------------------------------------------------
package/lib/changes.mjs CHANGED
@@ -9,7 +9,7 @@
9
9
  import { execFileSync } from "node:child_process";
10
10
  import { statSync } from "node:fs";
11
11
  import { extname } from "node:path";
12
- import { validatePath } from "./security.mjs";
12
+ import { validatePath, normalizePath } from "./security.mjs";
13
13
  import { readText } from "./format.mjs";
14
14
  import { outlineFromContent } from "./outline.mjs";
15
15
 
@@ -75,6 +75,7 @@ function gitRelativePath(absPath) {
75
75
  * @returns {Promise<string>} Formatted diff
76
76
  */
77
77
  export async function fileChanges(filePath, compareAgainst = "HEAD") {
78
+ filePath = normalizePath(filePath);
78
79
  const real = validatePath(filePath);
79
80
 
80
81
  // Directory: return git diff --stat (compact file list, no content reads)