SLOPSHOPPER

lsp-first

LSP-first guards: refuses shell greps on code symbols and shell writes to source files, retries cclsp's cold start, folds the refusals to one line

newrowsguard
v0.1.0no licenseupdated 2026-10-09djavrell/dotfile/claude/mods/lsp-first
A shopper browsing a rack in a slop shop
README

lsp-first

Keeps Claude on the LSP for code navigation. It refuses a shell grep on a code symbol (pointing to the LSP call instead) and a shell write to a source file (pointing to Edit or Write), quietly retries the LSP server when it is not warmed up yet, and shows each refusal as a single dim line. It replaces the old classic hooks in ~/.claude/hooks/ (unwired; saved in claude/hooks-lsp-first.disabled.json).

Example

CommandVerdict
grep -rn "buildAgentTree" src/refused
grep -n "flexShrink" types/index.d.tsallowed (typings)
cat > src/app.ts <<'TS'refused (shell write to src/app.ts)
echo x > /tmp/scratch.tsallowed (scratch path)

The transcript shows a refusal as one dim line:

⛔ LSP-FIRST: grep on code symbol(s) buildAgentTree: ask the LSP instead, mcp__cclsp__find_workspace_symbols("buildAgentTree") then …
Source 5 files
hooks/register.tsx 87 lines
1// LSP-first guards, as a mod. Four hooks:
2//   1. a Bash command that greps for a code symbol, or writes a source file, is refused (lib/grep.ts, lib/write.ts);
3//   2. Read calls are noted, so cclsp can be primed on a file the session already opened;
4//   3. a cclsp call that hit the server's cold start ("No Project") is primed and run again, transparently;
5//   4. every LSP-first refusal, this mod's and the classic hooks', is drawn as one dim line in the transcript.
6// Plugin hooks run above the classic PreToolUse hooks, so this mod judges a call first.
7// $ stays in this file (the validator does not follow it across an import); the judging is pure, in lib/.
8import type { EngineInterface, McpToolResult, Register } from "claude-code";
9
10import { compactDenial, grepDenial, writeDenial } from "./lib/denials";
11import { grepSymbols } from "./lib/grep";
12import { shellWriteTarget } from "./lib/write";
13
14const CCLSP_SERVER = "cclsp";
15const CCLSP_TOOL_PREFIX = `mcp__${CCLSP_SERVER}__`;
16// what cclsp answers until a file-scoped call has primed its project (ktnyt/cclsp#43)
17const COLD_START_ERROR = /No Project\.|ThrowNoProject|Server not initialized|Project not loaded|LSP server.*not ready/i;
18const SOURCE_FILE = /\.(?:ts|tsx|js|jsx|mjs|cjs|py)$/i;
19// the fields of a tool.call event that are no argument of the MCP tool itself
20const EVENT_ENVELOPE_FIELDS = new Set(["tool", "tool_use_id", "agentId", "requestMeta"]);
21
22// the last source file Read opened: cclsp is primed on it when the failed call names no file of its own
23let lastSourceFileRead = "";
24
25const textOf = (mcpResult: McpToolResult) =>
26  mcpResult.content.map((block) => ("text" in block && typeof block.text === "string" ? block.text : "")).join("\n");
27
28// Primes cclsp with a file-scoped call (get_diagnostics), then makes the failed call again.
29// Resolves the new result, or null when there is no file to prime with or the call still fails.
30async function primeAndRetry($: EngineInterface, toolName: string, toolArguments: Record<string, unknown>) {
31  const primingFile = typeof toolArguments.file_path === "string" ? toolArguments.file_path : lastSourceFileRead;
32  if (!primingFile) return null;
33  await $.mcp.call(CCLSP_SERVER, "get_diagnostics", { file_path: primingFile });
34  const retried = await $.mcp.call(CCLSP_SERVER, toolName, toolArguments);
35  return retried.isError || COLD_START_ERROR.test(textOf(retried)) ? null : retried;
36}
37
38export const register: Register = (on) => {
39  // 1. No .catch on purpose: a guard that throws lets the command through (the engine says so in a dim line),
40  //    rather than refusing every shell command until the bug is fixed.
41  on("tool.call", { tool: "Bash" }, ($, event, next) => {
42    const symbols = grepSymbols(event.command);
43    if (symbols.length > 0) return { deny: grepDenial(symbols) };
44
45    const writtenSourceFile = shellWriteTarget(event.command);
46    if (writtenSourceFile) return { deny: writeDenial(writtenSourceFile) };
47
48    return next(event);
49  });
50
51  // 2.
52  on("tool.call", { tool: "Read" }, ($, event, next) => {
53    if (SOURCE_FILE.test(event.file_path)) lastSourceFileRead = event.file_path;
54
55    return next(event);
56  });
57
58  // 3.
59  on("tool.call", async ($, event, next) => {
60    const ran = await next(event);
61    const isCclspCall = event.tool.startsWith(CCLSP_TOOL_PREFIX);
62    if (!isCclspCall || ran.deny !== undefined || !COLD_START_ERROR.test(ran.text ?? "")) return ran;
63
64    const toolArguments = Object.fromEntries(
65      Object.entries(event).filter(([field]) => !EVENT_ENVELOPE_FIELDS.has(field)),
66    );
67    const toolName = event.tool.slice(CCLSP_TOOL_PREFIX.length);
68    const retried = await primeAndRetry($, toolName, toolArguments).catch(() => null);
69    // ponytail: answers with the retry's content blocks; check live how the transcript draws such an MCP result
70    return retried ? { result: retried.content } : ran;
71  });
72
73  // 4.
74  on("ui.render", { component: "ToolResult" }, ($, event, next) => {
75    const { isErrored, output } = event.props;
76    const denialLine = isErrored && typeof output === "string" ? compactDenial(output) : null;
77    if (!denialLine) return next(event);
78    const { Text } = $.ui.resolve(event);
79
80    return (
81      <Text dimColor wrap="truncate-end">
82        {denialLine}
83      </Text>
84    );
85  });
86};
87
hooks/lib/denials.ts 28 lines
1// What the model reads when a guard refuses, and the one line the transcript shows in its place.
2
3// Starts every refusal of this mod, as it started the classic hooks' ones.
4const DENY_MARKER = "LSP-FIRST:";
5
6export const grepDenial = (symbols: readonly string[]) =>
7  `${DENY_MARKER} grep on code symbol(s) ${symbols.join(", ")}: ask the LSP instead, ` +
8  `mcp__cclsp__find_workspace_symbols("${symbols[0]}") then find_references from its definition. ` +
9  "Allowed: typings (.d.ts), one known file, regex patterns, -c, -A/-B/-C.";
10
11export const writeDenial = (target: string) =>
12  `${DENY_MARKER} writing the source file ${target} from the shell: use Edit (Read it first) or Write, ` +
13  "which show a diff and fail loudly on a missing anchor. Scratch paths (scratchpad/, /tmp) are exempt.";
14
15// The classic hooks prefixed their refusals: `PreToolUse:Bash hook error: [node ~/.claude/hooks/x.js]: ⛔ ...`.
16const CLASSIC_HOOK_PREFIX = /^[^]*?(?=⛔|LSP-FIRST|TOOL-CHOICE)/;
17
18// An LSP-first refusal (this mod's, or a classic hook's: LSP-FIRST or TOOL-CHOICE) as its first line, for the
19// transcript; null for any other error, which keeps its own display.
20export function compactDenial(errorText: string): string | null {
21  if (!/LSP-FIRST|TOOL-CHOICE/.test(errorText)) return null;
22  const lines = errorText
23    .replace(CLASSIC_HOOK_PREFIX, "")
24    .split("\n")
25    .map((line) => line.replace(/^⛔\s*/, "").trim());
26  return `⛔ ${lines.find(Boolean) ?? ""}`;
27}
28
hooks/lib/grep.ts 170 lines
1// Shell greps that look up a code symbol. An LSP lookup answers those better (the exact definition, the real
2// references), so the guard refuses them and names the LSP call to make instead.
3// Ported from the classic hook bash-grep-block.js.
4import { cleanCommand } from "./command";
5
6const SEARCH_PROGRAM = /\b(grep|rg|ag|ack)\b/i;
7
8// ── exemptions ───────────────────────────────────────────────────────────────
9// A grep that is no symbol lookup, or one no LSP call can answer, runs untouched.
10
11type Exemption = { reason: string; applies: (command: string) => boolean };
12
13// Programs whose output a piped grep filters: that text is no code an LSP knows.
14const OUTPUT_PRODUCER =
15  /^(?:npm|npx|pnpm|yarn|node|deno|bun|git|docker|make|kubectl|tsc|eslint|jest|vitest|ps|env|printenv|ls|find|wc|sort|uniq|head|tail|curl|dig)\b/i;
16
17// `npm test | grep FailedTest`: the grep reads a program's output, not files.
18function filtersProgramOutput(command: string) {
19  const pipeIndex = command.indexOf("|");
20  const searchIndex = command.search(SEARCH_PROGRAM);
21  const grepComesAfterPipe = pipeIndex !== -1 && pipeIndex < searchIndex;
22  // with xargs or -exec the grep reads files again, whatever comes before the pipe
23  if (!grepComesAfterPipe || /\bxargs\b|-exec\b/.test(command)) return false;
24  const producer = command
25    .slice(0, pipeIndex)
26    .trim()
27    .replace(/^\S*=\S*\s+/, ""); // a leading `VAR=value` is no program
28  return OUTPUT_PRODUCER.test(producer);
29}
30
31const RECURSIVE_FLAG = /\s-\w*[rR]\w*\b|--recursive|--include/i;
32const SOURCE_FILE_ARGUMENT =
33  /(?:^|\s)['"]?[\w./@~-]+\.(?:ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|swift|vue|svelte)['"]?(?=\s|$|\||;)/gi;
34
35// `grep -n "UserService" user.service.ts`: "where in this file" is cheaper by grep than by any LSP call.
36function searchesOneKnownFile(command: string) {
37  if (RECURSIVE_FLAG.test(command)) return false;
38  return (command.match(SOURCE_FILE_ARGUMENT) ?? []).length === 1;
39}
40
41// Paths that point at code: a search naming one is a search of code.
42const CODE_PATH = /\bsrc[\\/]|\bapp[\\/]|components[\\/]|lib[\\/]|hooks[\\/]|utils[\\/]|services[\\/]|actions[\\/]/i;
43const CODE_SEARCH_HINTS = [
44  CODE_PATH,
45  /\.tsx?\b|\.jsx?\b/i,
46  /-t\s+(ts|tsx|js|jsx|typescript|javascript)\b/i,
47  /--type[= ](ts|tsx|js|jsx|typescript)\b/i,
48  /\bfind\b.*\b(src|app|components|lib)\b/,
49  /\bxargs\b.*\b(grep|rg|ag|ack)\b/i,
50  /-exec\s+(grep|rg|ag|ack)\b/i,
51];
52const NON_CODE_FILE = /\.(sql|md|json|yaml|yml|txt|env|sh|css|scss|log|toml|xml)\b/i;
53
54// `grep -r "UserService" docs/notes.md`: only non-code files are named, and nothing points at code.
55function searchesOnlyNonCodeFiles(command: string) {
56  const pointsAtCode = CODE_SEARCH_HINTS.some((hint) => hint.test(command));
57  return NON_CODE_FILE.test(command) && !pointsAtCode;
58}
59
60const GREP_EXEMPTIONS: Exemption[] = [
61  {
62    reason: "git grep: a deliberate search of the git index",
63    applies: (command) => /\bgit\s+grep\b/i.test(command),
64  },
65  {
66    reason: "typings (.d.ts): no LSP index holds their members (csstype's flexShrink), grep is the only way in",
67    applies: (command) => /\.d\.ts\b/.test(command),
68  },
69  {
70    reason: "-v drops lines: nobody looks for a definition by excluding it",
71    applies: (command) => /\b(?:grep|rg|ag|ack)\s+(?:-\w*v\w*|--invert-match)\b/i.test(command),
72  },
73  {
74    reason: "a filter on another program's output, not a search of files",
75    applies: filtersProgramOutput,
76  },
77  {
78    reason: "a tree that holds no indexed code (node_modules, migrations, .claude, notes)",
79    applies: (command) =>
80      // the tree name starts an argument (after a space or a quote) or a path segment (after a slash)
81      /(?:^|[\s'"/\\])(?:supabase[/\\]migrations|\.task|\.claude|node_modules|knowledge-vault)(?:[/\\]|$)/i.test(command),
82  },
83  {
84    reason: "--include of non-code files only",
85    applies: (command) => /--include=?\S*\.(sql|md|json|yaml|yml|txt|env|sh|css|scss|log)\b/i.test(command),
86  },
87  {
88    reason: "one known file: grep finds the line cheaper than any LSP call",
89    applies: searchesOneKnownFile,
90  },
91  {
92    reason: "-c counts occurrences: LSP has no count",
93    applies: (command) => /\b(?:grep|rg|ag|ack)\s+(?:-\w*c\w*|--count)\b/i.test(command),
94  },
95  {
96    reason: "-A/-B/-C asks for the lines around: LSP answers positions only",
97    applies: (command) => /\s-[ABC]\s?\d|--(?:after|before)-context\b|--context\b/i.test(command),
98  },
99  {
100    reason: "only non-code files are searched",
101    applies: searchesOnlyNonCodeFiles,
102  },
103];
104
105// ── the pattern and its symbols ──────────────────────────────────────────────
106
107// The searched pattern: double-quoted, single-quoted, or a bare PascalCase word.
108function searchPattern(command: string) {
109  const unescaped = command.replace(/\\"/g, '"');
110  const doubleQuoted = unescaped.match(/\b(?:grep|rg|ag|ack)\s+(?:-\S+\s+)*"([^"]+)"/i);
111  const singleQuoted = unescaped.match(/\b(?:grep|rg|ag|ack)\s+(?:-\S+\s+)*'([^']+)'/i);
112  const bareWord = unescaped.match(/\b(?:grep|rg|ag|ack)\s+(?:(?:-\w+\s+(?:[a-z]+\s+)?)*?)([A-Z][a-zA-Z]\w+)/i);
113  return (doubleQuoted ?? singleQuoted ?? bareWord)?.[1];
114}
115
116// `build.*Tree`, `Optional?`: an unescaped wildcard or quantifier makes a regex, and LSP looks up exact names only.
117// An escaped dot (`UserService\.`) is still a name; `\\` pairs are dropped first so `\\.` reads as an escape.
118const isRegex = (pattern: string) => /(?<!\\)[.?*+[]/.test(pattern.replace(/\\\\/g, ""));
119
120// Words that look like identifiers but are none.
121const NOT_SYMBOLS = [
122  /^(TODO|FIXME|HACK|XXX|NOTE)/i,
123  /^console\b/,
124  /^import\b/,
125  /^export\b/,
126  /^http/i,
127  /^\d/,
128  /^[A-Z_]{3,}$/, // a CONSTANT or an env var
129  /^[a-z]{1,8}$/, // a short plain word
130  /^[a-z]+-[a-z]+/, // kebab-case: a file name or a CSS class
131];
132
133function isSymbol(word: string, searchesPython: boolean) {
134  if (word.length < 4 || /\s/.test(word)) return false;
135  if (NOT_SYMBOLS.some((notSymbol) => notSymbol.test(word))) return false;
136  const isCamelCase = /^[a-z][a-zA-Z0-9]{3,}$/.test(word) && /[A-Z]/.test(word);
137  const isPascalCase = /^[A-Z][a-zA-Z][a-zA-Z0-9]{2,}$/.test(word);
138  // snake_case is a real symbol in Python only; in TypeScript it is a table or a column, which no LSP indexes
139  const isSnakeCase = searchesPython && /^[a-z]+(_[a-z]+){2,}$/.test(word) && word.length >= 9;
140  return isCamelCase || isPascalCase || isSnakeCase;
141}
142
143// The identifiers among a pattern's alternatives: `UserService|createOrder` gives both.
144function symbolsOf(pattern: string, searchesPython: boolean) {
145  return pattern
146    .split(/\\?\||\./) // alternatives, and each side of `a.b`
147    .map((word) =>
148      word
149        .replace(/\\[a-zA-Z]/g, " ") // an escape goes whole: `Dao\b` must not read as the PascalCase `Daob`
150        .replace(/[*+?^${}()[\]\\]/g, "")
151        .trim(),
152    )
153    .filter((word) => isSymbol(word, searchesPython));
154}
155
156// ── the verdict ──────────────────────────────────────────────────────────────
157
158// The code symbols a shell grep looks up, which the guard refuses; none when the command is no such lookup.
159export function grepSymbols(rawCommand: string): string[] {
160  const command = cleanCommand(rawCommand);
161  if (!SEARCH_PROGRAM.test(command)) return [];
162  if (GREP_EXEMPTIONS.some((exemption) => exemption.applies(command))) return [];
163
164  const pattern = searchPattern(command);
165  if (!pattern || isRegex(pattern)) return [];
166
167  const searchesPython = /\.py\b|--include=?\S*\.py\b|-t\s*py\b/i.test(command);
168  return symbolsOf(pattern, searchesPython);
169}
170
hooks/lib/write.ts 101 lines
1// Shell commands that write a source file: a redirect, tee, sed -i, or a script's own write call. Edit and Write
2// show the change as a diff and fail loudly on a missing anchor; a heredoc or a script's write_text() does neither.
3// Ported from the write half of the classic hook bash-file-read-block.js. Its read half (`cat`/`sed -n` on a source
4// file) is dropped on purpose: auto mode asks for those commands.
5import { EXTENSION_END, SOURCE_EXTENSION, cleanCommand } from "./command";
6
7// Where a write is no edit of the project: scratch space, VCS internals, build output.
8const SCRATCH_OR_TOOLING_PATH =
9  /(?:^|[/\\])(?:\.claude|\.git|\.task|coverage|\.next|\.turbo|knowledge-vault|node_modules[/\\]\.bin)(?:[/\\]|$)|(?:^|[/\\])(?:scratchpad|tmp|temp)[/\\]|^\/tmp[/\\]/i;
10
11const SOURCE_PATH = new RegExp(`${SOURCE_EXTENSION}${EXTENSION_END}`, "i");
12const isProjectSourceFile = (path: string) => SOURCE_PATH.test(path) && !SCRATCH_OR_TOOLING_PATH.test(path);
13
14// ── splitting the command ────────────────────────────────────────────────────
15
16// A heredoc's body is payload, not command: the TypeScript inside `cat > a.ts <<'TS'` would otherwise read as
17// redirects (`=>`) and source paths (`import "./x.ts"`). The body goes, its header line stays.
18function withoutHeredocBodies(command: string) {
19  const heredocStart = /<<-?\s*(['"]?)([A-Za-z_]\w*)\1/g;
20  let stripped = command;
21  for (const match of command.matchAll(heredocStart)) {
22    const delimiter = match[2];
23    const bodyStart = command.indexOf("\n", match.index);
24    if (bodyStart === -1) continue;
25    const bodyEnd = command.search(new RegExp(`\\n\\s*${delimiter}\\s*(?:\\n|$)`));
26    const body = bodyEnd === -1 ? command.slice(bodyStart) : command.slice(bodyStart, bodyEnd);
27    if (body) stripped = stripped.split(body).join("\n");
28  }
29  return stripped;
30}
31
32// The pipeline and list parts (`a | b; c && d`), split outside quotes only, each without its leading
33// `VAR=value` assignments and sudo/command wrappers, so each is judged by the program it runs.
34function commandParts(command: string) {
35  const parts: string[] = [];
36  let currentPart = "";
37  let openQuote: string | null = null;
38  for (let index = 0; index < command.length; index++) {
39    const character = command[index]!;
40    const isEscaped = command[index - 1] === "\\";
41    if (openQuote) {
42      if (character === openQuote && !isEscaped) openQuote = null;
43      currentPart += character;
44    } else if (character === '"' || character === "'") {
45      openQuote = character;
46      currentPart += character;
47    } else if ("|;\n&".includes(character)) {
48      parts.push(currentPart);
49      currentPart = "";
50    } else {
51      currentPart += character;
52    }
53  }
54  parts.push(currentPart);
55  return parts
56    .map((part) => part.trim().replace(/^(?:\w+=\S*\s+)*(?:sudo\s+|command\s+)?/, "").trim())
57    .filter(Boolean);
58}
59
60// ── the writes ───────────────────────────────────────────────────────────────
61
62// `> src/app.ts`, `>> src/app.ts`
63const REDIRECT_TARGET = new RegExp(String.raw`>>?\s*['"]?([\w./@~-]+${SOURCE_EXTENSION})${EXTENSION_END}`, "i");
64// `| tee src/app.ts`
65const TEE_TARGET = new RegExp(String.raw`\btee\b[^|]*?['"]?([\w./@~-]+${SOURCE_EXTENSION})${EXTENSION_END}`, "i");
66// any source path among a command's arguments, for `sed -i`
67const SOURCE_ARGUMENT = new RegExp(String.raw`[\w./@~-]+${SOURCE_EXTENSION}${EXTENSION_END}`, "i");
68
69// The source file a command part writes through the shell itself: a redirect, tee, or an in-place sed.
70function shellWrittenFile(part: string) {
71  const redirected = (part.match(REDIRECT_TARGET) ?? part.match(TEE_TARGET))?.[1];
72  if (redirected) return redirected;
73  const isInPlaceSed = /^sed\b/i.test(part) && /\s-i\b/.test(part);
74  return isInPlaceSed ? part.match(SOURCE_ARGUMENT)?.[0] : undefined;
75}
76
77// A script's write call, by the path literal it names: Python's `Path("a.ts").write_text(...)` and
78// `open("a.ts", "w")`, Node's `writeFileSync("a.ts", ...)`. Only that path counts, so a script that writes
79// a .json while merely mentioning a .ts is left alone.
80// ponytail: a path held in a variable (`open(target, "w")`) goes unseen; resolve the assignment if that ever matters
81const SCRIPT_WRITE_CALLS = [
82  /\bPath\(\s*['"]([^'"]+)['"]\s*\)\s*\.write_text\s*\(/g,
83  /\bopen\s*\(\s*['"]([^'"]+)['"]\s*,\s*['"][wa]\+?['"]/g,
84  /\bwriteFileSync\s*\(\s*['"]([^'"]+)['"]/g,
85];
86
87function scriptWrittenFiles(command: string) {
88  return SCRIPT_WRITE_CALLS.flatMap((writeCall) => [...command.matchAll(writeCall)].map((match) => match[1]!));
89}
90
91// ── the verdict ──────────────────────────────────────────────────────────────
92
93// The project source file a shell command writes, which the guard refuses; null when it writes none.
94export function shellWriteTarget(rawCommand: string): string | null {
95  const command = cleanCommand(rawCommand);
96  const shellWrites = commandParts(withoutHeredocBodies(command)).map(shellWrittenFile);
97  // the script's code sits in the heredoc body: its write calls are looked for in the whole command
98  const candidates = [...shellWrites, ...scriptWrittenFiles(command)];
99  return candidates.find((path): path is string => !!path && isProjectSourceFile(path)) ?? null;
100}
101
hooks/lib/command.ts 14 lines
1// What the grep and write guards share about a shell command.
2
3// Zero-width and formatting characters could split a word invisibly (`grep​UserService`) and slip past
4// every check below: they are removed before anything is matched.
5const ZERO_WIDTH_CHARACTERS = /[­​-‏⁠-⁤]/g;
6
7export const cleanCommand = (rawCommand: string) => rawCommand.trim().replace(ZERO_WIDTH_CHARACTERS, "");
8
9// The extensions of the source files an LSP indexes, as a regex fragment.
10export const SOURCE_EXTENSION = String.raw`\.(?:ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|swift|vue|svelte|cpp|c|h|hpp)`;
11
12// Closes SOURCE_EXTENSION: without it the `.c` extension would match the start of `data.csv`.
13export const EXTENSION_END = String.raw`(?![A-Za-z0-9_])`;
14