@levnikolaevich/hex-line-mcp 1.16.0 → 1.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,10 +35,10 @@ Advanced / occasional:
35
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
36
  | `edit_file` | Revision-aware anchor edits (`set_line`, `replace_lines`, `insert_after`, `replace_between`) | Batched same-file edits + conservative auto-rebase |
37
37
  | `write_file` | Create new file or overwrite, auto-creates parent dirs | Path validation, no hash overhead |
38
- | `grep_search` | Search with ripgrep, summary-first discovery, and optional edit-ready hunks | `summary` by default, explicit `content` + `edit_ready` for verified edits |
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 |
40
40
  | `verify` | Check if held checksums / revision are still current | Staleness check without full re-read |
41
- | `inspect_path` | Unified file-or-directory inspection | Minimal tree discovery by default, deeper traversal on demand |
41
+ | `inspect_path` | Unified file-or-directory inspection | Minimal tree discovery by default, capped pattern-mode lists with refine hints |
42
42
  | `changes` | Compare file against git ref, shows added/removed/modified symbols | AST-level semantic diff with risk/provenance preview |
43
43
  | `bulk_replace` | Search-and-replace across multiple files inside an explicit root path | Compact summary (default) or capped diffs via `format`, dry_run, max_files |
44
44
 
@@ -46,7 +46,7 @@ Advanced / occasional:
46
46
 
47
47
  | Event | Trigger | Action |
48
48
  |-------|---------|--------|
49
- | **PreToolUse** | Read/Edit/Write/Grep on text files | Redirect-first policy for text files; built-in tools stay reserved for binary/media and `.claude/settings*.json` exceptions |
49
+ | **PreToolUse** | Read/Edit/Write/Grep/Glob on project text scope | Hard redirect to hex-line for project-scoped text files and file discovery; built-in tools stay available for binary/media and text paths outside the current project root |
50
50
  | **PreToolUse** | Bash with dangerous commands | Blocks `rm -rf /`, `git push --force`, 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 |
@@ -54,7 +54,7 @@ Advanced / occasional:
54
54
 
55
55
  ### Bash Redirects
56
56
 
57
- PreToolUse also intercepts simple Bash commands: cat, head, tail, tree, find, stat, wc -l, grep, rg, sed -i — redirects to hex-line equivalents. `ls`/`dir` only redirected for recursive listing (`ls -R`, `dir /s`); simple `ls path` is allowed. Compound commands with pipes are allowed.
57
+ PreToolUse also intercepts project-scoped file inspection Bash commands: `cat`, `type`, `Get-Content`, `head`, `tail`, `ls`, `dir`, `tree`, `find`, `Get-ChildItem`, `stat`, `wc`, `Get-Item`, `grep`, `rg`, `findstr`, `Select-String`, `sed -i`. Targeted inspection pipelines are redirected too; Git/build/test/docker/network and stdin-filter pipelines remain allowed.
58
58
  ## Install
59
59
 
60
60
  ### MCP Server
@@ -292,9 +292,13 @@ Search file contents using ripgrep. Default mode is `summary` for discovery. Use
292
292
  | `total_limit` | number | no | Total match events across all files; multiline matches count as 1 (default: 50 for `summary`, 200 for `content`, 1000 for `files`/`count`, 0 = unlimited) |
293
293
  | `plain` | boolean | no | Omit hash tags inside block entries, return `lineNum\|content` |
294
294
  | `edit_ready` | boolean | no | Preserve hash/checksum search hunks in `content` mode |
295
+ | `allow_large_output` | boolean | no | Bypass the default `content`-mode block/char caps when you intentionally need a larger payload |
295
296
 
296
297
  `summary` mode returns counts, top files, and a few plain snippets. `content` mode returns canonical `search_hunk` blocks with per-hunk checksums enabling direct `replace_lines` from grep results without intermediate `read_file`.
297
298
 
299
+ - Default `content` mode is intentionally capped to keep discovery cheap. When truncation happens, the diagnostic block includes `shown_matches`, `file_count`, `truncated`, `next_action`, and `suggested_refine_call`.
300
+ - Treat `allow_large_output: true` as an explicit override for review/debug workflows, not as the normal discovery path.
301
+
298
302
  ### outline
299
303
 
300
304
  AST-based structural outline with hash anchors for direct `edit_file` usage. Supports JavaScript/TypeScript, Python, C#, PHP, and fence-aware markdown heading navigation (`.md`/`.mdx`). Each entry includes a hash tag for immediate anchor use without intermediate `read_file`.
@@ -343,6 +347,7 @@ Inspect a file or directory path without guessing which low-level tool to call f
343
347
  | `pattern` | string | no | Glob filter on names (e.g. `"*-mcp"`, `"*.mjs"`). Returns flat match list instead of tree |
344
348
  | `type` | string | no | `"file"`, `"dir"`, or `"all"` (default). Like `find -type f/d` |
345
349
  | `max_depth` | number | no | Max recursion depth (default: 2 for discovery, or 20 in pattern mode) |
350
+ | `max_entries` | number | no | Max entries to show in pattern mode before truncation metadata is returned (default: 60, `0` = unlimited) |
346
351
  | `gitignore` | boolean | no | Respect root .gitignore patterns (default: true). Nested .gitignore not supported |
347
352
  | `format` | string | no | `"compact"` = shorter path view. `"full"` = include sizes / metadata where available |
348
353
  | `verbosity` | enum | no | `minimal`, `compact`, or `full` |
@@ -350,6 +355,7 @@ Inspect a file or directory path without guessing which low-level tool to call f
350
355
  - For regular files it returns compact metadata: size, line count when cheap, modification time, type, and binary flag.
351
356
  - For directories it returns a gitignore-aware tree.
352
357
  - With `pattern`, it switches to flat match mode and works as the preferred replacement for `find` / recursive `ls`.
358
+ - Broad pattern mode is capped by default and returns `match_count`, `shown_count`, `truncated`, `next_action`, and `suggested_refine_call` so the caller can narrow `path` before asking for more.
353
359
 
354
360
  ## Hook
355
361
 
@@ -357,11 +363,11 @@ The unified hook (`hook.mjs`) handles three Claude hook events:
357
363
 
358
364
  ### PreToolUse: Tool Redirect
359
365
 
360
- Applies redirect-first steering to built-in `Read`, `Edit`, `Write`, and `Grep` on text files. Binary/media files (images, PDFs, notebooks, archives, executables, fonts, media) stay on built-in tools. `.claude/settings.json` and `.claude/settings.local.json` at project root or home are also allowed on built-in tools.
366
+ Hard-routes built-in `Read`, `Edit`, `Write`, `Grep`, and `Glob` on project-scoped text workflows to hex-line. Binary/media files (images, PDFs, notebooks, archives, executables, fonts, media) and text paths outside the current project root stay on built-in tools.
361
367
 
362
368
  ### PreToolUse: Bash Redirect + Dangerous Blocker
363
369
 
364
- Intercepts simple Bash commands (`cat`, `head`, `tail`, `tree`, `find`, `stat`, `wc -l`, `grep`, `rg`, `sed -i`, etc.) and redirects covered cases to hex-line tools. `ls`/`dir` are redirected only for recursive listing. Dangerous commands (`rm -rf /`, `git push --force`, `git reset --hard`, `DROP TABLE`, `chmod 777`, `mkfs`, `dd`) are blocked.
370
+ Intercepts project-scoped Bash file inspection commands (`cat`, `type`, `Get-Content`, `head`, `tail`, `ls`, `dir`, `tree`, `find`, `Get-ChildItem`, `stat`, `wc`, `Get-Item`, `grep`, `rg`, `findstr`, `Select-String`, `sed -i`, etc.) and redirects covered cases to hex-line tools. Targeted inspection pipelines are redirected. Dangerous commands (`rm -rf /`, `git push --force`, `git reset --hard`, `DROP TABLE`, `chmod 777`, `mkfs`, `dd`) are blocked.
365
371
 
