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
3287 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.

@nesaminua
Ships withclaude-code-lsp-enforcement-kit

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

Stars on GitHub

Momentum

stars in total
328
Star history of nesaminua/claude-code-lsp-enforcement-kit on GitHub, a chart by star-history.com

More plugins that ship hooks.

superpowers

superpowers

A development methodology for coding agents that makes the agent brainstorm before building, follow plans, run parallel work and finish branches in a disciplined way. You get fewer skipped steps and more consistent results from your coding assistant.

15 skills1 hook311k
@obra@obra
Install Plugin
ponytail

ponytail

A Claude Code plugin with skills for auditing a whole repo, reviewing a change, listing deferred shortcut comments, and showing measured savings and a quick command reference.

6 skills10 hooks188k
@dietrichgebert@dietrichgebert
Install Plugin
agent-skills

agent-skills

Walks a software change from a written spec through design, testing, code review, CI/CD, and release, applying the matching engineering skill at each stage. Useful for developers using Claude Code on full development workflows.

25 skills+3112k
@addyosmani@addyosmani
Install Plugin
planning-with-files

planning-with-files

Persistent file-based planning for AI coding agents on long-running, multi-step tasks. It keeps task_plan.md, findings.md and progress.md on disk, so work can resume after /clear or context compaction.

6 skills+228k
@othmanadi@othmanadi
Install Plugin
gsd-core

gsd-core

A spec-driven development workflow for Claude Code that guides a project through planning, phases, milestone audits and fixes. Includes skills to generate tests from UAT criteria and to audit completed work.

72 skills+311k
@open-gsd@open-gsd
Install Plugin
superpowers-zh

superpowers-zh

Chinese-language skill pack for Claude Code that adds workflow guidance for brainstorming, TDD, debugging, and code review, plus Chinese conventions for commits, documentation, and domestic Git platforms like Gitee and Coding.net.

21 skills1 hook8.5k
@jnmetacode@jnmetacode
Install Plugin
socraticode

socraticode

Gives Claude Code semantic search, dependency graph and architecture context over a codebase, so an AI assistant can explore and understand a project's code. Includes skills for indexing a project and managing its file watching.

2 skills+33.4k
@giancarloerra@giancarloerra
Install Plugin
cocoindex-code

cocoindex-code

A lightweight code search engine that uses AST-based semantic search with local embeddings and incremental indexing. It helps Claude Code users find code faster and more efficiently.

1 skill1 MCP+13k
@cocoindex-io@cocoindex-io
Install Plugin

Questions about the hooks in claude-code-lsp-enforcement-kit.

Which coding agents can use the hooks in claude-code-lsp-enforcement-kit?
The hooks in claude-code-lsp-enforcement-kit install in Claude Code and Cursor: each runs Claude Code hooks from .claude/settings.json, so copy the hooks there.