Skip to content
Development
Hook

Hooks

What razor runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.

From plugin
razor
81 skill5 hooks

Claude Code

$ npx -y skills add V-Songbird/razor --agent claude-code

What fires, and when

SessionStart

Fires once when a session begins, and again after a context compaction. It is where a plugin sets up its environment, or restores state the compaction dropped.

  • Matchesstartup|resume|clear|compact|forknode

SubagentStart

  • node

PreToolUse

  • Matches^(Bash|PowerShell|Edit|Write)$node

Stop

  • node

UserPromptSubmit

Fires before Claude sees each prompt you send. A plugin can use it to inject context, so the same instruction reaches the model every turn instead of only at session start.

  • node
Read hooks/hooks.json

Where it lives

  • hooks/antigravity-hook.jsGitHub
    Read the script
    #!/usr/bin/env node
    'use strict';
    
    // Antigravity entrypoint. Select the native adapter explicitly; shared gates
    // do not detect hosts. One process per event, one JSON answer per process.
    //
    // Antigravity has no session, prompt or subagent event, so its three events
    // carry razor as follows. PreInvocation fires before every model call, and an
    // injected message reaches only the call it rides, so every call carries the
    // ladder, as SessionStart context does in every Claude Code request, and the
    // drift note, as UserPromptSubmit does, except on the calls that answer the
    // build check's continuation. Only the first call a conversation
    // sees takes the ledger baseline. A turn starts at the call whose
    // invocationNum is 0: the host restarts that count on each user turn, but not
    // when a Stop continues the model, and a turn can end without a Stop. The
    // turns counted in the session state key the per-turn file budget. PreToolUse
    // runs the same gates on the translated call. Stop only runs the build check,
    // and has the model continue only once its once-per-session mark is on disk;
    // the turn it continues then goes without the drift note.
    process.env.RAZOR_HOST = 'antigravity';
    
    const harness = require('./lib/antigravity-harness');
    const { RULESET, readState, writeState, isActive, emitContext } = require('./razor-lib');
    
    function preInvocation(data) {
      const seen = readState(data.session_id);
      if (!isActive(seen)) return harness.flush('PreInvocation');
      if (data.invocationNum === 0) {
        seen.antigravityTurn = (seen.antigravityTurn || 0) + 1;
        writeState(data.session_id, seen);
      }
      if (seen.ladderInjected) {
        emitContext('PreInvocation', RULESET);
      } else {
        require('./session-start').main();
        // Read again: session-start may have saved the ledger baseline meanwhile.
        const state = readState(data.session_id);
        state.ladderInjected = true;
        writeState(data.session_id, state);
      }
      // The calls that answer the build check come after the turn's own answer, and
      // with the note beside that question the model took it for a later request.
      if (seen.continuedTurn !== (seen.antigravityTurn || 0)) require('./mode-toggle').main();
      harness.flush('PreInvocation');
    }
    
    function preToolUse(data) {
      data.turn_id = String(readState(data.session_id).antigravityTurn || 0);
      require('./pre-tool-use').main();
      harness.flush('PreToolUse');
    }
    
    function stop(data) {
      require('./build-ledger').main();
      const state = readState(data.session_id);
      const persisted = Boolean(state.ledger && state.ledger.fired === true);
      // The build check fires once per conversation, so the first Stop that finds
      // its mark is the one that continues the model.
      if (persisted && state.continuedTurn === undefined) {
        state.continuedTurn = state.antigravityTurn || 0;
        writeState(data.session_id, state);
      }
      harness.flush('Stop', { persisted });
    }
    
    const EVENTS = { PreInvocation: preInvocation, PreToolUse: preToolUse, Stop: stop };
    
    function main() {
      const event = process.argv[2];
      if (!Object.hasOwn(EVENTS, event)) return;
      const data = harness.readInput();
      // No conversation means no state to keep: allow the call, inject nothing,
      // let the stop stand.
      if (typeof data.session_id !== 'string' || !data.session_id.trim()) return harness.flush(event);
      EVENTS[event](data);
    }
    
    if (require.main === module) main();
    module.exports = { main, EVENTS };
    
  • hooks/build-ledger.jsGitHub
    Read the script
    #!/usr/bin/env node
    'use strict';
    
    // Stop — build ledger: threshold-gated outcome meter.
    //
    // The gates prevent; this measures. At turn end, compare the working tree
    // against the SessionStart snapshot (base commit + untracked count). If the
    // session looks like sprawl — large insertion-heavy diff with almost no
    // deletions, or many new files — inject one question, once per session.
    // Silent while the session behaves; the thresholds are generous on purpose
    // so a legitimately large requested task never trips it.
    
    const { readInput, emitContext, readState, writeState, isActive, settingOff, settingNumber, git } = require('./razor-lib');
    const { classify } = require('./file-meter');
    
    const LOC_BUDGET = (() => {
      const n = settingNumber('LEDGER_LOC', 500);
      return n > 0 ? n : 500;
    })();
    
    const FILES_BUDGET = (() => {
      const n = settingNumber('LEDGER_FILES', 8);
      return n > 0 ? n : 8;
    })();
    
    // Sprawl = big net growth with next-to-no deletion, or a pile of new files.
    // A large diff that also deletes a lot is refactoring, not sprawl.
    function shouldFire(stats, locBudget, filesBudget) {
      const sprawlLoc =
        stats.insertions - stats.deletions > locBudget && stats.deletions < stats.insertions * 0.1;
      return sprawlLoc || stats.newFiles > filesBudget;
    }
    
    // Sum a --numstat block, skipping the paths the ledger never charges. Both
    // the session-start baseline and the turn-end tally go through here, so a
    // path exempt from one is exempt from the other — an asymmetry would have the
    // baseline subtract lines the tally never counted, and the meter would go
    // quiet for the rest of the session.
    function tally(numstat, skip = () => false) {
      let insertions = 0;
      let deletions = 0;
      for (const line of (numstat || '').split('\n')) {
        const [ins, del, file] = line.split('\t');
        if (!file || skip(file) || isUncounted(file)) continue;
        insertions += parseInt(ins, 10) || 0;
        deletions += parseInt(del, 10) || 0;
      }
      return { insertions, deletions };
    }
    
    // The session's own delta: the working tree vs the base commit, minus the
    // dirt that was already there when the session started. Files that were
    // untracked at session start are excluded by NAME — staging or committing
    // them mid-session must not move their content onto the session's bill.
    // razor: count-level subtraction for edits to pre-existing files; per-line
    // attribution needs a full diff snapshot at session start.
    // A regenerated lockfile is thousands of insertions nobody wrote, and it lands
    // with almost no deletions -- exactly the shape shouldFire reads as sprawl.
    // The benchmark runner's own diff metric already skips these; the ledger has
    // to as well or a routine dependency update ends the session with a question
    // about code the agent never authored.
    // git reports every path with forward slashes, so the name is what is after
    // the last one.
    function isLockfile(file) {
      const name = file.slice(file.lastIndexOf('/') + 1).toLowerCase();
      return name.endsWith('.lock') || name.endsWith('-lock.json') || name.endsWith('-lock.yaml');
    }
    
    // Prose is not sprawl. file-meter already classifies a new docs file and
    // refuses to charge it against the file budget; the ledger has to agree, or a
    // repo that mandates ADR amendments and doc comments ends every session with
    // a question about lines nobody would want cut. classify() owns the path
    // shapes so the two meters cannot drift apart.
    function isUncounted(file) {
      return isLockfile(file) || classify(file) === 'docs';
    }
    
    function diffStats(ledger, cwd) {
      const numstat = git(['diff', '--numstat', ledger.baseSha], cwd);
      if (numstat === null) return null; // base sha gone (rebase) or not a repo
      const baseNames = new Set(ledger.baseUntrackedFiles || []);
      let { insertions, deletions } = tally(numstat, (file) => baseNames.has(file));
      insertions = Math.max(0, insertions - (ledger.baseInsertions || 0));
      deletions = Math.max(0, deletions - (ledger.baseDeletions || 0));
    
      const list = (s) => (s || '').split('\n').filter(Boolean);
      const added = list(git(['diff', '--diff-filter=A', '--name-only', ledger.baseSha], cwd));
      const untracked = list(git(['ls-files', '--others', '--exclude-standard'], cwd));
      const fresh = [...new Set([...added, ...untracked])].filter(
        (f) => !baseNames.has(f) && !isUncounted(f)
      );
      const newFiles = Math.max(0, fresh.length - (ledger.baseAdded || 0));
    
      return { insertions, deletions, newFiles };
    }
    
    function main() {
      if (settingOff('LEDGER')) return;
      const data = readInput();
      const state = readState(data.session_id);
      if (!isActive(state)) return;
    
      const ledger = state.ledger;
      if (!ledger || !ledger.baseSha || ledger.fired) return;
    
      const stats = diffStats(ledger, data.cwd);
      if (!stats || !shouldFire(stats, LOC_BUDGET, FILES_BUDGET)) return;
    
      ledger.fired = true;
      writeState(data.session_id, state);
    
      emitContext(
        'Stop',
        `razor ledger: +${stats.insertions} / -${stats.deletions} LOC, ` +
          `${stats.newFiles} new files since session start. ` +
          'Is all of this needed? ' +
          '(fires once per session; RAZOR_LEDGER=off to silence)'
      );
    }
    
    if (require.main === module) main();
    
    module.exports = { main, shouldFire, diffStats, tally, isUncounted };
    
  • hooks/codex-hook.jsGitHub
    Read the script
    #!/usr/bin/env node
    'use strict';
    
    // Select the native adapter explicitly; shared gates do not detect hosts.
    process.env.RAZOR_HOST = 'codex';
    
    const EVENTS = {
      SessionStart: './session-start',
      SubagentStart: './subagent-start',
      PreToolUse: './pre-tool-use',
      Stop: './build-ledger',
      UserPromptSubmit: './mode-toggle',
    };
    
    function main() {
      const event = process.argv[2];
      if (!Object.hasOwn(EVENTS, event)) return;
      const data = require('./lib/codex-harness').readInput();
      if (typeof data.session_id !== 'string' || !data.session_id.trim()) return;
      if (data.hook_event_name !== event) return;
      require(EVENTS[event]).main();
    }
    
    if (require.main === module) main();
    module.exports = { main, EVENTS };
  • hooks/dep-guard.jsGitHub
    Read the script
    'use strict';
    
    // Gate (Bash|PowerShell, via pre-tool-use.js) — soft gate on new-dependency
    // installs.
    //
    // The first attempt to install a named package is denied with the
    // reuse-first reason (rungs 3–5); re-running the same install passes. One
    // forced reconsideration per dependency, never a hard block, and razor
    // never *grants* permission — on the pass path it stays silent so the
    // user's normal permission flow still applies.
    //
    // Only project-dependency managers are guarded. Lockfile restores
    // (`npm install` bare, `npm ci`, `pip install -r ...`, `poetry install`)
    // and system package managers (apt, brew, winget) are out of scope.
    
    const fs = require('fs');
    const path = require('path');
    const { settingOff } = require('./razor-lib');
    
    // manager → subcommands that add a named package
    const ADD_SUBCOMMANDS = {
      npm: ['install', 'i', 'add'],
      pnpm: ['install', 'i', 'add'],
      yarn: ['add'],
      bun: ['add', 'install', 'i'],
      pip: ['install'],
      pip3: ['install'],
      pipenv: ['install'],
      poetry: ['add'],
      uv: ['add'],
      cargo: ['add'],
      go: ['get'],
      composer: ['require'],
      gem: ['install'],
    };
    
    // pip args that mean "restore/develop", not "add a new dependency"
    const PIP_RESTORE_FLAGS = new Set(['-r', '--requirement', '-e', '--editable']);
    
    // Flags that take their value as the NEXT token. Left alone, that value is
    // read as a package name and the deny reason invents a dependency nobody
    // asked for. Only the separated form needs this — `--flag=value` is one
    // token and already skipped as a flag.
    const VALUE_FLAGS = new Set([
      '-t', '--target', '-i', '--index-url', '--extra-index-url', '-f', '--find-links',
      '-c', '--constraint', '--python', '--prefix', '--registry', '--tag',
      '-w', '--workspace', '--features', '--manifest-path',
      '--group', '--filter', '--branch', '--rev',
      // `cargo add -p <member> <dep>` and `uv pip install -p 3.12 <dep>` both put a
      // value here that is not a package. Reading it as one denies a name nobody
      // installed, which is the expensive direction.
      '-p', '--package',
    ]);
    
    // A local path or a URL is a location, not a name from a registry. Denying
    // one names a package that does not exist, and the suppressing direction is
    // the safe one: a missed nudge costs nothing, a false deny costs a turn.
    function isLocationSpec(a) {
      return (
        /^\.{1,2}[\\/]/.test(a) || a === './...' || a.startsWith('/') || a.startsWith('~/')
        || /^[A-Za-z]:[\\/]/.test(a) || a.includes('://') || a.startsWith('file:')
      );
    }
    
    // Flags, `.`, locations, and shell redirects are not package names. A bare
    // redirect operator (`>`, `2>`) also consumes the following token — its
    // target. Quotes come off first: the shell strips them before the manager
    // ever sees the token, and a version spec must be quoted in a real shell
    // (`pip install 'flask>=2.1'`), so `'flask>=2.1'` and `flask>=2.1` are
    // the same package.
    function packageArgs(args) {
      const out = [];
      let skipNext = false;
      for (const raw of args) {
        if (skipNext) {
          skipNext = false;
          continue;
        }
        const a = raw.replace(/^['"]+|['"]+$/g, '');
        if (!a || a === '.') continue;
        if (a.startsWith('-')) {
          if (VALUE_FLAGS.has(a)) skipNext = true;
          continue;
        }
        const redirect = a.match(/^\d*(?:>>?|<<?|&>>?)(.*)$/);
        if (redirect) {
          if (!redirect[1]) skipNext = true;
          continue;
        }
        if (isLocationSpec(a)) continue;
        out.push(a);
      }
      return out;
    }
    
    // A command another shell runs: `bash -c '<command>'` (also sh, zsh and an
    // option cluster ending in c such as -lc), `pwsh -Command "<command>"` (also
    // powershell and -c, after other switches) and `cmd /c "<command>"` (also
    // /k). PowerShell and cmd also run an unquoted rest of the line; a POSIX
    // shell's -c takes one word, so only its quoted form holds a command line.
    const QUOTED = String.raw`(?:'(?<single>[^']*)'|"(?<double>(?:\\.|[^"\\])*)")`;
    const WRAPPERS = [
      new RegExp(String.raw`^(?:sh|bash|zsh)\s+-[a-z]*c\s+${QUOTED}`),
      new RegExp(String.raw`^(?:pwsh|powershell)(?:\.exe)?(?:\s+-(?!(?:c|command)\b)\w+(?:\s+[^\s"'-]\S*)?)*\s+-(?:c|command)\s+(?:${QUOTED}|(?<bare>.+))`, 'is'),
      new RegExp(String.raw`^cmd(?:\.exe)?(?:\s+\/[a-z])*\s+\/[ck]\s+(?:${QUOTED}|(?<bare>.+))`, 'is'),
    ];
    
    // Parse one shell segment; returns each {manager, packages} it adds as a new
    // named dependency, or [] when it adds none.
    function parseSegment(segment) {
      // Wrapper prefixes (`sudo pip …`, `env PIP_X=1 pip …`, `command pip …`)
      // resolve to the same install; strip them so the manager is what's judged.
      // So do PowerShell's call operator (`& npm …`) and the braces of a script
      // block or brace group (`{ npm … }`), whose `}` may close a later segment.
      const rest = segment.trim()
        .replace(/^(?:(?:[A-Za-z_][A-Za-z0-9_]*=\S*|sudo|env|command|&)(?:\s+|$)|\{\s*)*/, '')
        .replace(/(?<!\$\{[^\s{}]*)\s*\}$/, '');
    
      // Another shell's command is a command line of its own, line breaks
      // included. Inside double quotes the shell drops the backslash before
      // " \ $ and `, so the parser does too.
      for (const wrapper of WRAPPERS) {
        const found = rest.match(wrapper);
        if (!found) continue;
        const { single, double, bare } = found.groups;
        return parseInstallCommands(single ?? bare ?? double.replace(/\\(["\\$`])/g, '$1'));
      }
    
      // A program named by its path, quoted or not, is still that program:
      // `& "C:\Program Files\nodejs\npm.cmd" install x` runs npm.
      const program = rest.match(/^(?:"([^"]*)"|'([^']*)'|(\S+))/);
      if (!program) return [];
      const tokens = rest.slice(program[0].length).split(/\s+/).filter(Boolean);
      let cmd = path.win32.basename(program[1] ?? program[2] ?? program[3]).toLowerCase().replace(/\.(exe|cmd)$/, '');
    
      // python -m pip install …  →  pip install …
      if ((cmd === 'python' || cmd === 'python3' || cmd === 'py') && tokens[0] === '-m' && /^pip3?$/.test(tokens[1] || '')) {
        cmd = tokens[1];
        tokens.splice(0, 2);
      }
      // uv pip install …  →  pip 
  • hooks/file-meter.jsGitHub
    Read the script
    'use strict';
    
    // Gate (Write, via pre-tool-use.js) — per-turn new-file shape check.
    //
    // A raw count of new files treats a migration, its test, its config and four
    // production modules as the same thing, which rewards stuffing complexity
    // into existing files instead. So the default budget counts PRODUCTION files
    // only: tests, fixtures, migrations, generated output, docs and config are
    // classified, reported, and never charged. A feature that ships with its
    // tests is not sprawl.
    //
    // Set RAZOR_FILE_BUDGET explicitly and that becomes a raw ceiling on every
    // new file — an operator who names a number gets the number they named.
    //
    // When the count crosses the budget, that one Write is denied with a rung-2
    // reason naming the shape; the retry and everything after it in the same turn
    // pass. One forced reconsideration per turn, self-clearing. Existing files
    // are never gated (edits and overwrites aren't sprawl), and temp and
    // scratchpad files are exempt, and so is a file Git reports as ignored.
    //
    // Known limit: files created via Bash heredocs bypass the Write tool and
    // this meter with them.
    
    const fs = require('fs');
    const os = require('os');
    const path = require('path');
    const { turnKey, settingNumber, settingGiven, git } = require('./razor-lib');
    const { PROVENANCE } = require('./dep-guard');
    
    const BUDGET = settingNumber('FILE_BUDGET', 4);
    // An explicitly named budget is an explicit ceiling: count everything.
    const RAW_CEILING = settingGiven('FILE_BUDGET');
    
    // Plural labels for the message. Anything not listed here is production.
    const UNCOUNTED = {
      test: 'tests',
      fixture: 'fixtures',
      migration: 'migrations',
      generated: 'generated files',
      docs: 'docs',
      config: 'config',
      asset: 'assets',
    };
    
    function norm(p) {
      return path.resolve(p).replace(/\\/g, '/').toLowerCase();
    }
    
    function isExemptPath(filePath) {
      const target = norm(filePath);
      const tmp = norm(os.tmpdir());
      return target === tmp || target.startsWith(tmp + '/') || target.includes('/scratchpad/');
    }
    
    // Path-shape classification. Order matters: generated output can live under a
    // test tree, and a fixture can live under a docs tree — the more specific
    // signal wins. Everything unrecognized is production, which is the fail-safe
    // direction: an unfamiliar layout still gets counted.
    function classify(filePath) {
      const p = norm(filePath);
      const name = p.slice(p.lastIndexOf('/') + 1);
    
      if (/(^|\/)(dist|build|out|coverage|node_modules|__generated__|generated)\//.test(p)) return 'generated';
      if (/\.min\.js$|\.d\.ts$|\.g\.dart$|\.generated\.|_pb2\.py$|\.pb\.go$/.test(p)) return 'generated';
    
      if (/(^|\/)(tests?|__tests__|specs?)\//.test(p)) return 'test';
      if (/^test_.+/.test(name) || /[._](test|spec)\.[a-z0-9]+$/.test(name)) return 'test';
    
      if (/(^|\/)(fixtures?|__fixtures__|testdata|mocks?|__mocks__|__snapshots__)\//.test(p)) return 'fixture';
      if (/\.snap$/.test(name)) return 'fixture';
    
      if (/(^|\/)(migrations?|migrate)\//.test(p)) return 'migration';
    
      if (/(^|\/)docs?\//.test(p) || /\.(md|mdx|rst|adoc|txt)$/.test(name)) return 'docs';
    
      if (/\.(json|ya?ml|toml|ini|cfg|conf|env|properties|lock)$/.test(name)) return 'config';
      if (/^\.[^/]+rc(\.[a-z]+)?$/.test(name) || /^(dockerfile|makefile)$/.test(name)) return 'config';
      if (/\.config\.[a-z]+$/.test(name)) return 'config';
      // A dotfile is tooling: .gitignore, .editorconfig, .env.example, .npmrc.
      // None of them is a module someone has to maintain.
      if (name.startsWith('.')) return 'config';
    
      // Icons, images, fonts and media are content, not code. Five icons used to
      // spend the whole production budget and deny the sixth write of the turn.
      if (/\.(svg|png|jpe?g|gif|ico|webp|avif|bmp|woff2?|ttf|otf|eot|mp4|webm|mp3|wav|pdf)$/.test(name)) return 'asset';
    
      return 'production';
    }
    
    // Pure budget step: given the previous turn state, the current turn key, the
    // budget and this file's kind, returns the next state and whether this Write
    // gets denied. Uncounted kinds are still tallied, so the message can say what
    // else the turn produced.
    function stepTurn(turn, key, budget, kind = 'production', countAll = false) {
      const next =
        turn && turn.turnKey === key
          ? { ...turn, kinds: { ...(turn.kinds || {}) } }
          : { turnKey: key, count: 0, fired: false, kinds: {} };
      next.kinds[kind] = (next.kinds[kind] || 0) + 1;
    
      const counts = countAll || kind === 'production';
      if (counts) next.count += 1;
      const deny = counts && next.count > budget && !next.fired;
      if (deny) next.fired = true;
      return { next, deny };
    }
    
    // A file Git ignores is not production code. One `git check-ignore -q` run from
    // the nearest existing folder above the file: exit 0 (git() returns '') means
    // ignored; exit 1, no git, no repository, an outside-the-repository path or a
    // timeout all return null, and the file is counted as before. The path goes to
    // Git relative to that folder, so a folder reached through a symlink or a
    // junction is not judged against its other spelling.
    function isGitIgnored(filePath) {
      const file = path.resolve(filePath);
      let dir = path.dirname(file);
      while (!fs.existsSync(dir)) {
        const up = path.dirname(dir);
        if (up === dir) return false;
        dir = up;
      }
      return git(['check-ignore', '-q', '--', path.relative(dir, file)], dir) !== null;
    }
    
    // "Already in this codebase?" is rung 2, and whether the directory exists is
    // the cheapest honest evidence of it.
    function placement(filePath) {
      const dir = path.dirname(filePath);
      const label = path.basename(dir) || dir;
      return fs.existsSync(dir)
        ? `It lands in an existing ${label}/. `
        : `It also creates a new directory, ${label}/. `;
    }
    
    function otherKinds(kinds) {
      const parts = Object.entries(kinds)
        .filter(([kind]) => kind !== 'production' && UNCOUNTED[kind])
        .map(([kind, n]) => `${n} ${UNCOUNTED[kind]}`);
      return parts.length ? `Also this turn, uncounted: ${parts.join(', ')}. ` : '';
    }
    
    // Dispatcher entry: mutates gat
  • hooks/import-guard.jsGitHub
    Read the script
    'use strict';
    
    // Gate (Write|Edit, via pre-tool-use.js) — soft gate on new dependencies
    // entering as code.
    //
    // Agents rarely run `npm install axios`; they write
    // `const axios = require('axios')` and move on — the install is a later or
    // human step, so a Bash-side gate never sees the moment the dependency is
    // actually chosen. This gate watches that moment: a Write/Edit whose payload
    // imports a package that is neither a builtin, a local file, nor declared in
    // the project manifest is denied once with the reuse-first reason (rungs
    // 3-5); re-issuing the same tool call passes. Deny-once per package, never a
    // hard block, silent on the pass path.
    //
    // Bounded on purpose:
    //   - fires ONLY when an ecosystem manifest exists up-tree (greenfield code
    //     with no declared-deps baseline stays ungated),
    //   - counts only imports the payload ADDS (anything the file already
    //     imports on disk is grandfathered),
    //   - JS/TS and Python only; other ecosystems are covered by the Bash-side
    //     dep-guard when an install is attempted,
    //   - test files are exempt (a test-framework import in a test is
    //     convention, not a shipped dependency).
    //
    // Known limit: name extraction is regex, not AST (a parser to police
    // dependency additions would be rung-5 irony), so exotic import forms may
    // slip through — the ladder still covers those in prompt-space.
    
    const fs = require('fs');
    const path = require('path');
    const { settingOff } = require('./razor-lib');
    const { installedDeps, evidenceReason, ledgerName } = require('./dep-guard');
    const { simulate } = require('./manifest-guard');
    
    // Node core modules — importing one is never a new dependency.
    const NODE_BUILTINS = new Set([
      'assert', 'async_hooks', 'buffer', 'child_process', 'cluster', 'console',
      'constants', 'crypto', 'dgram', 'diagnostics_channel', 'dns', 'domain',
      'events', 'fs', 'http', 'http2', 'https', 'inspector', 'module', 'net',
      'os', 'path', 'perf_hooks', 'process', 'punycode', 'querystring',
      'readline', 'repl', 'stream', 'string_decoder', 'test', 'timers', 'tls',
      'trace_events', 'tty', 'url', 'util', 'v8', 'vm', 'wasi',
      'worker_threads', 'zlib',
    ]);
    
    // Python stdlib top-level names (3.9+ common surface; additions are cheap).
    const PY_STDLIB = new Set([
      '__future__', 'abc', 'argparse', 'array', 'ast', 'asyncio', 'atexit',
      'base64', 'bdb', 'binascii', 'bisect', 'builtins', 'bz2', 'calendar',
      'cmath', 'cmd', 'code', 'codecs', 'collections', 'colorsys',
      'concurrent', 'configparser', 'contextlib', 'contextvars', 'copy',
      'copyreg', 'cProfile', 'csv', 'ctypes', 'curses', 'dataclasses',
      'datetime', 'dbm', 'decimal', 'difflib', 'dis', 'doctest', 'email',
      'encodings', 'ensurepip', 'enum', 'errno', 'faulthandler', 'fcntl', 'filecmp',
      'fileinput', 'fnmatch', 'fractions', 'ftplib', 'functools', 'gc',
      'getopt', 'getpass', 'gettext', 'glob', 'graphlib', 'grp', 'gzip',
      'hashlib', 'heapq', 'hmac', 'html', 'http', 'idlelib', 'imaplib', 'importlib',
      'inspect', 'io', 'ipaddress', 'itertools', 'json', 'keyword', 'linecache',
      'locale', 'logging', 'lzma', 'mailbox', 'marshal', 'math', 'mimetypes',
      'mmap', 'msvcrt', 'multiprocessing', 'netrc', 'ntpath', 'numbers',
      'operator', 'os',
      'pathlib', 'pdb', 'pickle', 'pickletools', 'pkgutil', 'platform',
      'plistlib', 'poplib', 'posixpath', 'pprint', 'profile', 'pstats', 'pty',
      'pwd', 'py_compile', 'pyclbr', 'pydoc', 'queue', 'quopri', 'random',
      're', 'readline', 'reprlib', 'resource', 'rlcompleter', 'runpy', 'sched',
      'secrets', 'select', 'selectors', 'shelve', 'shlex', 'shutil', 'signal',
      'site', 'smtplib', 'socket', 'socketserver', 'sqlite3', 'ssl', 'stat',
      'statistics', 'string', 'stringprep', 'struct', 'subprocess', 'symtable',
      'sys', 'sysconfig', 'syslog', 'tarfile', 'tempfile', 'termios', 'test',
      'textwrap', 'threading', 'time', 'timeit', 'token', 'tokenize', 'tomllib',
      'tkinter', 'trace', 'traceback', 'tracemalloc', 'tty', 'turtle',
      'turtledemo', 'types', 'typing',
      'unicodedata', 'unittest', 'urllib', 'uuid', 'venv', 'warnings', 'wave',
      'weakref', 'webbrowser', 'winreg', 'winsound', 'wsgiref', 'xml',
      'xmlrpc', 'zipapp', 'zipfile', 'zipimport', 'zlib', 'zoneinfo',
    ]);
    
    const JS_EXT = /\.(js|mjs|cjs|jsx|ts|tsx|mts|cts)$/i;
    const PY_EXT = /\.py$/i;
    
    function ecosystemOf(filePath) {
      if (JS_EXT.test(filePath)) return 'node';
      if (PY_EXT.test(filePath)) return 'python';
      return null;
    }
    
    // Same convention as the benchmark scorers and common runners: test files
    // never gate.
    // One pattern over the whole JS/TS extension family, so a `.test.tsx` or a
    // `.spec.mjs` is exempt for the same reason a `.test.js` is.
    const TEST_SUFFIX = /[._](test|spec)\.(js|mjs|cjs|jsx|ts|tsx|mts|cts)$/;
    
    function isTestFile(filePath) {
      const parts = String(filePath).split(/[\\/]/);
      const name = (parts[parts.length - 1] || '').toLowerCase();
      return (
        name.startsWith('test_') || name.endsWith('_test.py') || TEST_SUFFIX.test(name)
        || parts.some((p) => /^(test|tests|__tests__)$/i.test(p))
      );
    }
    
    // The package a specifier belongs to, or null when it can never be one
    // (relative, absolute, subpath-imports, or a runtime builtin).
    function specRoot(spec) {
      if (spec.startsWith('.') || spec.startsWith('/') || spec.startsWith('#')) return null;
      // `@/x` and `~/x` are the path-alias forms nearly every modern TS/JS setup
      // configures for its OWN source (tsconfig paths, Vite, Next, Remix, Nuxt).
      // Neither can name a package: an npm scope needs a name after the `@`, and
      // `~` is not a legal package name. Read as roots they became `@/components`
      // and `~`, and every internal import in such a project was denied as an
      // undeclared dependency. A missed nudge costs nothing; a false deny costs a
      // turn, and this one fired on ordinary local code.
      if (spec === '~' || spec.startsWith('~/') || spec.startsWith('@/')) return null;
      // A node:/bun: prefix can only resolve a runtime builtin, never a package.
      if (/^(node|bun):/.test(
  • hooks/manifest-guard.jsGitHub
  • hooks/mode-toggle.jsGitHub
  • hooks/pre-tool-use.jsGitHub
  • hooks/razor-lib.jsGitHub
  • hooks/session-start.jsGitHub
  • hooks/subagent-start.jsGitHub
  • hooks/windows-launcher.ps1GitHub

All 13 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.

@v-songbird
Ships withrazor

YAGNI checks for Claude Code and Codex: reconsider dependencies, file growth and unnecessary code before adding more.

Stars on GitHub

Momentum

stars in total
8
Star history of V-Songbird/razor 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 hook318k
@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 hooks191k
@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+3117k
@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+312k
@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 razor.

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