Physical enforcement of LSP-first navigation in Claude Code.
Stop burning tokens on Grep. Make Claude navigate code like an IDE — 100% of the time.




Quick Start •
Why •
Savings •
Architecture •
Hooks •
Changelog
In Action
When Claude tries to Grep for a code symbol, the hook blocks with a copy-pasteable LSP command:
⛔ LSP-FIRST BLOCK: Pattern contains code symbol(s) — use LSP instead
Symbols: handleSubmit, UserService
LSP tools:
handleSubmit:
mcp__cclsp__find_references("handleSubmit") (cclsp)
UserService:
mcp__cclsp__find_workspace_symbols("UserService") (cclsp)
When Claude tries to Read a code file without warming up LSP, the progressive gate blocks:
🛡️ LSP-FIRST READ GATE — Gate 1: warmup required
Call one of these first:
mcp__cclsp__get_diagnostics("src/page.tsx") (cclsp)
CONCRETE CALL FOR THIS FILE (works in any project):
mcp__cclsp__get_diagnostics("src/page.tsx")
After warmup: 2 free Reads, then need LSP navigation.
No generic advice. Every block message is parametrized by the actual file Claude tried to touch.
⚡ Quick Start
git clone https://github.com/nesaminua/claude-code-lsp-enforcement-kit.git
cd claude-code-lsp-enforcement-kit
bash install.sh
# Windows: pwsh ./install.ps1
Restart Claude Code. Done. The installer is idempotent — safe to re-run on upgrades.
Verify:
bash scripts/lsp-status.sh
🎯 The Problem
Claude Code defaults to Grep + Read for code navigation. This works, but it's wasteful:
"Where is handleSubmit defined?"
Grep approach:
Grep("handleSubmit") → 23 matches, ~1500 tokens of output
Read file1.tsx (wrong) → 2500 tokens
Read file2.tsx (still wrong) → 2500 tokens
Read file3.tsx (found it) → 2500 tokens
─────────────────────────────────────
Total: ~9,000 tokens, 4 tool calls
LSP approach:
find_definition("handleSubmit") → form-actions.ts:42, ~80 tokens
Read form-actions.ts:35-55 → ~150 tokens
─────────────────────────────────────
Total: ~230 tokens, 2 tool calls
~40x fewer tokens. Same answer.
A rule in CLAUDE.md saying "use LSP" helps ~60% of the time. Hooks make it 100%.
💰 Token Savings: Grep vs LSP Per Operation
| Task | Grep approach | LSP approach | Saved |
|---|
Find definition of handleSubmit | Grep → 23 matches (~1500 tok) + 2 wrong Reads (~5000 tok) = ~6500 tok | find_definition → file:line (~80 tok) + 1 targeted Read (~500 tok) = ~580 tok | 91% |
Find all usages of UserService | Grep → 15 matches (~1200 tok), scan results (~300 tok) = ~1500 tok | find_references → 8 file:line pairs (~150 tok) = ~150 tok | 90% |
Check type of formData | Read full file (~2500 tok), search visually = ~2500 tok | get_hover → type signature (~60 tok) = ~60 tok | 98% |
Find component InviteForm | Glob (~200 tok) + Grep (~800 tok) + Read wrong file (~2500 tok) = ~3500 tok | find_workspace_symbols → exact location (~100 tok) = ~100 tok | 97% |
Who calls validateToken? | Grep → noisy results (~1500 tok) + 3 Reads to verify (~6000 tok) = ~7500 tok | get_incoming_calls → caller list (~200 tok) + 1 Read (~500 tok) = ~700 tok | 91% |
📊 Real-World Data: 1 Week, 2 Projects
Aggregate from a week of development across 2 TypeScript projects:
| Metric | With LSP | Without LSP (estimated) |
|---|
| LSP navigation calls | 39 | — |
| Grep calls on code symbols | 0 (blocked) | ~120 |
| Unique code files Read | 53 | ~180 |
| Estimated navigation tokens | ~85k | ~320k |
| Tokens saved | | ~235k (~73%) |
How the estimate works:
- Each blocked Grep saves ~1200 tokens of noisy output
- Each avoided Read saves ~1500 tokens of file content loaded into context
- 39 LSP calls cost ~4k tokens total (precise, compact results)
- Without LSP: ~120 Greps + ~180 Reads = ~315k tokens for the same navigation work
- With LSP: 39 nav calls + 53 targeted Reads = ~84k tokens
🔌 Works with any LSP MCP server
v2.1 introduces provider-aware block messages. The kit detects which LSP MCP server(s) you have installed and tailors its suggestions accordingly:
- cclsp — standalone MCP server or bundled via the
typescript-lsp Claude Code plugin. Suggestions use mcp__cclsp__find_definition, find_references, find_workspace_symbols, etc.
- Serena — high-level symbol MCP server (MIT, by Oraios AI). Multi-language support (Python, Go, Rust, Java, TypeScript, Vue, and more via its bundled
solidlsp wrapper). Suggestions use mcp__serena__find_symbol, find_referencing_symbols, get_symbols_overview.
- Both installed — suggestions show entries for both providers.
- Neither installed — generic fallback with install hints for both.
Detection reads user-level Claude Code config (~/.claude.json, ~/.claude/settings.json) and matches known server names. The shared helper is in hooks/lib/detect-lsp-provider.js — adding a new provider means adding one entry to its PROVIDERS registry, with no changes to the individual hooks.
🏗️ Architecture: 6 Hooks + 1 Tracker
PreToolUse PostToolUse
────────── ───────────
Grep call ──→ [lsp-first-guard.js] ──→ BLOCK
detects code symbols,
suggests LSP equivalent
Glob call ──→ [lsp-first-glob-guard.js] ──→ BLOCK
blocks *UserService*, **/handleFoo*.ts;
allows *.ts, *subdomain*, src/**
Bash(grep) ──→ [bash-grep-block.js] ──→ BLOCK
catches grep/rg/ag/ack
in shell commands
Read(.tsx) ──→ [lsp-first-read-guard.js] ──→ GATE
5 progressive gates
(warmup → orient → nav → surgical)
Agent(impl) ─→ [lsp-pre-delegation.js] ──→ BLOCK
subagents can't access MCP,
orchestrator must pre-resolve
LSP call ─────────────────────────────────────→ [lsp-usage-tracker.js]
tracks nav_count,
read_count, state
SessionStart
────────────
New session ──→ [lsp-session-reset.js] ──→ WIPE
clears stale nav_count for current cwd,
forces fresh warmup + re-enforces gates
v2 note: versions before v2 had two silent bypass routes that let
Claude read code files without ever calling LSP:
(1) Glob("*SymbolName*") had no guard, and (2) nav_count persisted
for 24 h across sessions, so a new session inherited "surgical mode"
(unlimited reads) from yesterday's LSP work. Both are closed in v2 by
lsp-first-glob-guard.js and lsp-session-reset.js. If you installed
v1, re-run bash install.sh — it merges the new hooks without touching
your existing settings.
🔧 How Each Hook Works
1. lsp-first-guard.js — Grep Blocker
Hook type: PreToolUse | Matcher: Grep
Intercepts every Grep call. Detects code symbols in the pattern. Blocks with a suggestion to use the correct LSP tool.
| Pattern | Detected as | Action |
|---|
getUserById | camelCase symbol | BLOCK |
UserService | PascalCase symbol | BLOCK |
router.refresh | dotted symbol | BLOCK |
write_audit_log | snake_case function | BLOCK |
create-folder-modal | component filename | BLOCK |
TODO | keyword | allow |
NEXT_PUBLIC_URL | env var (SCREAMING_SNAKE) | allow |
flex-col | CSS class | allow |
*.md, *.json, *.sql | non-code file glob | allow |
.task/, node_modules/ | non-code path | allow |
Block message example (with both cclsp and Serena detected):
⛔ LSP-FIRST BLOCK: 1 code symbol(s) in Grep — use LSP instead
Symbols: handleSubmit
LSP tools:
handleSubmit:
mcp__cclsp__find_references("handleSubmit") (cclsp)
mcp__serena__find_referencing_symbols("handleSubmit") (Serena)
If only one provider is installed, only that suggestion appears.
2. lsp-first-glob-guard.js — Glob Symbol Blocker
Hook type: PreToolUse | Matcher: Glob
Closes the gap where Claude searches for a symbol by filename pattern instead of content. Without this hook, Glob("*UserService*") silently returns the file, Claude reads it, and LSP enforcement never fires.
The guard parses the glob pattern, extracts alphabetic tokens, and blocks if any token looks like a code symbol (PascalCase, camelCase, or snake_case with 3+ parts). Lowercase-only tokens and short generic words are always allowed.
| Pattern | Detected as | Action |
|---|
*UserService* | PascalCase symbol | BLOCK |
**/AuthProvider.tsx | PascalCase in path | BLOCK |
*createOrder* | camelCase symbol | BLOCK |
*handleSubmit* | camelCase handler | BLOCK |
*get_user_sessions* | snake_case function | BLOCK |
src/**/*.ts | extension pattern | allow |
*.tsx, **/*.json | extension pattern | allow |
*subdomain*, *auth* | lowercase concept | allow |
**/middleware* | file concept | allow |
tsconfig.json, next.config.ts | framework config | allow |
README.md | docs | allow |
Allowed by design: lowercase concept searches (*auth*, *subdomain*) are legitimate file discovery by topic. Only symbol-shaped tokens (casing patterns) are blocked, because those should use find_workspace_symbols instead.
3. bash-grep-block.js — Shell Grep Blocker
Hook type: PreToolUse | Matcher: Bash
Same detection logic, but for Bash(grep "UserService" src/), Bash(rg handleSubmit), etc. Claude sometimes tries to bypass the Grep hook by shelling out.
Allows: git grep (history search), non-code paths, non-code file type filters.
4. lsp-first-read-guard.js — Progressive Read Gate
Hook type: PreToolUse | Matcher: Read
The most sophisticated hook. Forces a "navigate first, read targeted" workflow through 5 gates:
Gate 1 — Warmup Required
No LSP state file → BLOCK
Must call get_diagnostics(<any .ts file>) first
Gate 2 — Free Orientation (reads 1-2)
ALLOW — explore freely, no restrictions
Gate 3 — Warning (read 3)
WARN if no LSP nav calls yet
"Next Read will be BLOCKED"
Gate 4 — Navigation Required (reads 4-5)
BLOCK if nav_count < 1
Must use at least 1 LSP navigation call
Gate 5 — Surgical Mode (reads 6+)
BLOCK if nav_count < 2
After 2 nav calls → unlimited reads forever
Session flow: