mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-15 12:45:29 +02:00
Carry forward PR #3055 at92f53c4contoef648e01, preserving contributor attribution and current main. Focused maintainer fixes reuse shared input ceilings, preserve late root context through outer bootstrap, bound frontmatter before immutable parsing, and assert real oversized input in the limits workflow.
317 lines
15 KiB
Markdown
317 lines
15 KiB
Markdown
# 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 and return feedback before Claude continues.
|
|
- **UserPromptSubmit** hooks run before Claude processes a user prompt.
|
|
- **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
|
|
|
|
Memory persistence lifecycle definitions live in `hooks/memory-persistence/`.
|
|
The executable hook graph remains `hooks/hooks.json`; the memory persistence directory is the stable contract for SessionStart, PreCompact, observation, activity tracking, and SessionEnd behavior.
|
|
|
|
Stable hook IDs and descriptions live in `hooks/hooks.metadata.json`, aligned by event and index with `hooks/hooks.json`. Claude Code validates a plugin's `hooks.json` against its own schema and reports any other key (`$schema`, `id`, `description`) as unknown at load time, so `hooks.json` carries only what the harness accepts. ECC's installer, validator, and dashboard merge the sidecar back in through `scripts/lib/hooks-config.js`; `node scripts/ci/validate-hooks.js` fails if the two files drift apart, and `node scripts/ci/check-hooks-schema-keys.js` fails if `hooks.json` or `hooks/codex-hooks.json` carry any key outside their loader's documented set.
|
|
|
|
Each sidecar entry also carries a `fingerprint` of the matcher entry it describes (matcher plus hook commands), so reordering `hooks.json` without reordering the sidecar, or editing a command without updating the sidecar, is caught rather than silently swapping IDs. When reordering hooks, move the matching sidecar entries first. Then run `node scripts/ci/validate-hooks.js --update-fingerprints` to refresh changed commands and commit both files. The updater rejects known fingerprints at different positions and writes only after validation succeeds.
|
|
|
|
## Installing These Hooks Manually
|
|
|
|
For Claude Code manual installs, do not paste the raw repo `hooks.json` into `~/.claude/settings.json` or copy it directly into `~/.claude/hooks/hooks.json`. The checked-in file is plugin/repo-oriented and is meant to be installed through the ECC installer or loaded as a plugin.
|
|
|
|
Use the installer instead so hook commands are rewritten against your actual Claude root:
|
|
|
|
```bash
|
|
bash ./install.sh --target claude --modules hooks-runtime --enable-hooks
|
|
```
|
|
|
|
```powershell
|
|
pwsh -File .\install.ps1 --target claude --modules hooks-runtime --enable-hooks
|
|
```
|
|
|
|
That installs the hook scripts under `~/.claude/` and registers the resolved
|
|
hook entries in `~/.claude/settings.json`. Existing user settings and hook
|
|
entries are preserved, while ECC-owned entries are tracked by stable ID for
|
|
idempotent updates and safe uninstall. On Windows, the Claude config root is
|
|
`%USERPROFILE%\.claude`.
|
|
|
|
### 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) |
|
|
| **Hookify runtime** | `.*` | Evaluates enabled project-local Hookify rules before shell and file tools | 0 (structured warn/block decision) |
|
|
|
|
### 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 |
|
|
| **Design quality check** | `Edit\|Write\|MultiEdit` | Warns when frontend edits drift toward generic template-looking UI |
|
|
| **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 |
|
|
| **Hookify runtime** | `.*` | Evaluates project-local Hookify rules against completed shell and file tools |
|
|
|
|
### Lifecycle Hooks
|
|
|
|
| Hook | Event | What It Does |
|
|
|------|-------|-------------|
|
|
| **Session start** | `SessionStart` | Loads previous context and detects package manager |
|
|
| **Hookify prompt rules** | `UserPromptSubmit` | Evaluates prompt rules before the user prompt reaches Claude |
|
|
| **Plan Canvas sessions** | `SessionStart` | Surfaces open Plan Canvas browser reviews so a fresh session can resume the loop |
|
|
| **Pre-compact** | `PreCompact` | Saves state before context compaction |
|
|
| **Hookify completion rules** | `Stop` | Warns or requests another turn when an enabled completion rule matches |
|
|
| **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`:
|
|
|
|
```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`:
|
|
|
|
```bash
|
|
# Master switch. Explicit environment values override plugin preferences.
|
|
export ECC_HOOKS_ENABLED=true
|
|
|
|
# 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"
|
|
|
|
# Lower the hook input cap in bytes (default and maximum: 1048576).
|
|
# run-with-flags.js adds runner-level fail-closed handling for
|
|
# pre:edit-write:gateguard-fact-force and pre:mcp-health-check because they
|
|
# cannot inspect the complete request. Other safety hooks, including the Bash
|
|
# dispatcher and config protection, retain their own fail-closed behavior.
|
|
# If a trusted tool call legitimately exceeds the cap, retry with a smaller
|
|
# input or temporarily set ECC_GATEGUARD=off (or GATEGUARD_DISABLED=1) for
|
|
# GateGuard, or ECC_MCP_HEALTH_FAIL_OPEN=yes for MCP health, then restore it.
|
|
# These switches reduce only the named protection while enabled; they do not
|
|
# bypass the Bash dispatcher or config-protection checks.
|
|
export ECC_HOOK_INPUT_MAX_BYTES=524288
|
|
|
|
# Disable only GateGuard during setup or recovery
|
|
export ECC_GATEGUARD=off
|
|
|
|
# Cap SessionStart additional context (default: 8000 chars)
|
|
export ECC_SESSION_START_MAX_CHARS=4000
|
|
|
|
# Disable SessionStart additional context entirely
|
|
export ECC_SESSION_START_CONTEXT=off
|
|
|
|
# Keep context/scope/loop warnings but suppress API-rate cost estimates
|
|
export ECC_CONTEXT_MONITOR_COST_WARNINGS=off
|
|
```
|
|
|
|
Windows PowerShell:
|
|
|
|
```powershell
|
|
[Environment]::SetEnvironmentVariable('ECC_CONTEXT_MONITOR_COST_WARNINGS', 'off', 'User')
|
|
```
|
|
|
|
Claude setup-only value:
|
|
- `off` — disables local ECC hook work through `ecc setup`; it is not a runtime hook profile.
|
|
|
|
Runtime hook profiles:
|
|
- `minimal` — keep essential lifecycle and safety hooks only.
|
|
- `standard` — default; balanced quality + safety checks.
|
|
- `strict` — enables additional reminders and stricter guardrails.
|
|
|
|
The Claude plugin exposes the same choices as the personal `hooks_enabled` and
|
|
`hook_profile` settings. Run `ecc setup --mode claude-plugin` to install or
|
|
update the plugin and change those preferences.
|
|
|
|
### Hookify Rule Runtime
|
|
|
|
ECC loads enabled `.claude/hookify.*.local.md` files directly; no separate
|
|
Hookify plugin or Python runtime is required. Rules support `bash`, `file`,
|
|
`prompt`, `stop`, and `all` events, plus either a simple regex `pattern` or an
|
|
AND-combined `conditions` list. `warn` adds visible context for Claude while
|
|
allowing the action. `block` denies a matching `PreToolUse` call and uses the
|
|
event's blocking decision contract for prompt, post-tool, and stop events.
|
|
|
|
Rule loading is project-scoped and bounded. Symlinked, oversized, malformed,
|
|
or unsafe-regex rule files are skipped with a diagnostic instead of breaking
|
|
the entire hook chain. Every regex evaluation also has a 25 ms hard timeout.
|
|
Stop rules ignore `stop_hook_active: true` to avoid continuation loops.
|
|
|
|
Files tracked by Git are disabled by default because opening a repository must
|
|
not activate repository-authored instructions. Locally created, gitignored
|
|
rules continue to work automatically. To review and explicitly trust tracked
|
|
rules, set `ECC_HOOKIFY_ALLOW_TRACKED=1`; messages are still labeled as
|
|
untrusted local rule data when passed to Claude.
|
|
|
|
### Writing Your Own Hook
|
|
|
|
Hooks are shell commands that receive tool input as JSON on stdin. A hook with
|
|
no decision or context to return should leave stdout empty. Only explicit hook
|
|
output, such as a deny decision or `additionalContext`, should be written to
|
|
stdout; the input payload must not be echoed as a no-op response.
|
|
|
|
**Basic structure:**
|
|
|
|
```javascript
|
|
// 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);
|
|
|
|
// No opinion: leave stdout empty.
|
|
});
|
|
```
|
|
|
|
**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
|
|
|
|
```typescript
|
|
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):
|
|
|
|
```json
|
|
{
|
|
"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
|
|
|
|
```json
|
|
{
|
|
"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')}})\""
|
|
}],
|
|
"description": "Warn when adding TODO/FIXME comments"
|
|
}
|
|
```
|
|
|
|
### Block large file creation
|
|
|
|
```json
|
|
{
|
|
"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)}})\""
|
|
}],
|
|
"description": "Block creation of files larger than 800 lines"
|
|
}
|
|
```
|
|
|
|
### Auto-format Python files with ruff
|
|
|
|
```json
|
|
{
|
|
"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){}}})\""
|
|
}],
|
|
"description": "Auto-format Python files with ruff after edits"
|
|
}
|
|
```
|
|
|
|
### Require test files alongside new source files
|
|
|
|
```json
|
|
{
|
|
"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)')}}})\""
|
|
}],
|
|
"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. The continuous-learning observer is exposed as a Node-mode hook and delegates to its existing `observe.sh` implementation through a profile-gated runner with Windows-safe fallback behavior.
|
|
|
|
## Related
|
|
|
|
- [rules/common/hooks.md](../rules/common/hooks.md) — Hook architecture guidelines
|
|
- [skills/strategic-compact/](../skills/strategic-compact/) — Strategic compaction skill
|
|
- [scripts/hooks/](../scripts/hooks/) — Hook script implementations
|