* perf(hooks): batch format+typecheck at Stop instead of per Edit Fixes #735. The per-edit post:edit:format and post:edit:typecheck hooks ran synchronously after every Edit call, adding 15-30s of latency per file — up to 7.5 minutes for a 10-file refactor. New approach: - post-edit-accumulator.js (PostToolUse/Edit): lightweight hook that records each edited JS/TS path to a session-scoped temp file in os.tmpdir(). No formatters, no tsc — exits in microseconds. - stop-format-typecheck.js (Stop): reads the accumulator once per response, groups files by project root and runs the formatter in one batched invocation per root, then groups .ts/.tsx files by tsconfig dir and runs tsc once per tsconfig. Clears the accumulator immediately on read so repeated Stop calls don't double-process. For a 10-file refactor: was 10 × (15s + 30s) = 7.5 min overhead, now 1 × (batch format + batch tsc) = ~5-30s total. * fix(hooks): address race condition, spawn timeout, and Windows path guard Three issues raised in code review: 1. Race condition: switched accumulator from non-atomic JSON read-modify-write to appendFileSync (one path per line). Concurrent Edit hook processes each append independently without clobbering each other. Deduplication moved to the Stop hook at read time. 2. Effective timeout: added run() export to stop-format-typecheck.js so run-with-flags.js uses the direct require() path instead of falling through to spawnSync (which has a hardcoded 30s cap). The 120s timeout in hooks.json now governs the full batch as intended. 3. Windows path guard: added spaces and parentheses to UNSAFE_PATH_CHARS so paths like "C:\Users\John Doe\project\file.ts" are caught before being passed to cmd.exe with shell: true. * fix(hooks): fix session fallback, stale comment, trim verbose comments - Replace 'default' session ID fallback with a cwd-based sha1 hash so concurrent sessions in different projects don't share the same accumulator file when CLAUDE_SESSION_ID is unset - Remove stale "JSON file" reference in accumulator header (format is now newline-delimited plain text) - Remove redundant/verbose inline comments throughout both files * fix(hooks): sanitize session ID, fix Windows tsc, proportional timeouts - Sanitize CLAUDE_SESSION_ID with /[^a-zA-Z0-9_-]/g before embedding in the temp filename so crafted separators or '..' sequences cannot escape os.tmpdir() (cubic P1) - Fix typecheckBatch on Windows: npx.cmd requires shell:true like formatBatch already does; use spawnSync and extract stdout/stderr from the result object (coderabbit P1) - Proportional per-batch timeouts: divide 270s budget across all format and typecheck batches so sequential runs in monorepos stay within the Stop hook wall-clock limit (greptile P2) - Raise Stop hook timeout from 120s to 300s to give large monorepos adequate headroom (cubic P2) * fix(hooks): extend accumulator to Write|MultiEdit, fix tests - Extend matcher from Edit to Edit|Write|MultiEdit so files created with Write and all files in a MultiEdit batch are included in the Stop-time format+typecheck pass (cubic P1) - Handle tool_input.edits[] array in accumulator for MultiEdit support - Rename misleading 'concurrent writes' test to clarify it tests append preservation, not true concurrency (cubic P2) - Add Stop hook dedup test: writes duplicate paths to accumulator and verifies the hook clears it cleanly (cubic P2) - Add Write and MultiEdit accumulation tests * fix(hooks): move timeout to command level, add dedup unit tests - Move timeout: 300 from the matcher object to the hook command object where it is actually enforced; the previous position was a no-op (cubic P2) - Extract parseAccumulator() and export it so tests can assert dedup behavior directly without relying only on side effects (cubic P2) - Add two unit tests for parseAccumulator: deduplication and blank-line handling; rename the integration test to match its scope * fix(hooks): replace removed format/typecheck hooks with accumulator in cursor adapter
Hooks
Hooks are event-driven automations that fire before or after Claude Code tool executions. They enforce code quality, catch mistakes early, and automate repetitive checks.
How Hooks Work
User request → Claude picks a tool → PreToolUse hook runs → Tool executes → PostToolUse hook runs
- PreToolUse hooks run before the tool executes. They can block (exit code 2) or warn (stderr without blocking).
- PostToolUse hooks run after the tool completes. They can analyze output but cannot block.
- Stop hooks run after each Claude response.
- SessionStart/SessionEnd hooks run at session lifecycle boundaries.
- PreCompact hooks run before context compaction, useful for saving state.
Hooks in This Plugin
PreToolUse Hooks
| Hook | Matcher | Behavior | Exit Code |
|---|---|---|---|
| Dev server blocker | Bash |
Blocks npm run dev etc. outside tmux — ensures log access |
2 (blocks) |
| Tmux reminder | Bash |
Suggests tmux for long-running commands (npm test, cargo build, docker) | 0 (warns) |
| Git push reminder | Bash |
Reminds to review changes before git push |
0 (warns) |
| Pre-commit quality check | Bash |
Runs quality checks before git commit: lints staged files, validates commit message format when provided via -m/--message, detects console.log/debugger/secrets |
2 (blocks critical) / 0 (warns) |
| Doc file warning | Write |
Warns about non-standard .md/.txt files (allows README, CLAUDE, CONTRIBUTING, CHANGELOG, LICENSE, SKILL, docs/, skills/); cross-platform path handling |
0 (warns) |
| Strategic compact | Edit|Write |
Suggests manual /compact at logical intervals (every ~50 tool calls) |
0 (warns) |
| InsAIts security monitor (opt-in) | Bash|Write|Edit|MultiEdit |
Optional security scan for high-signal tool inputs. Disabled unless ECC_ENABLE_INSAITS=1. Blocks on critical findings, warns on non-critical, and writes audit log to .insaits_audit_session.jsonl. Requires pip install insa-its. Details |
2 (blocks critical) / 0 (warns) |
PostToolUse Hooks
| Hook | Matcher | What It Does |
|---|---|---|
| PR logger | Bash |
Logs PR URL and review command after gh pr create |
| Build analysis | Bash |
Background analysis after build commands (async, non-blocking) |
| Quality gate | Edit|Write|MultiEdit |
Runs fast quality checks after edits |
| Prettier format | Edit |
Auto-formats JS/TS files with Prettier after edits |
| TypeScript check | Edit |
Runs tsc --noEmit after editing .ts/.tsx files |
| console.log warning | Edit |
Warns about console.log statements in edited files |
Lifecycle Hooks
| Hook | Event | What It Does |
|---|---|---|
| Session start | SessionStart |
Loads previous context and detects package manager |
| Pre-compact | PreCompact |
Saves state before context compaction |
| Console.log audit | Stop |
Checks all modified files for console.log after each response |
| Session summary | Stop |
Persists session state when transcript path is available |
| Pattern extraction | Stop |
Evaluates session for extractable patterns (continuous learning) |
| Cost tracker | Stop |
Emits lightweight run-cost telemetry markers |
| Desktop notify | Stop |
Sends macOS desktop notification with task summary (standard+) |
| Session end marker | SessionEnd |
Lifecycle marker and cleanup log |
Customizing Hooks
Disabling a Hook
Remove or comment out the hook entry in hooks.json. If installed as a plugin, override in your ~/.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write",
"hooks": [],
"description": "Override: allow all .md file creation"
}
]
}
}
Runtime Hook Controls (Recommended)
Use environment variables to control hook behavior without editing hooks.json:
# minimal | standard | strict (default: standard)
export ECC_HOOK_PROFILE=standard
# Disable specific hook IDs (comma-separated)
export ECC_DISABLED_HOOKS="pre:bash:tmux-reminder,post:edit:typecheck"
Profiles:
minimal— keep essential lifecycle and safety hooks only.standard— default; balanced quality + safety checks.strict— enables additional reminders and stricter guardrails.
Writing Your Own Hook
Hooks are shell commands that receive tool input as JSON on stdin and must output JSON on stdout.
Basic structure:
// my-hook.js
let data = '';
process.stdin.on('data', chunk => data += chunk);
process.stdin.on('end', () => {
const input = JSON.parse(data);
// Access tool info
const toolName = input.tool_name; // "Edit", "Bash", "Write", etc.
const toolInput = input.tool_input; // Tool-specific parameters
const toolOutput = input.tool_output; // Only available in PostToolUse
// Warn (non-blocking): write to stderr
console.error('[Hook] Warning message shown to Claude');
// Block (PreToolUse only): exit with code 2
// process.exit(2);
// Always output the original data to stdout
console.log(data);
});
Exit codes:
0— Success (continue execution)2— Block the tool call (PreToolUse only)- Other non-zero — Error (logged but does not block)
Hook Input Schema
interface HookInput {
tool_name: string; // "Bash", "Edit", "Write", "Read", etc.
tool_input: {
command?: string; // Bash: the command being run
file_path?: string; // Edit/Write/Read: target file
old_string?: string; // Edit: text being replaced
new_string?: string; // Edit: replacement text
content?: string; // Write: file content
};
tool_output?: { // PostToolUse only
output?: string; // Command/tool output
};
}
Async Hooks
For hooks that should not block the main flow (e.g., background analysis):
{
"type": "command",
"command": "node my-slow-hook.js",
"async": true,
"timeout": 30
}
Async hooks run in the background. They cannot block tool execution.
Common Hook Recipes
Warn about TODO comments
{
"matcher": "Edit",
"hooks": [{
"type": "command",
"command": "node -e \"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{const i=JSON.parse(d);const ns=i.tool_input?.new_string||'';if(/TODO|FIXME|HACK/.test(ns)){console.error('[Hook] New TODO/FIXME added - consider creating an issue')}console.log(d)})\""
}],
"description": "Warn when adding TODO/FIXME comments"
}
Block large file creation
{
"matcher": "Write",
"hooks": [{
"type": "command",
"command": "node -e \"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{const i=JSON.parse(d);const c=i.tool_input?.content||'';const lines=c.split('\\n').length;if(lines>800){console.error('[Hook] BLOCKED: File exceeds 800 lines ('+lines+' lines)');console.error('[Hook] Split into smaller, focused modules');process.exit(2)}console.log(d)})\""
}],
"description": "Block creation of files larger than 800 lines"
}
Auto-format Python files with ruff
{
"matcher": "Edit",
"hooks": [{
"type": "command",
"command": "node -e \"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{const i=JSON.parse(d);const p=i.tool_input?.file_path||'';if(/\\.py$/.test(p)){const{execFileSync}=require('child_process');try{execFileSync('ruff',['format',p],{stdio:'pipe'})}catch(e){}}console.log(d)})\""
}],
"description": "Auto-format Python files with ruff after edits"
}
Require test files alongside new source files
{
"matcher": "Write",
"hooks": [{
"type": "command",
"command": "node -e \"const fs=require('fs');let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{const i=JSON.parse(d);const p=i.tool_input?.file_path||'';if(/src\\/.*\\.(ts|js)$/.test(p)&&!/\\.test\\.|\\.spec\\./.test(p)){const testPath=p.replace(/\\.(ts|js)$/,'.test.$1');if(!fs.existsSync(testPath)){console.error('[Hook] No test file found for: '+p);console.error('[Hook] Expected: '+testPath);console.error('[Hook] Consider writing tests first (/tdd)')}}console.log(d)})\""
}],
"description": "Remind to create tests when adding new source files"
}
Cross-Platform Notes
Hook logic is implemented in Node.js scripts for cross-platform behavior on Windows, macOS, and Linux. A small number of shell wrappers are retained for continuous-learning observer hooks; those wrappers are profile-gated and have Windows-safe fallback behavior.
Related
- rules/common/hooks.md — Hook architecture guidelines
- skills/strategic-compact/ — Strategic compaction skill
- scripts/hooks/ — Hook script implementations