Hooks
What razor runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
$ npx -y skills add V-Songbird/razor --agent claude-codeShips with razor. Installing the plugin gets these hooks.
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.
- Matches
startup|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
Where it lives
- 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. ` + 'Deletion-positive diffs are the goal — 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; } // Parse one shell segment; returns {manager, packages} when it adds a new // named dependency, null otherwise. function parseSegment(segment) { const tokens = segment.trim().split(/\s+/).filter(Boolean); // 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. while (tokens.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0]) || ['sudo', 'env', 'command'].includes(tokens[0]))) { tokens.shift(); } if (!tokens.length) return null; let cmd = tokens.shift().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 install … if (cmd === 'uv' && tokens[0] === 'pip') { cmd = 'pip'; tokens.shift(); } // yarn global add … → yarn add … if (cmd === 'yarn' && tokens[0] === 'global') tokens.shift(); // dotnet add [proj] package Name if (cmd === 'dotnet' && tokens[0] === 'add') { const idx = tokens.indexOf('package'); if (idx !== -1 && tokens[idx + 1]) return { manager: 'dotnet', packages: [tokens[idx + 1]] }; return null; } const subs = ADD_SUBCOMMANDS[cmd]; if (!subs) return null; const sub = (tokens.shift() || '').toLowerCase(); if (!subs.includes(sub)) return null; if (/^pip3?$/.test(cmd) && tokens.some((t) => PIP_RESTORE_FLAGS.has(t))) return null; const packages = packageArgs(tokens); if (!packages.length) return null; // bare install = lockfile restore // `pip install --upgrade pip` upgrades the tool, it does not add a project // dependency. Same for any manager asked to install only itself. if (packages.length === 1 && packages[0].toLowerCase() === cmd) return null; return { manager: cmd, packages }; } // razor: parses the command exactly as the model issued it. hush's // preserve-exit-code.js rewrites Bash/PowerShell commands via updatedInput // under bypassPermissions/HUSH_WRAP=1, but PreToolUse hooks from separate // plugins don't chain — each one gets the same original tool_input, never a // sibling's rewrite (verified live 2026-07-14). No unwrap step needed here. // Scan a whole command line (split on shell chaining) for a dependency add. function parseInstallCommands(command) { const hits = []; for (const segment of String(command || '').split(/&&|\|\||;|\|/)) { const hit = parseSegme - 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. // // 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 } = require('./razor-lib'); const { PROVENANCE, retryContract } = 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 }; } // "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 gate state, returns the deny reason or null. function check(data, state) { if (BUDGET <= 0) return null; // 0 or negative disables the meter if (data.tool_name !== 'Write') return null; const filePath = data.tool_input && data.tool_input.file_path; if (!filePath || isExemptPath(filePath)) return null; if (fs.existsSync(filePath)) return null; // overwrite/edit, not a new file const kind = classify(filePath); const { next, deny } = stepTurn(state.turn, turnKey(data), BUDGET, kind, RAW_CEILING); state.turn = next; if (!deny) return null; const noun = RAW_CEILING ? 'new file' : 'new production file'; return ( `razor: ${noun} #${next.count} this turn (budget ${BUDGET}). ` + placement(filePath) + otherKinds(next.kinds) + 'Rung 2 — does an existin - 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'); // 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(spec)) return null; const root = spec.startsWith - hooks/manifest-guard.jsGitHub
Read the script
'use strict'; // Gate (Write|Edit, via pre-tool-use.js) — soft gate on dependencies entering // through the manifest itself. // // The dep guard watches install commands and the import guard watches code, // but a dependency can also arrive by editing package.json or // requirements.txt directly — on some models that's the dominant path, and // it reaches the project without either gate speaking. This gate watches // that moment: a Write/Edit whose result adds a NEW name to the manifest's // dependency sections is denied once with the reuse-first reason; re-issuing // the same call passes. The reconsideration ledger is shared with the dep // and import guards — one nudge per dependency however it enters. // // Bounded on purpose: // - package.json (every dependency section), requirements.txt, and // pyproject.toml (PEP 621 plus poetry tables) only; other manifests are // covered when their install is attempted, // - fires only when the manifest already exists on disk (creating a fresh // manifest is scaffolding a project, not sneaking a dependency in), // - version bumps of existing entries never fire — only new names count, // - Edits are simulated against the on-disk content (old_string → // new_string), so fragments are judged by the file they would produce; // anything unparseable stays silent — never a false deny. const fs = require('fs'); const path = require('path'); const { settingOff } = require('./razor-lib'); const { installedDeps, evidenceReason, ledgerName, pyprojectDepNames } = require('./dep-guard'); // null = unparseable (caller stays silent), Set otherwise. // The same four sections readNodeDeps counts, and for the same reason: if the // two disagreed, moving a package from optionalDependencies to dependencies // would read as a brand-new name and deny an edit that adds nothing. function jsonDepNames(text) { try { const pkg = JSON.parse(text); return new Set( Object.keys({ ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}), ...(pkg.optionalDependencies || {}), ...(pkg.peerDependencies || {}), }).map((n) => n.toLowerCase()) ); } catch { return null; } } function reqDepNames(text) { const names = new Set(); for (const line of String(text || '').split(/\r?\n/)) { const t = line.trim(); if (!t || t.startsWith('#') || t.startsWith('-')) continue; const name = t.split(/[<>=!~;\[\s@(]/)[0].trim(); if (name) names.add(name.toLowerCase()); } return names; } const GUARDED = { 'package.json': { eco: 'node', manager: 'npm', extract: jsonDepNames }, 'requirements.txt': { eco: 'python', manager: 'pip', extract: reqDepNames }, // A modern python project may declare everything here and never own a // requirements.txt, which left the manifest-edit path ungated for it — // the exact path this gate exists to cover. 'pyproject.toml': { eco: 'python', manager: 'pip', extract: pyprojectDepNames }, }; function denyReason(tool, names, eco, manifestName, deps) { const what = names.map((n) => `\`${n}\``).join(', '); return evidenceReason( `razor: this ${tool} to ${manifestName} adds a new ${eco} dependency (${what}) without an install. `, deps, tool ); } // The resulting manifest content this tool call would produce, or null when // the call cannot land as written (the Edit would fail anyway). function simulate(toolName, input, existing) { if (toolName === 'Write') return input.content || null; const oldStr = input.old_string; const newStr = input.new_string; if (!oldStr || newStr === undefined || !existing.includes(oldStr)) return null; return input.replace_all ? existing.split(oldStr).join(newStr) : existing.replace(oldStr, newStr); } // Dispatcher entry: mutates gate state, returns the deny reason or null. function check(data, state) { if (settingOff('MANIFEST_GUARD')) return null; if (data.tool_name !== 'Write' && data.tool_name !== 'Edit') return null; const input = data.tool_input || {}; const filePath = input.file_path; if (!filePath || /node_modules/.test(filePath)) return null; const spec = GUARDED[path.basename(filePath).toLowerCase()]; if (!spec) return null; let existing; try { existing = fs.readFileSync(path.resolve(filePath), 'utf-8'); } catch { return null; // no manifest on disk — greenfield scaffolding stays ungated } const resulting = simulate(data.tool_name, input, existing); if (!resulting) return null; const before = spec.extract(existing); const after = spec.extract(resulting); if (!before || !after) return null; // unparseable side — stay silent const fresh = [...after].filter((n) => !before.has(n)).sort(); if (!fresh.length) return null; state.deniedImports = state.deniedImports || {}; const unseen = fresh.filter((n) => !state.deniedImports[`${spec.eco}:${ledgerName(n)}`]); if (!unseen.length) return null; // all already reconsidered — pass silently for (const n of unseen) state.deniedImports[`${spec.eco}:${ledgerName(n)}`] = true; const deps = installedDeps(spec.manager, path.dirname(path.resolve(filePath))); return denyReason(data.tool_name, unseen, spec.eco, path.basename(filePath), deps); } module.exports = { check, jsonDepNames, reqDepNames, simulate, GUARDED }; - hooks/mode-toggle.jsGitHub
- hooks/pre-tool-use.jsGitHub
- hooks/razor-lib.jsGitHub
- hooks/session-start.jsGitHub
- hooks/subagent-start.jsGitHub
All 11 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.
YAGNI checks for Claude Code and Codex: reconsider dependencies, file growth and unnecessary code before adding more.
Repo: V-Songbird/razor