366
372
  ### PostToolUse: RTK Output Filter
367
373
 
@@ -477,7 +483,7 @@ The PostToolUse hook normalizes Bash output (replaces UUIDs, timestamps, IPs wit
477
483
  <details>
478
484
  <summary><b>Can I disable the built-in tool blocking?</b></summary>
479
485
 
480
- Yes. To downgrade redirects to advice, set `.hex-skills/environment_state.json` to `{ "hooks": { "mode": "advisory" } }`. To remove the hook entirely, delete the `hex-line` hook entries from `~/.claude/settings.json`. To disable the MCP server for one project, add `hex-line` to `~/.claude.json -> projects.{cwd}.disabledMcpServers`.
486
+ Yes. The supported runtime bypass is `.hex-skills/environment_state.json` -> `{ "hooks": { "mode": "advisory" } }`, which downgrades project text redirects to advice. To remove the hook entirely, delete the `hex-line` hook entries from `~/.claude/settings.json`. To disable the MCP server for one project, add `hex-line` to `~/.claude.json -> projects.{cwd}.disabledMcpServers`.
481
487
 
482
488
  </details>
483
489
 
package/dist/hook.mjs CHANGED
@@ -55,11 +55,10 @@ function normalizeOutput(text, opts = {}) {
55
55
 
56
56
  // hook.mjs
57
57
  import { readFileSync, writeSync } from "node:fs";
58
- import { resolve as resolve2 } from "node:path";
58
+ import { resolve } from "node:path";
59
59
  import { homedir } from "node:os";
60
60
 
61
61
  // lib/hook-policy.mjs
62
- import { resolve } from "node:path";
63
62
  var BINARY_EXT = /* @__PURE__ */ new Set([
64
63
  ".png",
65
64
  ".jpg",
@@ -109,7 +108,7 @@ var REVERSE_TOOL_HINTS = {
109
108
  "mcp__hex-line__edit_file": "Edit (old_string, new_string, replace_all)",
110
109
  "mcp__hex-line__write_file": "Write (file_path, content)",
111
110
  "mcp__hex-line__grep_search": "Grep (pattern, path)",
112
- "mcp__hex-line__inspect_path": "Path info / tree / Bash(ls,stat)",
111
+ "mcp__hex-line__inspect_path": "Glob (pattern, path) / Path info / tree / Bash(ls,stat)",
113
112
  "mcp__hex-line__outline": "Read with offset/limit",
114
113
  "mcp__hex-line__verify": "Read (check checksum/revision freshness before follow-up edits)",
115
114
  "mcp__hex-line__changes": "Bash(git diff)",
@@ -120,12 +119,13 @@ var TOOL_HINTS = {
120
119
  Edit: "mcp__hex-line__edit_file for revision-aware hash edits. Batch same-file hunks, carry base_revision, use replace_between for block rewrites",
121
120
  Write: "mcp__hex-line__write_file (not Write). No prior Read needed",
122
121
  Grep: "mcp__hex-line__grep_search (not Grep). Params: output, literal, context_before, context_after, multiline",
123
- cat: "mcp__hex-line__read_file (not cat/head/tail/less/more)",
122
+ Glob: "mcp__hex-line__inspect_path (not Glob). Use pattern=... with an explicit path for project file discovery and name/path globbing",
123
+ cat: "mcp__hex-line__read_file (not cat/head/tail/less/more/type/Get-Content)",
124
124
  head: "mcp__hex-line__read_file with limit param (not head)",
125
125
  tail: "mcp__hex-line__read_file with offset param (not tail)",
126
- ls: "mcp__hex-line__inspect_path for tree or pattern search (not ls/find/tree). E.g. pattern='*-mcp' type='dir'",
127
- stat: "mcp__hex-line__inspect_path for compact file metadata (not stat/wc/file)",
128
- grep: "mcp__hex-line__grep_search (not grep/rg). Params: output, literal, context_before, context_after, multiline",
126
+ ls: "mcp__hex-line__inspect_path for tree or pattern search (not ls/dir/find/tree/Get-ChildItem). E.g. pattern='*-mcp' type='dir'",
127
+ stat: "mcp__hex-line__inspect_path for compact file metadata (not stat/wc/Get-Item/file)",
128
+ grep: "mcp__hex-line__grep_search (not grep/rg/findstr/Select-String). Params: output, literal, context_before, context_after, multiline",
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)",
@@ -135,23 +135,25 @@ var TOOL_HINTS = {
135
135
  };
136
136
  var DEFERRED_HINT = "If schemas not loaded: ToolSearch('+hex-line read edit')";
137
137
  var BASH_REDIRECTS = [
138
- { regex: /^cat\s+\S+/, key: "cat" },
139
- { regex: /^head\s+/, key: "head" },
140
- { regex: /^tail\s+(?!-[fF])/, key: "tail" },
141
- { regex: /^(less|more)\s+/, key: "cat" },
142
- { regex: /^ls\s+-\S*R(\s|$)/, key: "ls" },
143
- { regex: /^dir\s+\/[sS](\s|$)/, key: "ls" },
144
- { regex: /^tree\s+/, key: "ls" },
145
- { regex: /^find\s+/, key: "ls" },
146
- { regex: /^(stat|wc)\s+/, key: "stat" },
147
- { regex: /^(grep|rg)\s+/, key: "grep" },
148
- { regex: /^sed\s+-i/, key: "sed" }
138
+ { regex: /^(cat|type)\b/i, key: "cat", kind: "reader" },
139
+ { regex: /^head\b/i, key: "head", kind: "reader" },
140
+ { regex: /^tail\b(?!.*\s-[fF](\s|$))(?!.*\s--follow(\s|$))/i, key: "tail", kind: "reader" },
141
+ { regex: /^(less|more)\b/i, key: "cat", kind: "reader" },
142
+ { regex: /^(Get-Content|gc)\b/i, key: "cat", kind: "reader" },
143
+ { regex: /^(ls|dir|tree|find)\b/i, key: "ls", kind: "list" },
144
+ { regex: /^(Get-ChildItem|gci)\b/i, key: "ls", kind: "list" },
145
+ { regex: /^(stat|wc)\b/i, key: "stat", kind: "meta" },
146
+ { regex: /^(Get-Item|gi)\b/i, key: "stat", kind: "meta" },
147
+ { regex: /^(grep|rg|findstr)\b/i, key: "grep", kind: "search" },
148
+ { regex: /^Select-String\b/i, key: "grep", kind: "search" },
149
+ { regex: /^sed\b.*\s-i(\s|$)/i, key: "sed", kind: "edit" }
149
150
  ];
150
151
  var TOOL_REDIRECT_MAP = {
151
152
  Read: "Read",
152
153
  Edit: "Edit",
153
154
  Write: "Write",
154
- Grep: "Grep"
155
+ Grep: "Grep",
156
+ Glob: "Glob"
155
157
  };
156
158
  var DANGEROUS_PATTERNS = [
157
159
  { regex: /rm\s+(-[rf]+\s+)*[/~]/, reason: "rm -rf on root/home directory" },
@@ -175,15 +177,16 @@ var HOOK_OUTPUT_POLICY = {
175
177
  headLines: 15,
176
178
  tailLines: 15
177
179
  };
178
- function buildAllowedClaudeSettingsPaths(cwd, home) {
179
- const cwdNorm = cwd.replace(/\\/g, "/");
180
- const homeNorm = home.replace(/\\/g, "/");
181
- return [
182
- resolve(cwdNorm, ".claude/settings.json"),
183
- resolve(cwdNorm, ".claude/settings.local.json"),
184
- resolve(homeNorm, ".claude/settings.json"),
185
- resolve(homeNorm, ".claude/settings.local.json")
186
- ].map((entry) => entry.replace(/\\/g, "/").toLowerCase());
180
+ function normalizePolicyPath(filePath) {
181
+ if (!filePath) return "";
182
+ let normalized = filePath.replace(/\\/g, "/");
183
+ if (normalized.length > 1 && normalized.endsWith("/")) normalized = normalized.slice(0, -1);
184
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
185
+ }
186
+ function isWithinDir(candidatePath, dirPath) {
187
+ const candidate = normalizePolicyPath(candidatePath);
188
+ const dir = normalizePolicyPath(dirPath);
189
+ return !!candidate && !!dir && (candidate === dir || candidate.startsWith(`${dir}/`));
187
190
  }
188
191
 
189
192
  // hook.mjs
@@ -195,10 +198,140 @@ function extOf(filePath) {
195
198
  function getFilePath(toolInput) {
196
199
  return toolInput.file_path || toolInput.path || "";
197
200
  }
201
+ function getGlobScopePath(toolInput) {
202
+ if (toolInput.path) return toolInput.path;
203
+ const pattern = typeof toolInput.pattern === "string" ? toolInput.pattern : "";
204
+ if (!pattern) return "";
205
+ const prefix = pattern.split(/[*?[{\]]/, 1)[0];
206
+ return prefix.replace(/[\\/]+$/, "");
207
+ }
198
208
  function resolveToolPath(filePath) {
199
209
  if (!filePath) return "";
200
- if (filePath.startsWith("~/")) return resolve2(homedir(), filePath.slice(2));
201
- return resolve2(process.cwd(), filePath);
210
+ if (filePath.startsWith("~/")) return resolve(homedir(), filePath.slice(2));
211
+ return resolve(process.cwd(), filePath);
212
+ }
213
+ function stripMatchingQuotes(token) {
214
+ if (!token || token.length < 2) return token;
215
+ const first = token[0];
216
+ const last = token[token.length - 1];
217
+ return first === last && (first === '"' || first === "'" || first === "`") ? token.slice(1, -1) : token;
218
+ }
219
+ function tokenizeCommand(segment) {
220
+ return (segment.match(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`|\S+/g) || []).map(stripMatchingQuotes);
221
+ }
222
+ function splitCompoundCommand(command) {
223
+ return command.split(/\s*(?:\|\||&&|[|;]|>>?)\s*/).map((part) => part.trim()).filter(Boolean);
224
+ }
225
+ function readNamedOptionValues(tokens, optionNames) {
226
+ const values = [];
227
+ const names = new Set(optionNames.map((name) => name.toLowerCase()));
228
+ for (let i = 1; i < tokens.length; i++) {
229
+ const token = tokens[i];
230
+ const lower = token.toLowerCase();
231
+ let matched = false;
232
+ for (const name of names) {
233
+ if (lower === name) {
234
+ if (tokens[i + 1]) {
235
+ values.push(tokens[i + 1]);
236
+ }
237
+ matched = true;
238
+ break;
239
+ }
240
+ if (lower.startsWith(`${name}=`) || lower.startsWith(`${name}:`)) {
241
+ values.push(token.slice(name.length + 1));
242
+ matched = true;
243
+ break;
244
+ }
245
+ }
246
+ if (matched) continue;
247
+ }
248
+ return { values };
249
+ }
250
+ function collectPositionals(tokens, { optionValueFlags = [], slashOptions = false } = {}) {
251
+ const values = [];
252
+ const expectsValue = new Set(optionValueFlags.map((flag) => flag.toLowerCase()));
253
+ for (let i = 1; i < tokens.length; i++) {
254
+ const token = tokens[i];
255
+ const lower = token.toLowerCase();
256
+ if (expectsValue.has(lower)) {
257
+ i += 1;
258
+ continue;
259
+ }
260
+ if (lower.startsWith("-")) continue;
261
+ if (slashOptions && lower.startsWith("/")) continue;
262
+ values.push(token);
263
+ }
264
+ return values;
265
+ }
266
+ function resolveCandidatePaths(pathTokens) {
267
+ return pathTokens.map(resolveToolPath).filter(Boolean).map(normalizePolicyPath);
268
+ }
269
+ function isProjectScopedPath(filePath) {
270
+ return !!filePath && isWithinDir(resolveToolPath(filePath), process.cwd());
271
+ }
272
+ function getBashPathCandidates(spec, tokens, isFirstSegment) {
273
+ const command = (tokens[0] || "").toLowerCase();
274
+ const powershellPaths = readNamedOptionValues(tokens, ["-path", "-literalpath"]);
275
+ const positional = (flags = [], slashOptions = false) => collectPositionals(tokens, { optionValueFlags: flags, slashOptions });
276
+ if (spec.kind === "reader") {
277
+ if (powershellPaths.values.length > 0) return powershellPaths.values;
278
+ if (command === "head" || command === "tail") {
279
+ return positional(["-n", "-c", "--lines", "--bytes"]).slice(-1);
280
+ }
281
+ return positional();
282
+ }
283
+ if (spec.kind === "list") {
284
+ if (powershellPaths.values.length > 0) return powershellPaths.values;
285
+ if (command === "find") return positional().slice(0, 1);
286
+ if (command === "dir") return positional([], true);
287
+ return positional();
288
+ }
289
+ if (spec.kind === "meta") {
290
+ if (powershellPaths.values.length > 0) return powershellPaths.values;
291
+ if (command === "wc") return positional(["-l", "-w", "-c", "-m", "-L"]).slice(0);
292
+ return positional();
293
+ }
294
+ if (spec.kind === "edit") {
295
+ const args = positional(["-e", "-f"]);
296
+ const hasScriptOption = tokens.some((token) => {
297
+ const lower = token.toLowerCase();
298
+ return lower === "-e" || lower === "-f";
299
+ });
300
+ return hasScriptOption ? args : args.slice(1);
301
+ }
302
+ if (spec.kind === "search") {
303
+ if (powershellPaths.values.length > 0) return powershellPaths.values;
304
+ const args = command === "findstr" ? positional(["/c"], true) : positional(["-e", "-f", "-g", "--glob", "-t", "--type", "--type-not", "-A", "-B", "-C", "-m"]);
305
+ const hasExplicitPatternOption = command === "findstr" ? tokens.some((token) => {
306
+ const lower = token.toLowerCase();
307
+ return lower === "/c" || lower.startsWith("/c:");
308
+ }) : tokens.some((token) => {
309
+ const lower = token.toLowerCase();
310
+ return lower === "-e" || lower === "-f";
311
+ });
312
+ const pathArgs = hasExplicitPatternOption ? args : args.slice(1);
313
+ if (pathArgs.length > 0) return pathArgs;
314
+ return isFirstSegment ? [process.cwd()] : [];
315
+ }
316
+ return [];
317
+ }
318
+ function getBashRedirect(segment, isFirstSegment) {
319
+ const tokens = tokenizeCommand(segment);
320
+ if (tokens.length === 0) return null;
321
+ const spec = BASH_REDIRECTS.find((entry) => entry.regex.test(segment));
322
+ if (!spec) return null;
323
+ const targets = resolveCandidatePaths(getBashPathCandidates(spec, tokens, isFirstSegment));
324
+ if (targets.length > 0 && !targets.some((target) => isWithinDir(target, process.cwd()))) {
325
+ return null;
326
+ }
327
+ if (targets.length === 0 && spec.kind !== "list" && !(spec.kind === "search" && isFirstSegment)) {
328
+ return null;
329
+ }
330
+ const hint = TOOL_HINTS[spec.key];
331
+ return {
332
+ hint,
333
+ toolName: hint.split(" (")[0]
334
+ };
202
335
  }
203
336
  function isPartialRead(toolInput) {
204
337
  return [toolInput.offset, toolInput.limit, toolInput.start_line, toolInput.end_line, toolInput.ranges].some((value) => value !== void 0 && value !== null && value !== "");
@@ -224,7 +357,7 @@ function isHexLineDisabled(configPath) {
224
357
  if (_hexLineDisabled !== null) return _hexLineDisabled;
225
358
  _hexLineDisabled = false;
226
359
  try {
227
- const p = configPath || resolve2(homedir(), ".claude.json");
360
+ const p = configPath || resolve(homedir(), ".claude.json");
228
361
  const claudeJson = JSON.parse(readFileSync(p, "utf-8"));
229
362
  const projects = claudeJson.projects;
230
363
  if (!projects || typeof projects !== "object") return _hexLineDisabled;
@@ -250,7 +383,7 @@ function getHookMode() {
250
383
  if (_hookMode !== void 0) return _hookMode;
251
384
  _hookMode = "blocking";
252
385
  try {
253
- const stateFile = resolve2(process.cwd(), ".hex-skills/environment_state.json");
386
+ const stateFile = resolve(process.cwd(), ".hex-skills/environment_state.json");
254
387
  const data = JSON.parse(readFileSync(stateFile, "utf-8"));
255
388
  if (data.hooks?.mode === "advisory") _hookMode = "advisory";
256
389
  } catch {
@@ -270,7 +403,9 @@ function block(reason, context) {
270
403
  ${context}` : reason;
271
404
  const output = {
272
405
  hookSpecificOutput: {
273
- permissionDecision: "deny"
406
+ hookEventName: "PreToolUse",
407
+ permissionDecision: "deny",
408
+ permissionDecisionReason: reason
274
409
  },
275
410
  systemMessage: msg
276
411
  };
@@ -280,7 +415,9 @@ ${context}` : reason;
280
415
  function advise(reason, context) {
281
416
  const output = {
282
417
  hookSpecificOutput: {
283
- permissionDecision: "allow"
418
+ hookEventName: "PreToolUse",
419
+ permissionDecision: "allow",
420
+ permissionDecisionReason: reason
284
421
  },
285
422
  systemMessage: context ? `${reason}
286
423
  ${context}` : reason
@@ -302,27 +439,23 @@ function handlePreToolUse(data) {
302
439
  }
303
440
  const hintKey = TOOL_REDIRECT_MAP[toolName];
304
441
  if (hintKey) {
305
- const filePath = getFilePath(toolInput);
442
+ const filePath = toolName === "Glob" ? getGlobScopePath(toolInput) : getFilePath(toolInput);
306
443
  if (BINARY_EXT.has(extOf(filePath))) {
307
444
  process.exit(0);
308
445
  }
309
- const resolvedNorm = resolveToolPath(filePath).replace(/\\/g, "/");
310
- const claudeAllow = buildAllowedClaudeSettingsPaths(process.cwd(), homedir());
311
- if (claudeAllow.includes(resolvedNorm.toLowerCase())) {
446
+ const projectScoped = filePath ? isProjectScopedPath(filePath) : toolName === "Grep" || toolName === "Glob";
447
+ if (!projectScoped) {
312
448
  process.exit(0);
313
449
  }
314
- if (resolvedNorm.includes("/.claude/")) {
315
- redirect("Protected .claude/ path. Use built-in tools for .claude/ config files.");
316
- }
317
450
  if (toolName === "Read") {
318
451
  const ext = filePath ? extOf(filePath) : "";
319
452
  const rangeHint = isPartialRead(toolInput) ? " Preserve the same offset/limit or ranges in read_file." : "";
320
453
  const outlineHint = filePath && OUTLINEABLE_EXT.has(ext) ? `Use mcp__hex-line__outline(path="${filePath}") for structure, then mcp__hex-line__read_file(path="${filePath}") with ranges to read only what you need.${rangeHint}` : filePath ? `Use mcp__hex-line__read_file(path="${filePath}") with ranges or offset/limit.${rangeHint}` : "Use mcp__hex-line__inspect_path or mcp__hex-line__read_file";
321
- redirect(outlineHint, "Use hex-line for text-file reads to keep hashes, revision metadata, and graph hints in one flow.\n" + DEFERRED_HINT);
454
+ redirect(outlineHint, "Use hex-line for project text-file reads to keep hashes, revision metadata, and graph hints in one flow.\n" + DEFERRED_HINT);
322
455
  }
323
456
  if (toolName === "Edit") {
324
457
  const target = filePath ? `Use mcp__hex-line__grep_search or mcp__hex-line__read_file, then mcp__hex-line__edit_file with path="${filePath}"` : "Use mcp__hex-line__grep_search or mcp__hex-line__read_file, then mcp__hex-line__edit_file";
325
- redirect(target, "Use hash-verified edits for text files. Locate anchors/checksums first, then call edit_file once with batched edits.\n" + DEFERRED_HINT);
458
+ redirect(target, "Use hash-verified edits for project text files. Locate anchors/checksums first, then call edit_file once with batched edits.\n" + DEFERRED_HINT);
326
459
  }
327
460
  if (toolName === "Write") {
328
461
  const pathNote = filePath ? ` with path="${filePath}"` : "";
@@ -332,6 +465,15 @@ function handlePreToolUse(data) {
332
465
  const pathNote = filePath ? ` with path="${filePath}"` : "";
333
466
  redirect(`Use mcp__hex-line__grep_search${pathNote}`, TOOL_HINTS.Grep + "\n" + DEFERRED_HINT);
334
467
  }
468
+ if (toolName === "Glob") {
469
+ const pattern = typeof toolInput.pattern === "string" ? toolInput.pattern : "";
470
+ const inspectPath = JSON.stringify(filePath || process.cwd());
471
+ const inspectPattern = pattern ? JSON.stringify(pattern) : '"..."';
472
+ redirect(
473
+ `Use mcp__hex-line__inspect_path(path=${inspectPath}, pattern=${inspectPattern})`,
474
+ "Use inspect_path(pattern=...) for project file discovery and name/path globbing. grep_search(glob=...) remains available when you also need content search.\n" + DEFERRED_HINT
475
+ );
476
+ }
335
477
  }
336
478
  if (toolName === "Bash") {
337
479
  const command = (toolInput.command || "").trim();
@@ -348,24 +490,20 @@ function handlePreToolUse(data) {
348
490
  }
349
491
  }
350
492
  if (COMPOUND_OPERATORS.test(command)) {
351
- const firstCmd = command.split(/\s*[|;&>]\s*/)[0].trim();
352
- for (const { regex, key } of BASH_REDIRECTS) {
353
- if (regex.test(firstCmd)) {
354
- const hint = TOOL_HINTS[key];
355
- const toolName2 = hint.split(" (")[0];
356
- redirect(`Use ${toolName2} instead of piped command`, hint);
493
+ const segments = splitCompoundCommand(command);
494
+ for (const [index, segment] of segments.entries()) {
495
+ const redirectInfo2 = getBashRedirect(segment, index === 0);
496
+ if (redirectInfo2) {
497
+ redirect(`Use ${redirectInfo2.toolName} instead of project file inspection pipeline`, redirectInfo2.hint);
357
498
  }
358
499
  }
359
500
  process.exit(0);
360
501
  }
361
- for (const { regex, key } of BASH_REDIRECTS) {
362
- if (regex.test(command)) {
363
- const hint = TOOL_HINTS[key];
364
- const toolName2 = hint.split(" (")[0];
365
- const args = command.split(/\s+/).slice(1).join(" ");
366
- const argsNote = args ? ` \u2014 args: "${args}"` : "";
367
- redirect(`Use ${toolName2}${argsNote}`, hint);
368
- }
502
+ const redirectInfo = getBashRedirect(command, true);
503
+ if (redirectInfo) {
504
+ const args = command.split(/\s+/).slice(1).join(" ");
505
+ const argsNote = args ? ` \u2014 args: "${args}"` : "";
506
+ redirect(`Use ${redirectInfo.toolName}${argsNote}`, redirectInfo.hint);
369
507
  }
370
508
  }
371
509
  process.exit(0);
@@ -423,9 +561,9 @@ function handlePostToolUse(data) {
423
561
  }
424
562
  function handleSessionStart() {
425
563
  const settingsFiles = [
426
- resolve2(process.cwd(), ".claude/settings.local.json"),
427
- resolve2(process.cwd(), ".claude/settings.json"),
428
- resolve2(homedir(), ".claude/settings.json")
564
+ resolve(process.cwd(), ".claude/settings.local.json"),
565
+ resolve(process.cwd(), ".claude/settings.json"),
566
+ resolve(homedir(), ".claude/settings.json")
429
567
  ];
430
568
  let styleActive = false;
431
569
  for (const f of settingsFiles) {
@@ -439,7 +577,7 @@ function handleSessionStart() {
439
577
  } catch {
440
578
  }
441
579
  }
442
- const msg = styleActive ? "Hex-line MCP available. Output style active.\n<hex-line_instructions>\n <deferred_loading>If hex-line schemas not loaded, run: ToolSearch('+hex-line read edit')</deferred_loading>\n <note>Follow the active hex-line output style for primary tool choices.</note>\n <exceptions>Built-in tools stay OK for images, PDFs, notebooks, Glob, .claude/settings.json, and .claude/settings.local.json.</exceptions>\n</hex-line_instructions>" : "Hex-line MCP available.\n<hex-line_instructions>\n <deferred_loading>If hex-line schemas not loaded, run: ToolSearch('+hex-line read edit')</deferred_loading>\n <exploration>\n <rule>Use outline for structure (code + markdown), not Read. ~10-20 lines vs hundreds.</rule>\n <rule>Use read_file with offset/limit or ranges for targeted reads.</rule>\n <rule>Use grep_search before editing to get hash anchors.</rule>\n </exploration>\n <editing>\n <path name='surgical'>grep_search \u2192 edit_file (fastest: hash-verified, no full read needed)</path>\n <path name='exploratory'>outline \u2192 read_file (ranges) \u2192 edit_file with base_revision</path>\n <path name='multi-file'>bulk_replace(path=&quot;&lt;project root&gt;&quot;) for text rename/refactor across files</path>\n </editing>\n <tips>\n <tip>Auto-fill path from the active file or project root. Read-only tools may inspect explicit temp-file paths outside the repo. Mutating tools stay project-scoped unless you intentionally pass allow_external=true.</tip>\n <tip>Never invent range_checksum. Copy it from fresh read_file or grep_search blocks.</tip>\n <tip>Prefer set_line or insert_after for small local changes and replace_between for larger bounded rewrites.</tip>\n <tip>Carry revision from read_file into base_revision on edit_file.</tip>\n <tip>If edit returns CONFLICT, call verify \u2014 only reread when STALE.</tip>\n <tip>Avoid large first-pass edit batches. Start with 1-2 hunks, then continue from the returned revision.</tip>\n <tip>Use write_file for new files (no prior Read needed).</tip>\n </tips>\n <exceptions>Built-in tools stay OK for images, PDFs, notebooks, Glob, .claude/settings.json, and .claude/settings.local.json.</exceptions>\n</hex-line_instructions>";
580
+ const msg = styleActive ? "Hex-line MCP available. Output style active.\n<hex-line_instructions>\n <deferred_loading>If hex-line schemas not loaded, run: ToolSearch('+hex-line read edit')</deferred_loading>\n <note>Follow the active hex-line output style for primary tool choices.</note>\n <exceptions>Built-in tools stay OK for images, PDFs, notebooks, and text paths outside the current project root.</exceptions>\n</hex-line_instructions>" : "Hex-line MCP available.\n<hex-line_instructions>\n <deferred_loading>If hex-line schemas not loaded, run: ToolSearch('+hex-line read edit')</deferred_loading>\n <exploration>\n <rule>Use outline for structure (code + markdown), not Read. ~10-20 lines vs hundreds.</rule>\n <rule>Use read_file with offset/limit or ranges for targeted reads.</rule>\n <rule>Use grep_search before editing to get hash anchors.</rule>\n </exploration>\n <editing>\n <path name='surgical'>grep_search \u2192 edit_file (fastest: hash-verified, no full read needed)</path>\n <path name='exploratory'>outline \u2192 read_file (ranges) \u2192 edit_file with base_revision</path>\n <path name='multi-file'>bulk_replace(path=&quot;&lt;project root&gt;&quot;) for text rename/refactor across files</path>\n </editing>\n <tips>\n <tip>Auto-fill path from the active file or project root. Read-only tools may inspect explicit temp-file paths outside the repo. Mutating tools stay project-scoped unless you intentionally pass allow_external=true.</tip>\n <tip>Never invent range_checksum. Copy it from fresh read_file or grep_search blocks.</tip>\n <tip>Prefer set_line or insert_after for small local changes and replace_between for larger bounded rewrites.</tip>\n <tip>Carry revision from read_file into base_revision on edit_file.</tip>\n <tip>If edit returns CONFLICT, call verify \u2014 only reread when STALE.</tip>\n <tip>Avoid large first-pass edit batches. Start with 1-2 hunks, then continue from the returned revision.</tip>\n <tip>Use write_file for new files (no prior Read needed).</tip>\n <tip>Use inspect_path(pattern=...) for project file discovery and name/path globbing.</tip>\n </tips>\n <exceptions>Built-in tools stay OK for images, PDFs, notebooks, and text paths outside the current project root.</exceptions>\n</hex-line_instructions>";
443
581
  safeExit(1, JSON.stringify({ systemMessage: msg }), 0);
444
582
  }
445
583
  var _norm = (p) => p.replace(/\\/g, "/");
package/dist/server.mjs CHANGED
@@ -1272,6 +1272,7 @@ function serializeDiagnosticBlock(block) {
1272
1272
  if (block.requestedStartLine !== null && block.requestedEndLine !== null) {
1273
1273
  lines.push(`requested_span: ${block.requestedStartLine}-${block.requestedEndLine}`);
1274
1274
  }
1275
+ lines.push(...renderMetaLines(block.meta));
1275
1276
  lines.push(`message: ${block.message}`);
1276
1277
  return lines.join("\n");
1277
1278
  }
@@ -2161,7 +2162,6 @@ function collectBatchConflicts({
2161
2162
  changedRanges,
2162
2163
  conflictPolicy,
2163
2164
  currentSnapshot,
2164
- filePath,
2165
2165
  hasBaseSnapshot,
2166
2166
  opts,
2167
2167
  staleRevision
@@ -2717,7 +2717,6 @@ ${snip.text}`;
2717
2717
  changedRanges,
2718
2718
  conflictPolicy,
2719
2719
  currentSnapshot,
2720
- filePath,
2721
2720
  hasBaseSnapshot,
2722
2721
  opts,
2723
2722
  staleRevision
@@ -2924,6 +2923,8 @@ try {
2924
2923
  var DEFAULT_LIMIT2 = 100;
2925
2924
  var DEFAULT_TOTAL_LIMIT_CONTENT = 200;
2926
2925
  var DEFAULT_TOTAL_LIMIT_LIST = 1e3;
2926
+ var DEFAULT_CONTENT_BLOCK_LIMIT = 12;
2927
+ var DEFAULT_CONTENT_OUTPUT_CHARS = 12e3;
2927
2928
  var MAX_OUTPUT = 10 * 1024 * 1024;
2928
2929
  var TIMEOUT2 = 3e4;
2929
2930
  var MAX_SEARCH_OUTPUT_CHARS = 8e4;
@@ -2964,15 +2965,16 @@ function spawnRg(args) {
2964
2965
  function grepSearch(pattern, opts = {}) {
2965
2966
  const normPath = normalizePath(opts.path || "");
2966
2967
  const target = normPath ? resolve2(normPath) : process.cwd();
2967
- const output = opts.output || "content";
2968
+ const output = opts.output || "summary";
2968
2969
  const plain = !!opts.plain;
2969
2970
  const editReady = !!opts.editReady;
2971
+ const allowLargeOutput = !!opts.allowLargeOutput;
2970
2972
  const defaultTotalLimit = output === "content" ? DEFAULT_TOTAL_LIMIT_CONTENT : DEFAULT_TOTAL_LIMIT_LIST;
2971
2973
  const totalLimit = opts.totalLimit === 0 ? 0 : opts.totalLimit && opts.totalLimit > 0 ? opts.totalLimit : defaultTotalLimit;
2972
2974
  if (output === "summary") return summaryMode(pattern, target, opts, totalLimit);
2973
2975
  if (output === "files") return filesMode(pattern, target, opts, totalLimit);
2974
2976
  if (output === "count") return countMode(pattern, target, opts, totalLimit);
2975
- return contentMode(pattern, target, opts, plain, editReady, totalLimit);
2977
+ return contentMode(pattern, target, opts, plain, editReady, totalLimit, allowLargeOutput);
2976
2978
  }
2977
2979
  function applyListModeTotalLimit(lines, totalLimit) {
2978
2980
  if (!totalLimit || totalLimit <= 0 || lines.length <= totalLimit) return lines.join("\n");
@@ -3054,10 +3056,24 @@ async function summaryMode(pattern, target, opts, totalLimit) {
3054
3056
  }
3055
3057
  return lines.join("\n");
3056
3058
  }
3057
- async function contentMode(pattern, target, opts, plain, editReady, totalLimit) {
3059
+ function buildSearchRefineCall(target, pattern, opts) {
3060
+ const args = { path: String(target).replace(/\\/g, "/"), pattern };
3061
+ if (opts.glob) args.glob = opts.glob;
3062
+ if (opts.type) args.type = opts.type;
3063
+ return JSON.stringify({
3064
+ tool: "mcp__hex_line__grep_search",
3065
+ arguments: {
3066
+ ...args,
3067
+ output: "summary"
3068
+ }
3069
+ });
3070
+ }
3071
+ async function contentMode(pattern, target, opts, plain, editReady, totalLimit, allowLargeOutput) {
3058
3072
  const realArgs = ["--json"];
3059
3073
  const plainOutput = plain || !editReady;
3060
3074
  const shouldUseGraph = editReady && !plain;
3075
+ const contentBlockLimit = allowLargeOutput ? Number.POSITIVE_INFINITY : DEFAULT_CONTENT_BLOCK_LIMIT;
3076
+ const outputCharBudget = allowLargeOutput ? MAX_SEARCH_OUTPUT_CHARS : DEFAULT_CONTENT_OUTPUT_CHARS;
3061
3077
  if (opts.caseInsensitive) realArgs.push("-i");
3062
3078
  else if (opts.smartCase) realArgs.push("-S");
3063
3079
  if (opts.literal) realArgs.push("-F");
@@ -3079,6 +3095,7 @@ async function contentMode(pattern, target, opts, plain, editReady, totalLimit)
3079
3095
  const db = shouldUseGraph ? getGraphDB(target) : null;
3080
3096
  const relCache = /* @__PURE__ */ new Map();
3081
3097
  let annotationBudget = GRAPH_MATCH_ANNOTATION_BUDGET;
3098
+ const matchedFiles = /* @__PURE__ */ new Set();
3082
3099
  let groupFile = null;
3083
3100
  let groupEntries = [];
3084
3101
  let matchCount = 0;
@@ -3123,6 +3140,7 @@ async function contentMode(pattern, target, opts, plain, editReady, totalLimit)
3123
3140
  const filePath = (data.path?.text || "").replace(/\\/g, "/");
3124
3141
  const lineNum = data.line_number;
3125
3142
  if (!lineNum) continue;
3143
+ if (msg.type === "match") matchedFiles.add(filePath);
3126
3144
  let content = data.lines?.text;
3127
3145
  if (content === void 0 && data.lines?.bytes) {
3128
3146
  content = Buffer.from(data.lines.bytes, "base64").toString("utf-8");
@@ -3172,6 +3190,15 @@ async function contentMode(pattern, target, opts, plain, editReady, totalLimit)
3172
3190
  flushGroup();
3173
3191
  blocks.push(buildDiagnosticBlock({
3174
3192
  kind: "total_limit",
3193
+ meta: {
3194
+ total_matches: matchCount,
3195
+ shown_matches: matchCount,
3196
+ file_count: matchedFiles.size,
3197
+ shown_count: blocks.filter((block) => block.type === "edit_ready_block").length,
3198
+ truncated: true,
3199
+ next_action: "narrow_search_scope",
3200
+ suggested_refine_call: buildSearchRefineCall(target, pattern, opts)
3201
+ },
3175
3202
  message: `Search stopped after ${totalLimit} match event(s). Narrow the query, raise total_limit, or pass total_limit=0 to disable the cap.`,
3176
3203
  path: String(target).replace(/\\/g, "/")
3177
3204
  }));
@@ -3181,10 +3208,18 @@ async function contentMode(pattern, target, opts, plain, editReady, totalLimit)
3181
3208
  }
3182
3209
  flushGroup();
3183
3210
  if (db) blocks.sort((a, b) => (b.meta.graphScore || 0) - (a.meta.graphScore || 0));
3211
+ const searchBlocks = blocks.filter((block) => block.type === "edit_ready_block");
3212
+ const totalSearchBlocks = searchBlocks.length;
3184
3213
  const parts = [];
3185
- let budget = MAX_SEARCH_OUTPUT_CHARS;
3214
+ let budget = outputCharBudget;
3186
3215
  let capped = false;
3216
+ let shownBlocks = 0;
3217
+ let shownMatches = 0;
3187
3218
  for (const block of blocks) {
3219
+ if (block.type === "edit_ready_block" && shownBlocks >= contentBlockLimit) {
3220
+ capped = true;
3221
+ break;
3222
+ }
3188
3223
  const serialized = block.type === "edit_ready_block" ? serializeSearchBlock(block, { plain: plainOutput }) : serializeDiagnosticBlock(block);
3189
3224
  if (parts.length > 0 && budget - serialized.length < 0) {
3190
3225
  capped = true;
@@ -3192,12 +3227,26 @@ async function contentMode(pattern, target, opts, plain, editReady, totalLimit)
3192
3227
  }
3193
3228
  parts.push(serialized);
3194
3229
  budget -= serialized.length;
3230
+ if (block.type === "edit_ready_block") {
3231
+ shownBlocks++;
3232
+ shownMatches += Array.isArray(block.meta.matchLines) ? block.meta.matchLines.length : 0;
3233
+ }
3195
3234
  }
3196
3235
  if (capped) {
3197
- const remaining = blocks.length - parts.length;
3236
+ const remaining = Math.max(0, totalSearchBlocks - shownBlocks);
3198
3237
  parts.push(serializeDiagnosticBlock(buildDiagnosticBlock({
3199
3238
  kind: "output_capped",
3200
- message: `OUTPUT_CAPPED: ${remaining} more search block(s) omitted (${MAX_SEARCH_OUTPUT_CHARS} char limit). Narrow with path= or glob= filters.`
3239
+ path: String(target).replace(/\\/g, "/"),
3240
+ meta: {
3241
+ total_matches: matchCount,
3242
+ shown_matches: shownMatches,
3243
+ file_count: matchedFiles.size,
3244
+ shown_count: shownBlocks,
3245
+ truncated: true,
3246
+ next_action: "narrow_search_scope",
3247
+ suggested_refine_call: buildSearchRefineCall(target, pattern, opts)
3248
+ },
3249
+ message: `OUTPUT_CAPPED: ${remaining} more search block(s) omitted (${outputCharBudget} char limit${allowLargeOutput ? "" : `, ${DEFAULT_CONTENT_BLOCK_LIMIT} block default`}). Narrow with path= or glob= filters or pass allow_large_output=true when you intentionally need a larger payload.`
3201
3250
  })));
3202
3251
  }
3203
3252
  return parts.join("\n\n");
@@ -3657,10 +3706,30 @@ function isIgnored(ig, relPath, isDir) {
3657
3706
  if (!ig) return false;
3658
3707
  return ig.ignores(isDir ? relPath + "/" : relPath);
3659
3708
  }
3709
+ function topPatternGroups(matches) {
3710
+ const groups = /* @__PURE__ */ new Map();
3711
+ for (const entry of matches) {
3712
+ const trimmed = entry.endsWith("/") ? entry.slice(0, -1) : entry;
3713
+ const [head] = trimmed.split("/");
3714
+ const key = trimmed.includes("/") ? head : ".";
3715
+ groups.set(key, (groups.get(key) || 0) + 1);
3716
+ }
3717
+ return [...groups.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 4);
3718
+ }
3719
+ function buildPatternRefineCall(absRoot, pattern, type, groups) {
3720
+ const bestGroup = groups.find(([key]) => key !== ".")?.[0];
3721
+ const args = { path: bestGroup ? join4(absRoot, bestGroup) : absRoot, pattern };
3722
+ if (type && type !== "all") args.type = type;
3723
+ return JSON.stringify({
3724
+ tool: "mcp__hex_line__inspect_path",
3725
+ arguments: args
3726
+ });
3727
+ }
3660
3728
  function findByPattern(dirPath, opts) {
3661
3729
  const re = globToRegex(opts.pattern);
3662
3730
  const filterType = opts.type || "all";
3663
3731
  const maxDepth = opts.max_depth ?? 20;
3732
+ const maxEntries = opts.max_entries === 0 ? 0 : Math.max(1, Number(opts.max_entries ?? 60));
3664
3733
  const abs = resolve4(normalizePath(dirPath));
3665
3734
  if (!existsSync5(abs)) throw new Error(`DIRECTORY_NOT_FOUND: ${abs}`);
3666
3735
  if (!statSync7(abs).isDirectory()) throw new Error(`Not a directory: ${abs}`);
@@ -3694,9 +3763,25 @@ function findByPattern(dirPath, opts) {
3694
3763
  if (matches.length === 0) {
3695
3764
  return `No matches for "${opts.pattern}" in ${rootName}/`;
3696
3765
  }
3697
- return `Found ${matches.length} match${matches.length === 1 ? "" : "es"} for "${opts.pattern}" in ${rootName}/
3698
-
3699
- ${matches.join("\n")}`;
3766
+ const shown = maxEntries === 0 ? matches : matches.slice(0, maxEntries);
3767
+ const truncated = shown.length < matches.length;
3768
+ const groups = topPatternGroups(matches);
3769
+ const lines = [
3770
+ `Found ${matches.length} match${matches.length === 1 ? "" : "es"} for "${opts.pattern}" in ${rootName}/`,
3771
+ `match_count: ${matches.length}`,
3772
+ `shown_count: ${shown.length}`,
3773
+ `truncated: ${truncated}`
3774
+ ];
3775
+ if (groups.length > 0) {
3776
+ lines.push(`top_groups: ${groups.map(([group, count]) => `${group} (${count})`).join(", ")}`);
3777
+ }
3778
+ if (truncated) {
3779
+ lines.push("next_action: narrow_path");
3780
+ lines.push(`suggested_refine_call: ${buildPatternRefineCall(abs, opts.pattern, filterType, groups)}`);
3781
+ }
3782
+ lines.push("");
3783
+ lines.push(...shown);
3784
+ return lines.join("\n");
3700
3785
  }
3701
3786
  function directoryTree(dirPath, opts = {}) {
3702
3787
  if (opts.pattern) return findByPattern(dirPath, opts);
@@ -4638,7 +4723,7 @@ OUTPUT_CAPPED: Output exceeded ${MAX_BULK_OUTPUT_CHARS} chars.`;
4638
4723
  }
4639
4724
 
4640
4725
  // server.mjs
4641
- var version = true ? "1.16.0" : (await null).createRequire(import.meta.url)("./package.json").version;
4726
+ var version = true ? "1.17.1" : (await null).createRequire(import.meta.url)("./package.json").version;
4642
4727
  var { server, StdioServerTransport } = await createServerRuntime({
4643
4728
  name: "hex-line-mcp",
4644
4729
  version
@@ -4801,7 +4886,8 @@ server.registerTool("grep_search", {
4801
4886
  limit: flexNum().describe("Max matches per file (default: 20 for summary discovery, 100 for content)"),
4802
4887
  total_limit: flexNum().describe("Total match events across all files; multiline matches count as 1 (default: 50 for summary discovery, 200 for content, 1000 for files/count, 0 = unlimited)"),
4803
4888
  plain: flexBool().describe("Omit hash tags, return file:line:content"),
4804
- edit_ready: flexBool().describe("Preserve hash/checksum search hunks in `content` mode. Default: false.")
4889
+ edit_ready: flexBool().describe("Preserve hash/checksum search hunks in `content` mode. Default: false."),
4890
+ allow_large_output: flexBool().describe("Bypass the default content-mode block/char caps when you intentionally need a larger payload.")
4805
4891
  }),
4806
4892
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }
4807
4893
  }, async (rawParams) => {
@@ -4821,7 +4907,8 @@ server.registerTool("grep_search", {
4821
4907
  limit,
4822
4908
  total_limit,
4823
4909
  plain,
4824
- edit_ready
4910
+ edit_ready,
4911
+ allow_large_output
4825
4912
  } = rawParams ?? {};
4826
4913
  try {
4827
4914
  const resolvedOutput = output ?? "summary";
@@ -4842,7 +4929,8 @@ server.registerTool("grep_search", {
4842
4929
  limit: resolvedLimit,
4843
4930
  totalLimit: resolvedTotalLimit,
4844
4931
  plain,
4845
- editReady: !!edit_ready
4932
+ editReady: !!edit_ready,
4933
+ allowLargeOutput: !!allow_large_output
4846
4934
  });
4847
4935
  return { content: [{ type: "text", text: result }] };
4848
4936
  } catch (e) {
@@ -4893,13 +4981,14 @@ server.registerTool("inspect_path", {
4893
4981
  pattern: z2.string().optional().describe('Glob filter on names (e.g. "*-mcp", "*.mjs"). Returns flat match list instead of tree'),
4894
4982
  type: z2.enum(["file", "dir", "all"]).optional().describe('"file", "dir", or "all" (default). Like find -type f/d'),
4895
4983
  max_depth: flexNum().describe("Max recursion depth (default: 2 for discovery, or 20 in pattern mode)"),
4984
+ max_entries: flexNum().describe("Max entries to show in pattern mode before truncation metadata is returned (default: 60, 0 = unlimited)"),
4896
4985
  gitignore: flexBool().describe("Respect root .gitignore patterns (default: true). Nested .gitignore not supported"),
4897
4986
  format: z2.enum(["compact", "full"]).optional().describe('"compact" = shorter path view, "full" = include sizes/metadata where available'),
4898
4987
  verbosity: z2.enum(["minimal", "compact", "full"]).optional().describe("Response budget. `minimal` returns the shortest tree summary.")
4899
4988
  }),
4900
4989
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }
4901
4990
  }, async (rawParams) => {
4902
- const { path: p, max_depth, gitignore, format, pattern, type: entryType, verbosity } = rawParams ?? {};
4991
+ const { path: p, max_depth, max_entries, gitignore, format, pattern, type: entryType, verbosity } = rawParams ?? {};
4903
4992
  try {
4904
4993
  const resolvedVerbosity = verbosity ?? "minimal";
4905
4994
  return { content: [{ type: "text", text: inspectPath(p, {
@@ -4907,7 +4996,8 @@ server.registerTool("inspect_path", {
4907
4996
  gitignore,
4908
4997
  format: format ?? (resolvedVerbosity === "full" ? "full" : "compact"),
4909
4998
  pattern,
4910
- type: entryType
4999
+ type: entryType,
5000
+ max_entries
4911
5001
  }) }] };
4912
5002
  } catch (e) {
4913
5003
  return { content: [{ type: "text", text: e.message }], isError: true };
package/output-style.md CHANGED
@@ -13,7 +13,8 @@ Prefer `hex-line` for text files you may inspect or modify. Hash-annotated reads
13
13
  | Read | `mcp__hex-line__read_file` | Hash-annotated, revision-aware |
14
14
  | Edit | `mcp__hex-line__edit_file` | Hash-verified anchors + conservative auto-rebase |
15
15
  | Write | `mcp__hex-line__write_file` | No prior Read needed |
16
- | Grep | `mcp__hex-line__grep_search` | Edit-ready matches |
16
+ | Grep | `mcp__hex-line__grep_search` | Summary-first discovery with edit-ready escalation |
17
+ | Glob | `mcp__hex-line__inspect_path` | Project file discovery and name/path globbing |
17
18
  | Text rename across files | `mcp__hex-line__bulk_replace` | Multi-file text rename/refactor inside an explicit root path |
18
19
  | Path/tree/stat Bash | `mcp__hex-line__inspect_path` | Compact path info and pattern search |
19
20
  | Large code read | `mcp__hex-line__outline` then `read_file` with ranges | Structure first, targeted reads |
@@ -26,7 +27,7 @@ Prefer `hex-line` for text files you may inspect or modify. Hash-annotated reads
26
27
 
27
28
  | Path | Flow |
28
29
  |------|------|
29
- | Surgical | `grep_search -> edit_file` |
30
+ | Surgical | `grep_search(output="summary") -> grep_search(output="content", edit_ready=true) if needed -> edit_file` |
30
31
  | Exploratory | `outline -> read_file (ranges) -> edit_file(base_revision)` |
31
32
  | Multi-file | `bulk_replace(path=<project root>)` |
32
33
  | Follow-up after delay | `verify(base_revision) -> reread only if STALE -> retry with returned helpers` |
@@ -36,14 +37,14 @@ Prefer `hex-line` for text files you may inspect or modify. Hash-annotated reads
36
37
  - Auto-fill `path` instead of leaving scope implicit.
37
38
  - For file tools (`read_file`, `edit_file`, `outline`, `changes` on one file), use the target file path.
38
39
  - Read-only file tools may target explicit temp-file paths outside the repo when you intentionally inspect a scratch file.
39
- - For repo-wide tools (`bulk_replace`, directory `inspect_path`, broad `grep_search`), use the resolved project root or intended directory scope.
40
+ - For repo-wide tools (`bulk_replace`, directory `inspect_path`, broad `grep_search`), use the resolved project root or intended directory scope, then narrow further before requesting rich output.
40
41
  - Mutating tools stay inside the current project root by default. Add `allow_external=true` only when you intentionally edit a temp or external path.
41
42
  - Treat missing or ambiguous scope as an error to fix, not as a reason to guess across repositories.
42
43
 
43
44
  ## Edit Discipline
44
45
 
45
- - Never invent `range_checksum`. Copy it from a fresh `read_file` or `grep_search(output:"content")` block.
46
- - First mutation in a file: use `grep_search` for narrow targets, or `outline -> read_file(ranges)` for structural edits.
46
+ - Never invent `range_checksum`. Copy it from a fresh `read_file` or `grep_search(output:"content", edit_ready=true)` block.
47
+ - First mutation in a file: use `grep_search(output="summary")` for narrow targets, or `outline -> read_file(ranges)` for structural edits. Escalate to `grep_search(output="content", edit_ready=true)` only when the next edit needs canonical hunks.
47
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.
48
49
  - Prefer `set_line` or `insert_after` for small local changes. Prefer `replace_between` for larger bounded block rewrites.
49
50
  - Use `replace_lines` only when you already hold the exact inclusive range checksum for that block.
@@ -53,11 +54,12 @@ Prefer `hex-line` for text files you may inspect or modify. Hash-annotated reads
53
54
  - Reuse `retry_checksum` when it is returned for the exact same target range.
54
55
  - Once `hex-line` owns a file edit session, avoid mixing built-in `Edit`/`Write` on that file unless you intentionally want a new baseline.
55
56
  - Follow `next_action` first. Treat `summary` and `snippet` as the compact local context, not as prose to reinterpret.
57
+ - If broad `grep_search(output="content")` or pattern `inspect_path` truncates, narrow `path`, `glob`, or query shape before retrying. Use `allow_large_output=true` only when you intentionally accept a larger payload.
56
58
 
57
59
  ## Exceptions
58
60
 
59
- - Built-in `Read`/`Edit`/`Write`/`Grep` are fallback only. Built-in OK for images, PDFs, notebooks, Glob, `.claude/settings.json`, and `.claude/settings.local.json`.
60
- - Bash is still fine for npm, node, git, docker, curl, pipes, and compound commands.
61
+ - Built-in `Read`/`Edit`/`Write`/`Grep`/`Glob` are fallback only. With the hook active, project-scoped text calls and file discovery route to hex-line. Built-in OK for images, PDFs, notebooks, and text paths outside the current project root.
62
+ - Bash is still fine for npm, node, git, docker, curl, non-inspection pipelines, and other runtime workflows. Project file inspection commands route to hex-line, including Windows-native readers/searchers/listing commands.
61
63
 
62
64
  ## hex-graph
63
65
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@levnikolaevich/hex-line-mcp",
3
- "version": "1.16.0",
3
+ "version": "1.17.1",
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.",