@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.
- package/README.md +120 -47
- package/benchmark/atomic.mjs +502 -0
- package/benchmark/graph.mjs +80 -0
- package/benchmark/index.mjs +144 -0
- package/benchmark/workflows.mjs +350 -0
- package/hook.mjs +48 -15
- package/lib/benchmark-helpers.mjs +1 -1
- package/lib/changes.mjs +2 -1
- package/lib/coerce.mjs +1 -42
- package/lib/edit.mjs +258 -248
- package/lib/graph-enrich.mjs +76 -58
- package/lib/hash.mjs +1 -109
- package/lib/info.mjs +1 -1
- package/lib/normalize.mjs +1 -106
- package/lib/outline.mjs +32 -87
- package/lib/read.mjs +8 -5
- package/lib/revisions.mjs +238 -0
- package/lib/search.mjs +6 -7
- package/lib/security.mjs +4 -4
- package/lib/setup.mjs +7 -20
- package/lib/update-check.mjs +1 -56
- package/lib/verify.mjs +32 -16
- package/output-style.md +21 -6
- package/package.json +18 -6
- package/server.mjs +35 -43
- package/benchmark.mjs +0 -1106
|
@@ -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)
|
|
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
|
|
24
|
+
* Exit 2 = block (PreToolUse) or stderr feedback (PostToolUse)
|
|
24
25
|
*/
|
|
25
26
|
|
|
26
|
-
import { normalizeOutput } from "
|
|
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 (
|
|
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": "
|
|
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
|
|
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
|
|
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)
|
|
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: /^
|
|
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
|
|
353
|
+
const rawText = extractBashText(data.tool_response);
|
|
321
354
|
const command = toolInput.command || "";
|
|
322
355
|
|
|
323
356
|
// Nothing to filter
|
|
324
|
-
if (!
|
|
357
|
+
if (!rawText) {
|
|
325
358
|
process.exit(0);
|
|
326
359
|
}
|
|
327
360
|
|
|
328
|
-
const lines =
|
|
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.
|
|
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 "
|
|
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)
|