Skip to content
Development
Hook

Hooks

What claude-code-lsp-enforcement-kit runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.

From plugin
claude-code-lsp-enforcement-kit
3257 hooks

Where it lives

  • hooks/bash-grep-block.jsGitHub
    Read the script
    #!/usr/bin/env node
    'use strict';
    
    // bash-grep-block.js — PreToolUse hook (matcher: Bash)
    // Blocks grep/rg/ag/ack with code symbols in shell commands.
    // Suggests LSP equivalent for the active provider (cclsp / Serena / ...).
    // Allows: git grep, non-code paths, non-code file types.
    
    const { buildSuggestion, buildStructuredBlockResponse } = require('./lib/detect-lsp-provider');
    
    // Zero-width / formatting chars that would split tokens invisibly and
    // bypass ASCII regex symbol detection.
    const ZERO_WIDTH = /[\u00AD\u200B-\u200F\u2060-\u2064\uFEFF]/g;
    
    let raw = '';
    process.stdin.setEncoding('utf8');
    process.stdin.on('data', d => { raw += d; });
    process.stdin.on('end', () => {
      let data;
      try { data = JSON.parse(raw); } catch { process.exit(0); }
      if (data.tool_name !== 'Bash') process.exit(0);
    
      // String coercion: non-string command would throw on .trim() and fail-open.
      // Zero-width strip: prevents `grep\u200BUserFunc` evasion.
      const cmd = String(data.tool_input?.command ?? '').trim().replace(ZERO_WIDTH, '');
      // Case-insensitive to catch `GREP`, `RG` variants
      if (!/\b(grep|rg|ag|ack)\b/i.test(cmd)) process.exit(0);
      if (/\bgit\s+grep\b/i.test(cmd)) process.exit(0);
      if (/(?:^|[\/\\])(?:supabase[\/\\]migrations|\.task|\.claude|node_modules|knowledge-vault)(?:[\/\\]|$)/i.test(cmd)) process.exit(0);
      if (/--include=?\S*\.(sql|md|json|yaml|yml|txt|env|sh|css|scss|log)\b/i.test(cmd)) process.exit(0);
    
      const cleaned = cmd.replace(/\\"/g, '"');
      const patternMatch =
        cleaned.match(/\b(?:grep|rg|ag|ack)\s+(?:-\S+\s+)*"([^"]+)"/i) ||
        cleaned.match(/\b(?:grep|rg|ag|ack)\s+(?:-\S+\s+)*'([^']+)'/i) ||
        cleaned.match(/\b(?:grep|rg|ag|ack)\s+(?:(?:-\w+\s+(?:[a-z]+\s+)?)*?)([A-Z][a-zA-Z]\w+)/i);
    
      if (!patternMatch) process.exit(0);
    
      const fullPattern = patternMatch[1];
      // Strip zero-width chars from the matched pattern (already stripped from cmd,
      // but an explicit safety for pattern extraction edge cases).
      // NOTE: split on BOTH `|` and `.` — previously we stripped dots, which merged
      // dotted expressions like `mcp.Tool` into `mcpTool` (camelCase false positive).
      // Now we split on dots so each side is evaluated independently.
      const parts = fullPattern
        .split(/\\?\||\./)
        .map(p => p.replace(ZERO_WIDTH, '').replace(/[*+?^${}()[\]\\]/g, '').trim())
        .filter(Boolean);
      const symbols = parts.filter(p => {
        if (p.length < 4 || /\s/.test(p)) return false;
        const skip = [
          /^(TODO|FIXME|HACK|XXX|NOTE)/i,
          /^console\b/, /^import\b/, /^export\b/, /^http/i, /^\d/,
          /^[A-Z_]{3,}$/, /^[a-z]{1,8}$/, /^[a-z]+-[a-z]+/,
        ];
        if (skip.some(rx => rx.test(p))) return false;
    
        // NOTE: dotted-symbol regex removed — after splitting on `.` above,
        // no `p` can contain a dot, so the path was dead code.
        return (/^[a-z][a-zA-Z0-9]{3,}$/.test(p) && /[A-Z]/.test(p)) ||
               /^[A-Z][a-zA-Z][a-zA-Z0-9]{2,}$/.test(p) ||
               (/^[a-z]+(_[a-z]+){2,}$/.test(p) && p.length >= 9);
      });
    
      // SECURITY: only allow the safe-prefix pipe bypass AFTER confirming no code symbols.
      // Previously `echo x | grep SomeCamelFunc` passed because the bypass ran before
      // symbol detection. Now: if symbols present, no bypass — always proceed to block.
      if (symbols.length === 0) {
        const targetsCodeEarly =
          /\bsrc[\\/]|\bapp[\\/]|components[\\/]|lib[\\/]|hooks[\\/]|utils[\\/]|services[\\/]|actions[\\/]/i.test(cmd) ||
          /\.tsx?\b|\.jsx?\b/i.test(cmd);
        const hasNonCodeTargetEarly = /\.(sql|md|json|yaml|yml|txt|env|sh|css|scss|log|toml|xml)\b/i.test(cmd) && !targetsCodeEarly;
        if (hasNonCodeTargetEarly) process.exit(0);
    
        const isSimplePipe = /\|/.test(cmd) && !/xargs|exec/.test(cmd);
        const grepPos = cmd.search(/\b(grep|rg|ag|ack)\b/i);
        const pipePos = cmd.indexOf('|');
        if (isSimplePipe && pipePos !== -1 && pipePos < grepPos) {
          const beforePipe = cmd.substring(0, pipePos).trim();
          if (/^(git|npm|npx|pnpm|node|echo|cat\s+\S+\.(?:json|md|txt|log|ya?ml))/i.test(beforePipe) ||
              /^(ls|wc|head|tail|sort|uniq)\b/i.test(beforePipe)) {
            process.exit(0);
          }
        }
        process.exit(0);
      }
    
      const targetsCode =
        /\bsrc[\\/]|\bapp[\\/]|components[\\/]|lib[\\/]|hooks[\\/]|utils[\\/]|services[\\/]|actions[\\/]/i.test(cmd) ||
        /\.tsx?\b|\.jsx?\b/i.test(cmd) ||
        /-t\s+(ts|tsx|js|jsx|typescript|javascript)\b/i.test(cmd) ||
        /--type[= ](ts|tsx|js|jsx|typescript)\b/i.test(cmd) ||
        /\bfind\b.*\b(src|app|components|lib)\b/.test(cmd) ||
        /\bxargs\b.*\b(grep|rg|ag|ack)\b/i.test(cmd) ||
        /-exec\s+(grep|rg|ag|ack)\b/i.test(cmd);
    
      const hasNonCodeTarget =
        /\.(sql|md|json|yaml|yml|txt|env|sh|css|scss|log|toml|xml)\b/i.test(cmd) &&
        !targetsCode;
    
      // Symbols are present — only bypass if the command is unambiguously
      // targeting non-code files AND doesn't touch code paths.
      if (hasNonCodeTarget && !targetsCode) process.exit(0);
    
      const suggestions = symbols.map(sym => {
        const intent = /^[A-Z]/.test(sym) ? 'symbol_search' : 'references';
        return `  ${sym}:\n${buildSuggestion(sym, intent, '    ')}`;
      }).join('\n');
    
      process.stderr.write(
        `\n⛔ LSP-FIRST: Blocked grep/rg — found ${symbols.length} code symbol(s): ${symbols.join(', ')}\n` +
        `LSP is always connected. Use:\n${suggestions}\n\n`
      );
    
      const intent = /^[A-Z]/.test(symbols[0]) ? 'symbol_search' : 'references';
      console.log(JSON.stringify(buildStructuredBlockResponse({
        hook: 'bash-grep-block',
        symbols,
        intent,
        reason: `LSP-FIRST: Pattern contains code symbols [${symbols.join(', ')}]. Use LSP:\n${suggestions}`,
      })));
    });
    
  • hooks/lsp-first-glob-guard.jsGitHub
    Read the script
    #!/usr/bin/env node
    'use strict';
    
    /**
     * lsp-first-glob-guard.js — PreToolUse hook (matcher: Glob)
     *
     * HARD BLOCK: Glob patterns that search for code symbols by filename.
     * Closes the gap where an agent bypasses lsp-first-guard (Grep matcher)
     * and lsp-first-read-guard (Read matcher) by using Glob to locate files
     * containing a symbol name.
     *
     * Allowed:
     *   - Extension patterns:        src/**\/*.ts, *.tsx, **\/*.json
     *   - Concept patterns:          *subdomain*, *auth*, **\/middleware*
     *   - Short / all-lowercase:     *modal*, *form*, auth/**
     *   - Config / framework files:  tsconfig.json, next.config.ts
     *
     * Blocked:
     *   - PascalCase symbol:         *UserService*, **\/*Modal.tsx, *TabsClient*
     *   - camelCase symbol:          *createOrder*, *handleSubmit*
     *   - snake_case function (3+):  *get_user_sessions*, *write_audit_log*
     *
     * Philosophy: if you know the symbol name, use LSP (find_workspace_symbols
     *   for cclsp, find_symbol for Serena). Glob is for broad file discovery
     *   by extension or concept, not for symbol-based search.
     */
    
    const { buildSuggestion, buildStructuredBlockResponse } = require('./lib/detect-lsp-provider');
    
    let raw = '';
    process.stdin.setEncoding('utf8');
    process.stdin.on('data', d => { raw += d; });
    process.stdin.on('end', () => {
      let data;
      try { data = JSON.parse(raw); } catch { process.exit(0); }
    
      if (data.tool_name !== 'Glob') process.exit(0);
    
      // String coercion: non-string input would throw on .trim() and fail-open.
      const pattern = String(data.tool_input?.pattern ?? '').trim();
      if (!pattern) process.exit(0);
    
      const searchPath = String(data.tool_input?.path ?? '').trim();
    
      // ── Allow: non-code paths (anchored — bare substring would let
      //    "myknowledge-vaultxxx" bypass detection) ──────────────────────────
      const NON_CODE_PATH = /(?:^|[\/\\])(?:knowledge-vault|\.task|\.claude|node_modules|supabase[\/\\]migrations|\.git)(?:[\/\\]|$)/i;
      if (NON_CODE_PATH.test(searchPath)) process.exit(0);
      if (NON_CODE_PATH.test(pattern)) process.exit(0);
    
      // Extract alphabetic tokens from the pattern (strip *, /, ., brackets, etc.)
      const tokens = pattern
        .split(/[*/.\\{}\[\]()!?,\s|+-]+/)
        .map(t => t.trim())
        .filter(Boolean);
    
      const symbolTokens = tokens.filter(t => isCodeSymbol(t));
      if (symbolTokens.length === 0) process.exit(0);
    
      const suggestions = symbolTokens.map(sym => {
        const intent = /^[A-Z]/.test(sym) ? 'symbol_search' : 'references';
        return `  ${sym}:\n${buildSuggestion(sym, intent, '    ')}`;
      }).join('\n');
    
      const msg =
        `\n⛔ LSP-FIRST BLOCK: Glob pattern contains ${symbolTokens.length} code symbol(s)\n` +
        `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` +
        `Pattern: ${pattern}\n` +
        `Symbols: ${symbolTokens.join(', ')}\n\n` +
        `LSP is always connected. Searching files by symbol name is LSP territory:\n` +
        `${suggestions}\n\n` +
        `If you need to find files by extension or concept, use lowercase\n` +
        `(e.g. "*subdomain*", "src/**/*.ts") — those are allowed.\n` +
        `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n`;
    
      process.stderr.write(msg);
    
      const intent = /^[A-Z]/.test(symbolTokens[0]) ? 'symbol_search' : 'references';
      console.log(JSON.stringify(buildStructuredBlockResponse({
        hook: 'lsp-first-glob-guard',
        symbols: symbolTokens,
        intent,
        reason: `LSP-FIRST: Glob pattern contains code symbol(s) [${symbolTokens.join(', ')}]. Use LSP tools instead of filename-based symbol search.`,
      })));
    });
    
    // Zero-width / formatting chars that would split tokens invisibly and
    // bypass ASCII regex checks. Strip them before any symbol detection.
    const ZERO_WIDTH = /[\u00AD\u200B-\u200F\u2060-\u2064\uFEFF]/g;
    
    function isCodeSymbol(raw) {
      if (!raw) return false;
      const s = String(raw).replace(ZERO_WIDTH, '');
      if (s.length < 4) return false;
      if (/\s/.test(s)) return false;
    
      // File extensions and directory/framework keywords — always allowed
      const skipExact = new Set([
        // extensions
        'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'py', 'go', 'rs', 'java',
        'vue', 'svelte', 'md', 'mdx', 'json', 'jsonc', 'yaml', 'yml', 'sql',
        'sh', 'bash', 'css', 'scss', 'sass', 'less', 'html', 'htm', 'xml',
        'toml', 'ini', 'env', 'lock', 'log', 'txt', 'csv',
        // common dirs / file stems
        'src', 'app', 'lib', 'libs', 'hooks', 'utils', 'util', 'types',
        'components', 'pages', 'api', 'server', 'client', 'public', 'docs',
        'tests', 'test', 'spec', 'specs', 'dist', 'build', 'out', 'next',
        'turbo', 'cache', 'node_modules', 'coverage', 'scripts', 'config',
        'assets', 'styles', 'fonts', 'images', 'icons', 'locales', 'i18n',
        'middleware', 'services', 'service', 'models', 'model', 'schemas',
        'schema', 'routes', 'route', 'views', 'view', 'store', 'stores',
        'actions', 'action', 'reducers', 'slices', 'providers', 'contexts',
        'layouts', 'layout', 'templates', 'template', 'helpers', 'helper',
        'constants', 'const', 'configs', 'fixtures', 'mocks', 'mock',
        'validations', 'validators', 'transformers',
        // common filenames
        'index', 'main', 'page', 'error', 'loading', 'not-found', 'global',
        'root', 'readme', 'license', 'changelog', 'dockerfile', 'makefile',
        'tsconfig', 'jsconfig', 'package', 'pnpm-lock', 'yarn', 'npm-lock',
        // framework / tool config stems
        'next', 'vite', 'webpack', 'rollup', 'babel', 'jest', 'vitest',
        'tailwind', 'postcss', 'eslint', 'prettier', 'playwright', 'cypress',
        'drizzle', 'prisma', 'supabase', 'turbo', 'nx', 'bun', 'deno',
        // directives / keywords
        'todo', 'fixme', 'hack', 'xxx', 'note', 'import', 'export', 'from',
        'require', 'http', 'https',
      ]);
      if (skipExact.has(s.toLowerCase())) return false;
    
      // Short all-lowercase (≤8 chars) — too generic to be a symbol
      if (/^[a-z]{1,8}$/.test(s)) return false;
    
      // SCREAMING_SNAKE — constants / env vars / acronyms
      if (/^[A-Z_]{3,}$/.test(s)) return false;
    
      // kebab-case — filename conventions, not s
  • hooks/lsp-first-guard.jsGitHub
    Read the script
    #!/usr/bin/env node
    'use strict';
    
    // lsp-first-guard.js — PreToolUse hook (matcher: Grep)
    // Blocks Grep on code symbols. Suggests LSP equivalent for the active provider.
    
    const { buildSuggestion, buildStructuredBlockResponse } = require('./lib/detect-lsp-provider');
    
    let raw = '';
    process.stdin.setEncoding('utf8');
    process.stdin.on('data', d => { raw += d; });
    process.stdin.on('end', () => {
      let data;
      try { data = JSON.parse(raw); } catch (e) { process.exit(0); }
    
      if (data.tool_name !== 'Grep') process.exit(0);
    
      const params  = data.tool_input || {};
      // String coercion: non-string pattern (number, array, etc.) would throw on .trim()
      // and fail-open — Claude Code treats crash as passthrough. See security review.
      const pattern = String(params.pattern ?? '').trim();
      const searchPath = String(params.path ?? '');
      const glob    = String(params.glob ?? '');
    
      if (/knowledge-vault|\.task[\\/]|\.claude[\\/]|node_modules|logs?[\\/]|docs?[\\/]|supabase[\\/]migrations/i.test(searchPath)) {
        process.exit(0);
      }
    
      if (/\.(md|txt|log|json|jsonc|yaml|yml|env|csv|toml|xml|sql|sh|css|scss)/i.test(glob)) {
        process.exit(0);
      }
    
      if (pattern.length < 4) process.exit(0);
    
      const parts = pattern.split('|').map(p => p.trim()).filter(Boolean);
      const symbolParts = [];
      for (const part of parts) {
        if (isCodeSymbol(part)) symbolParts.push(part);
      }
    
      if (symbolParts.length === 0) process.exit(0);
    
      const suggestions = symbolParts.map(sym => {
        const intent = /^[A-Z]/.test(sym) ? 'symbol_search' : 'references';
        return `  ${sym}:\n${buildSuggestion(sym, intent, '    ')}`;
      }).join('\n');
    
      process.stderr.write(
        `\n⛔ LSP-FIRST BLOCK: ${symbolParts.length} code symbol(s) in Grep — use LSP instead\n` +
        `Symbols: ${symbolParts.join(', ')}\nLSP tools:\n${suggestions}\n\n`
      );
    
      // Emit structured JSON for programmatic consumers (monitoring, dashboards, IDE plugins).
      // `decision` and `reason` fields remain backward compatible.
      const intent = /^[A-Z]/.test(symbolParts[0]) ? 'symbol_search' : 'references';
      console.log(JSON.stringify(buildStructuredBlockResponse({
        hook: 'lsp-first-guard',
        symbols: symbolParts,
        intent,
        reason: `LSP-FIRST: Pattern contains code symbol(s) [${symbolParts.join(', ')}]. Use LSP tools:\n${suggestions}`,
      })));
    });
    
    function isCodeSymbol(s) {
      if (s.length < 4) return false;
      if (/\s/.test(s)) return false;
      if (/[&?+[\]{}()\\^$*]/.test(s)) return false;
    
      const allowList = [
        /^(TODO|FIXME|HACK|XXX|NOTE)/i,
        /^console\./, /^import\b/, /^require\(/, /^from\b/, /^export\b/,
        /^\/\//, /^#/, /^\./, /^http/i, /^\d/,
        /^[A-Z_]{3,}$/,
        /^[a-z]{1,8}$/,
        /^['"`]/,
        /^use (client|server)/,
      ];
      if (allowList.some(rx => rx.test(s))) return false;
    
      if (/^[a-z]+-[a-z]/.test(s)) {
        if (/^(text-|bg-|border-|font-|hover:|focus:|active:|group-|ring-|shadow-|rounded-|flex-|grid-|gap-|space-|divide-|overflow-|whitespace-|break-|leading-|tracking-|align-|justify-|items-|self-|order-|col-|row-|transition-|duration-|ease-|animate-|scale-|rotate-|translate-|origin-|cursor-|select-|resize-|appearance-|outline-|decoration-|underline-|line-|placeholder-|caret-|accent-|sr-|z-|opacity-|w-|h-|p-|m-|px-|py-|pt-|pb-|pl-|pr-|mx-|my-|mt-|mb-|ml-|mr-|max-|min-|inset-|top-|right-|bottom-|left-|float-|data-)/.test(s)) {
          return false;
        }
        if (/-(modal|form|dialog|sidebar|popover|tab|list|card|button|widget|table|page|layout|header|footer|section|panel|gallery|grid|menu|nav|banner|badge|skeleton|spinner|tooltip|dropdown|select|input|textarea|checkbox|radio|switch|slider|avatar|icon|chip|toast|alert|bar|row|cell|item|field|wrapper|container|provider|context|hook|view|screen|chart|editor|builder|filler|picker|uploader|timeline|breadcrumb|steward|runner|tester|checker|resolver|reviewer|optimizer|detector|guard|enforcer)s?$/.test(s)) {
          return true;
        }
        if (/^(actions?|helpers?|utils?|hooks?|types?|constants?|validations?|services?)-/.test(s)) {
          return true;
        }
        return false;
      }
    
      const isCamelCase = /^[a-z][a-zA-Z0-9]{3,}$/.test(s) && /[A-Z]/.test(s);
      const isPascalCase = /^[A-Z][a-zA-Z][a-zA-Z0-9]{2,}$/.test(s);
      const isDottedSymbol = /^[a-z][a-zA-Z]*\.[a-z][a-zA-Z]*$/i.test(s);
      const isSnakeCaseFunc = /^[a-z]+(_[a-z]+){2,}$/.test(s) && s.length >= 9;
    
      return isCamelCase || isPascalCase || isDottedSymbol || isSnakeCaseFunc;
    }
    
  • hooks/lsp-first-read-guard.jsGitHub
    Read the script
    #!/usr/bin/env node
    'use strict';
    
    const fs = require('fs');
    const path = require('path');
    const os = require('os');
    const crypto = require('crypto');
    const { buildWarmupInstructions, buildFileWarmupCall } = require('./lib/detect-lsp-provider');
    
    /**
     * Build a copy-pasteable warmup call parametrized by the exact file the
     * agent is about to Read. This is project-agnostic: it uses the file path
     * from the hook input instead of guessing a symbol name from the filename,
     * so it works in any project regardless of export conventions.
     */
    function buildConcreteCall(filePath) {
      const call = buildFileWarmupCall(filePath, '  ');
      if (!call) return '';
      return `\nCONCRETE CALL FOR THIS FILE (works in any project):\n${call}\n`;
    }
    
    const STATE_DIR = path.join(os.homedir(), '.claude', 'state');
    const CODE_EXTENSIONS = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|swift|vue|svelte|cpp|c|h|hpp)$/i;
    const ALLOW_NON_CODE_EXT = /\.(md|txt|log|json|jsonc|yaml|yml|env|csv|toml|xml|sql|sh|css|scss|html|lock|ini|conf|cfg)$/i;
    const ALLOW_CONFIG_PATTERNS = /(\.config\.|tsconfig|next\.config|vite\.config|webpack\.config|rollup\.config|babel\.config|jest\.config|vitest\.config|tailwind\.config|postcss\.config|eslint|prettier|package\.json|pnpm-lock|yarn\.lock)/i;
    const ALLOW_PATH_PATTERNS = /(^|\/)(\.task|\.claude|\.git|node_modules|build|dist|out|public|scripts|docs?|knowledge-vault|supabase\/migrations|coverage|\.next|\.turbo|__tests__|__mocks__)(\/|$)/i;
    const ALLOW_TEST_PATTERNS = /\.(test|spec)\.(ts|tsx|js|jsx|mjs|cjs|py)$/i;
    
    const FLAG_EXPIRY_MS = 24 * 60 * 60 * 1000;
    const FREE_READS = 2;
    const WARN_AT = 3;
    const REQUIRE_NAV_2_AT = 6;
    
    function getFlagPath() {
      const cwd = process.cwd();
      const hash = crypto.createHash('md5').update(cwd).digest('hex').slice(0, 12);
      return path.join(STATE_DIR, `lsp-ready-${hash}`);
    }
    
    function ensureStateDir() {
      try { if (!fs.existsSync(STATE_DIR)) fs.mkdirSync(STATE_DIR, { recursive: true }); } catch {}
    }
    
    function readFlag(fp) {
      try {
        if (!fs.existsSync(fp)) return null;
        const d = JSON.parse(fs.readFileSync(fp, 'utf8'));
        if (Date.now() - (d.timestamp || 0) > FLAG_EXPIRY_MS) return null;
        return d;
      } catch { return null; }
    }
    
    function writeFlag(fp, flag) {
      try { ensureStateDir(); fs.writeFileSync(fp, JSON.stringify(flag)); } catch {}
    }
    
    function emitWarning(msg) { console.log(JSON.stringify({ systemMessage: msg })); }
    function emitBlock(msg) { process.stderr.write(msg); process.exit(2); }
    
    let raw = '';
    process.stdin.setEncoding('utf8');
    process.stdin.on('data', d => { raw += d; });
    process.stdin.on('end', () => {
      let data;
      try { data = JSON.parse(raw); } catch { process.exit(0); }
      if (data.tool_name !== 'Read') process.exit(0);
    
      // String coercion: non-string input would throw on .trim() and fail-open.
      const filePath = String(data.tool_input?.file_path ?? '').trim();
      if (!filePath) process.exit(0);
    
      if (ALLOW_NON_CODE_EXT.test(filePath)) process.exit(0);
      if (ALLOW_CONFIG_PATTERNS.test(path.basename(filePath))) process.exit(0);
      if (ALLOW_PATH_PATTERNS.test(filePath)) process.exit(0);
      if (ALLOW_TEST_PATTERNS.test(filePath)) process.exit(0);
      if (!CODE_EXTENSIONS.test(filePath)) process.exit(0);
    
      const flagPath = getFlagPath();
      const flag = readFlag(flagPath);
    
      if (!flag || !flag.warmup_done) {
        const warmupLines = buildWarmupInstructions('  ').join('\n');
        const concrete = buildConcreteCall(filePath);
        emitBlock(
          `⛔ LSP-FIRST BLOCK (Gate 1 — Warmup Required)\n\n` +
          `Read on code file requires prior LSP warmup.\n\n` +
          `WARMUP PROTOCOL — call one of these first:\n` +
          `${warmupLines}\n` +
          concrete +
          `\nAfter warmup: ${FREE_READS} free Reads, then need LSP navigation.\n\n` +
          `Blocked: ${filePath}\n`
        );
      }
    
      const readFiles = Array.isArray(flag.read_files) ? flag.read_files : [];
      const navCount = flag.nav_count || 0;
      const alreadyRead = readFiles.includes(filePath);
      const nextReadNum = alreadyRead ? readFiles.length : readFiles.length + 1;
    
      if (navCount >= 2 || alreadyRead) {
        if (!alreadyRead) {
          readFiles.push(filePath);
          flag.read_files = readFiles;
          flag.read_count = readFiles.length;
          flag.timestamp = Date.now();
          writeFlag(flagPath, flag);
        }
        process.exit(0);
      }
    
      if (nextReadNum <= FREE_READS) {
        readFiles.push(filePath);
        flag.read_files = readFiles;
        flag.read_count = readFiles.length;
        flag.timestamp = Date.now();
        writeFlag(flagPath, flag);
        process.exit(0);
      }
    
      if (nextReadNum === WARN_AT && navCount === 0) {
        emitWarning(
          `⚠️ LSP-FIRST WARNING (Read ${nextReadNum}) — consider LSP navigation.\n` +
          `Use find_workspace_symbols / find_references before more Reads.\n` +
          `Next Read will be BLOCKED unless you use at least 1 LSP nav call.\n` +
          `After 2 nav calls, all Reads are unlimited (surgical mode).`
        );
        readFiles.push(filePath);
        flag.read_files = readFiles;
        flag.read_count = readFiles.length;
        flag.timestamp = Date.now();
        writeFlag(flagPath, flag);
        process.exit(0);
      }
    
      if (nextReadNum < REQUIRE_NAV_2_AT && navCount < 1) {
        emitBlock(
          `⛔ LSP-FIRST BLOCK (Gate 4 — LSP Navigation Required)\n\n` +
          `Read #${nextReadNum} requires at least 1 LSP navigation call.\n` +
          `After 1 nav call, Reads 4-5 unlock. After 2, unlimited.\n` +
          buildConcreteCall(filePath) +
          `\nBlocked: ${filePath}\n`
        );
      }
    
      if (nextReadNum >= REQUIRE_NAV_2_AT && navCount < 2) {
        emitBlock(
          `⛔ LSP-FIRST BLOCK (Gate 5 — Surgical Mode Required)\n\n` +
          `Read #${nextReadNum} requires at least 2 LSP navigation calls.\n` +
          `Current: ${navCount} nav calls. Need 2.\n` +
          buildConcreteCall(filePath) +
          `\nBlocked: ${filePath}\n`
        );
      }
    
      readFiles.push(filePath);
      flag.read_files = readFiles;
      flag.read_count = readFiles.length;
      flag.timestamp = Date.now();
      writeFlag(flagPath, flag);
      process.exit(0);
    
  • hooks/lsp-pre-delegation.jsGitHub
    Read the script
    #!/usr/bin/env node
    'use strict';
    
    const fs = require('fs');
    const path = require('path');
    
    const FORCE_LSP_CONTEXT_AGENTS = [
      'backend-explorer', 'frontend-explorer', 'db-explorer',
    ];
    
    const EXEMPT_AGENTS = [
      'explore', 'security-reviewer', 'performance-reviewer', 'conventions-reviewer',
      'conflict-detector', 'code-auditor', 'lint-types-checker', 'test-runner',
      'code-reviewer', 'go-reviewer', 'doc-updater', 'architect', 'planner',
      'deep-security-reviewer', 'typescript-reviewer', 'python-reviewer',
      'ai-integration-reviewer', 'supabase-auth-reviewer', 'scraper-reviewer',
      'nextjs-static-reviewer', 'build-error-resolver', 'e2e-runner',
      'performance-optimizer', 'tdd-guide',
    ];
    
    let input = '';
    process.stdin.setEncoding('utf8');
    process.stdin.on('data', d => { input += d; });
    process.stdin.on('end', () => {
      let data;
      try { data = JSON.parse(input); } catch { process.exit(0); }
      if (data.tool_name !== 'Agent') process.exit(0);
    
      const toolInput = data.tool_input || {};
      // String coercion: non-string fields would throw on subsequent string methods.
      const prompt = String(toolInput.prompt ?? '');
      const subagentType = String(toolInput.subagent_type ?? '');
      const isForcedExplorer = FORCE_LSP_CONTEXT_AGENTS.includes(subagentType);
    
      if (!isForcedExplorer) {
        // Exact match only — previously `.includes(e)` allowed substring matches
        // like `exploit-deep-security-reviewer` to bypass by containing a
        // legitimate exempt name. Both checks now case-insensitive exact.
        const subType = subagentType.toLowerCase();
        if (EXEMPT_AGENTS.some(e => e.toLowerCase() === subType)) process.exit(0);
      }
    
      if (prompt.length < 200) process.exit(0);
    
      const isolation = String(toolInput.isolation ?? '');
      const cwd = String(data.cwd ?? process.cwd());
      const taskDir = path.join(cwd, '.task');
    
      if (!isForcedExplorer && isolation !== 'worktree') {
        if (!fs.existsSync(taskDir)) process.exit(0);
      }
    
      let inImplementPhase = isForcedExplorer || isolation === 'worktree';
    
      if (!inImplementPhase) {
        try {
          const entries = fs.readdirSync(taskDir).filter(e => {
            return e.startsWith('20') && fs.statSync(path.join(taskDir, e)).isDirectory();
          });
          const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
          for (const entry of entries) {
            const folderPath = path.join(taskDir, entry);
            const stat = fs.statSync(folderPath);
            if (stat.mtimeMs < twoHoursAgo) continue;
            const statePath = path.join(folderPath, 'state.json');
            if (fs.existsSync(statePath)) {
              try {
                const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
                if (state.phase === 'implement') { inImplementPhase = true; break; }
              } catch {}
            }
            const taskMd = path.join(folderPath, '00-task.md');
            if (fs.existsSync(taskMd)) {
              try {
                const content = fs.readFileSync(taskMd, 'utf8');
                if (/\*{0,2}Phase\*{0,2}:\*{0,2}\s*implement/i.test(content)) { inImplementPhase = true; break; }
              } catch {}
            }
          }
        } catch {}
      }
    
      if (!inImplementPhase) process.exit(0);
    
      const hasLspContext =
        /\bLSP CONTEXT\b/i.test(prompt) ||
        /\bSymbol Map\b/i.test(prompt) ||
        /\bdefined\s+at\s+[\w\-\/]+\.\w{2,4}:\d+/i.test(prompt) ||
        /\bcalled\s+from\s+[\w\-\/]+\.\w{2,4}:\d+/i.test(prompt) ||
        /\bused\s+in\s+[\w\-\/]+\.\w{2,4}:\d+/i.test(prompt) ||
        /\bimported\s+(?:in|by)\s+[\w\-\/]+\.\w{2,4}:\d+/i.test(prompt);
    
      if (hasLspContext) process.exit(0);
    
      const agentLabel = isForcedExplorer ? `explorer "${subagentType}"` : 'implement agent';
    
      const decision = (isForcedExplorer || isolation === 'worktree') ? 'block' : 'warn';
      console.log(JSON.stringify({
        decision,
        reason: [
          `LSP PRE-DELEGATION: ${agentLabel} without "## LSP CONTEXT".`,
          '',
          'DO THIS NOW (3 steps, then retry the Agent call):',
          '1. mcp__cclsp__get_diagnostics("<any .ts file>")  — primes LSP',
          '2. mcp__cclsp__find_workspace_symbols("<keyword from task>")  — finds symbols',
          '3. Add to EVERY agent prompt:',
          '   ## LSP CONTEXT (pre-resolved — do NOT re-search)',
          '   - symbolName: defined at file.ts:42, called from a.ts:15',
          '',
          'Then re-launch the same Agent calls with ## LSP CONTEXT included.',
        ].join('\n'),
      }));
    });
    
  • hooks/lsp-session-reset.jsGitHub
    Read the script
    #!/usr/bin/env node
    'use strict';
    
    /**
     * lsp-session-reset.js — SessionStart hook
     *
     * Wipes stale LSP navigation state for the current cwd at session start.
     *
     * Without this, `nav_count` persists for 24h across sessions (see
     * lsp-first-read-guard.js FLAG_EXPIRY_MS). A new session can inherit
     * "surgical mode" (nav_count >= 2) from previous work and freely Read
     * code files without ever calling LSP — a full bypass of the LSP-first
     * enforcement chain.
     *
     * After reset:
     *   - Gate 1 (warmup): first code Read BLOCKED until mcp__cclsp__get_diagnostics
     *   - Gate 4: read #4 BLOCKED unless nav_count >= 1
     *   - Gate 5: read #6 BLOCKED unless nav_count >= 2
     *
     * Side-effect: first session call forces one warmup (~1 LSP call). Cheap.
     */
    
    const fs = require('fs');
    const path = require('path');
    const os = require('os');
    const crypto = require('crypto');
    
    const STATE_DIR = path.join(os.homedir(), '.claude', 'state');
    
    function getFlagPath(cwd) {
      const hash = crypto.createHash('md5').update(cwd).digest('hex').slice(0, 12);
      return path.join(STATE_DIR, `lsp-ready-${hash}`);
    }
    
    let raw = '';
    process.stdin.setEncoding('utf8');
    process.stdin.on('data', d => { raw += d; });
    process.stdin.on('end', () => {
      let cwd = process.cwd();
      try {
        const data = JSON.parse(raw || '{}');
        if (data.cwd && typeof data.cwd === 'string') cwd = data.cwd;
      } catch { /* ignore */ }
    
      const flagPath = getFlagPath(cwd);
    
      try {
        if (fs.existsSync(flagPath)) {
          fs.unlinkSync(flagPath);
        }
      } catch { /* silent: hook must never block session start */ }
    
      process.exit(0);
    });
    
  • hooks/lsp-usage-tracker.jsGitHub

All 7 scripts are listed above. The source is inlined for 6 of them, starting with whatever hooks.json actually runs. See all of them in the repo.

Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.

Ships withclaude-code-lsp-enforcement-kit

Hooks that force Claude Code to use LSP instead of Grep for code navigation. Saves ~80% tokens

Get the whole plugin
Stats
325
Stars
24
Forks
Maintained
Maintenance
JavaScript
Language
MIT
License
3mo ago
Last commit
4mo ago
Created

Repo: nesaminua/claude-code-lsp-enforcement-kit