From 6c3ce0c8499e04e169393ff7732feb3ab9067bfa Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Mon, 19 Oct 2026 21:03:51 -0400 Subject: [PATCH] docs(hooks): document the lifecycle events and Claude Code compatibility Cover every event with its payload fields and control surface, map each one to its Claude Code counterpart and list where Crush differs, fix the PostToolUse examples to read tool_response and build JSON with jq, add the crushrc form of hook config, and bring the builtin hook skills up to date. --- docs/hooks/FUTURE.md | 16 +- docs/hooks/README.md | 349 +++++++++++++++--- internal/skills/builtin/crush-config/SKILL.md | 38 +- internal/skills/builtin/crush-hooks/SKILL.md | 60 ++- 4 files changed, 389 insertions(+), 74 deletions(-) diff --git a/docs/hooks/FUTURE.md b/docs/hooks/FUTURE.md index bb6318578..04212be3d 100644 --- a/docs/hooks/FUTURE.md +++ b/docs/hooks/FUTURE.md @@ -152,16 +152,18 @@ unaffected. ## `UserPromptSubmit` event -**Status:** not implemented. +**Status:** partially implemented as `TurnStart`. The current `TurnStart` event +fires when the agent begins processing a prompt but does not yet support +`decision` (blocking prompts) or `updated_prompt` (rewriting prompts). Those +control features are still planned. ### Motivation -Today Crush supports exactly one hook event, `PreToolUse`. That's enough to gate -and rewrite tool calls but nothing else. The next-most-useful event is -`UserPromptSubmit`: fires after the user hits Enter but before the turn hits the -LLM. Lets hooks inject context, rewrite prompts, or gate on content without the -mutation complexity of `PostToolUse` (output scrubbing, error coercion, size -limits — all rabbit holes). +Crush now supports 13 hook events including `TurnStart`, which covers the basic +observability use case. The remaining gap is **control**: letting hooks block +prompts matching a policy or rewrite them before they reach the LLM. This would +enable secret redaction from prompts, policy enforcement ("don't mention +production credentials"), and shorthand expansion. ### Use cases diff --git a/docs/hooks/README.md b/docs/hooks/README.md index 26b82e94c..7e08f716d 100644 --- a/docs/hooks/README.md +++ b/docs/hooks/README.md @@ -16,11 +16,12 @@ forward. - Hooks are just shell commands - Hooks can be written in any language because they're just executables: Bash, Python, Node, Rust, Haskell, whatever -- Hooks are Claude Code-compatible +- Hooks are largely Claude Code-compatible: same payload field names, same + output forms, same exit codes - Crush ships with a builtin `crush-hook` skill to write, edit, and configure hooks; just tell Crush how to configure Crush -- Crush currently supports just one hook, `PreToolUse`, with plans to support - the full gamut; please let us know which hooks you'd like to see next +- Crush supports 13 hook events covering the full agent lifecycle: tool use, + session management, turn control, permissions, compaction, and error handling - Hooks run in parallel for speed, but their results compose in config order for determinism @@ -127,15 +128,21 @@ What this means in practice: ## Configuration -Hooks can be added to your `crushrc` (or `.crushrc`) at both the global -and project-level, with project level hooks taking precedence. +Hooks can be added to your `crushrc` (or `.crushrc`) at both the global and +project level, with project level hooks taking precedence. Each hook is one +`hook add` line: ```bash +# name: friendly name shown in the TUI; matcher: regex tested against the +# tool name; timeout: seconds, default 30. hook add PreToolUse \ --name "no-rm-rf" \ --matcher '^bash$' \ --command "./hooks/my-hot-hook.sh" \ --timeout 10 +hook add TurnEnd \ + --name "ding" \ + --command 'afplay /System/Library/Sounds/Glass.aiff' ``` > [!IMPORTANT] @@ -160,29 +167,155 @@ wins when rewriting input, but first deny wins when blocking. ## Events -Here are the events you can hook into (spoiler: there's currently just one): +Crush supports 13 hook events. Event names are case-insensitive and accept +snake_case (`PreToolUse`, `pre_tool_use`, `PRETOOLUSE` all work). An unknown +event name is a config error. -### PreToolUse +| Event | Fires When | Control | Matcher | Claude Code | +| -------------------- | ---------------------------------------------- | ---------------------------------------- | --------- | --------------------------- | +| `PreToolUse` | Before a tool runs | allow/deny, halt, rewrite input, context | tool name | same | +| `PostToolUse` | After a tool succeeds | halt, replace output, context, feedback | tool name | same | +| `PostToolUseFailure` | After a tool fails | halt, context | tool name | same | +| `PermissionRequest` | Permission prompt shown | observe | tool name | same (observe only here) | +| `PermissionResult` | Prompt granted, denied, or cancelled | observe | tool name | none | +| `SessionStart` | Session created, or resumed with `--continue` | observe | — | same | +| `SessionEnd` | App exits (2s budget) | observe | — | same | +| `TurnStart` | Agent begins processing a prompt | observe | — | `UserPromptSubmit` | +| `TurnEnd` | Agent finishes a turn | observe | — | `Stop` | +| `StopFailure` | Turn ends with a provider error | observe | — | same | +| `Interrupt` | User cancels a turn (ctrl-c, escape) | observe | — | none | +| `PreCompact` | Before context compaction (auto or `/compact`) | block | — | same | +| `PostCompact` | After context compaction | observe | — | same | -This hook fires before every tool call. Use it to block dangerous commands, -enforce policies, rewrite tool input, inject context the model should see, log -stuff, and so on. +**Matcher** is a regex tested against the tool name (e.g. `^bash$`, +`^(edit|write)$`, `^mcp_`). Omit it to match every tool. It is only accepted on +the events that carry a tool name; configuring one elsewhere is a config error. -**Matched against**: the tool name (e.g. `bash`, `edit`, `write`, -`mcp_github_create_pull_request`). +**Scope**: tool and turn events only fire for the **top-level agent**. +Sub-agents (the task tool, `agentic_fetch`, and friends) fire none of them, +so a notification hook on `TurnEnd` speaks once per prompt rather than once +per delegated turn. The outer sub-agent tool call itself _is_ hooked. -> [!NOTE] -> Event names are case insensitive and snake-caseable, so `PreToolUse`, -> `pretooluse`, `PRETOOLUSE`, `pre_tool_use`, and `PRE_TOOL_USE` all work. +**Permission events** fire only when a prompt is actually shown. Calls that a +`PreToolUse` hook allowed, that are on the allow-list, or that run in YOLO +mode never prompt and so fire neither. `PermissionResult` always follows its +`PermissionRequest`, even when the turn is cancelled while the prompt is open; +its `decision` field says which way it went. -**Scope**: `PreToolUse` only fires on the **top-level agent's** tool calls. -Sub-agents (the `agent` task tool, `agentic_fetch`, etc.) run without hook -interception so a single delegated turn doesn't trigger your hook N times. The -outer sub-agent tool call itself _is_ hooked, so policy like "never let the -agent spawn sub-agents" still works. +**SessionEnd** fires once, for the session you were last working in, after the +running turn has been cancelled (so you will see `Interrupt` first). It has a +two second budget shared by every `SessionEnd` hook, so keep it to enqueueing +or notifying; do the heavy work elsewhere. -Hooks are keyed by event name. Only `command` is required, and you can omit -`matcher` to match all tools. +### What each control field does + +- **`decision: "allow"`** — pre-approves the tool call, skips the permission + prompt. `"deny"` blocks it; the model sees the error and may retry. +- **`halt: true`** — ends the entire turn. For `PreCompact`, blocks compaction + instead. +- **`updated_input`** — for `PreToolUse`: shallow-merge patch against tool + input. +- **`updated_output`** — for `PostToolUse`: replaces what the model sees as the + tool's output. +- **`context`** — string or array appended to what the model sees. Works on + the tool events. +- **`reason`** with `decision: "deny"` on `PostToolUse` — the tool already + ran, so instead of blocking anything the reason is handed to the model as + feedback ("run the tests first"). This is Claude Code's `decision: "block"` + and it works the same way here. + +Events marked "observe" ignore all control fields. They exist for logging, +monitoring, and external integrations (e.g. herdr, dashboards). To auto-approve +tools, use `PreToolUse` with `decision: "allow"` rather than +`PermissionRequest`. + +Blocking compaction (`PreCompact`) leaves the conversation as it was. When the +compaction was automatic, the turn ends there, since the next step would only +hit the context limit again; send another message to carry on. A blocked +`/compact` reports the hook's reason. + +### Control field examples + +**PreToolUse — rewrite input:** + +```bash +#!/usr/bin/env bash +# Replace npm with bun in every bash call. +jq -c 'select(.tool_input.command | startswith("npm ")) + | {updated_input: {command: (.tool_input.command | sub("^npm "; "bun "))}}' +``` + +**PostToolUse — redact secrets from output:** + +```bash +#!/usr/bin/env bash +# Replace anything that looks like an API key in tool output. +jq -c '{updated_output: (.tool_response | gsub("(sk-|ghp_|AKIA)[A-Za-z0-9]{20,}"; "[REDACTED]"))}' +``` + +**PostToolUse — halt on unexpected output:** + +```bash +#!/usr/bin/env bash +# Stop the turn if bash output contains a credential leak warning. +if jq -r '.tool_response' | grep -qi 'credential.*leak'; then + echo '{"halt":true,"reason":"Possible credential leak detected in tool output"}' +fi +``` + +**PostToolUse — send the model feedback:** + +```bash +#!/usr/bin/env bash +# The commit went through, but tell the model what it skipped. +if jq -r '.tool_input.command' | grep -q '^git commit' && [ ! -f .tests-passed ]; then + echo '{"decision":"deny","reason":"You committed without running the tests. Run them now and amend if needed."}' +fi +``` + +**PreCompact — skip compaction during critical work:** + +```bash +#!/usr/bin/env bash +# Don't compact if we're in the middle of a migration. +if [ -f .migration-in-progress ]; then + echo '{"halt":true,"reason":"migration in progress"}' +fi +``` + +**PreToolUse — inject context:** + +```bash +#!/usr/bin/env bash +# Remind the model about formatting rules when editing Go files. +if jq -r '.tool_input.file_path // empty' | grep -q '\.go$'; then + echo '{"context":"Remember: run gofumpt -w after editing Go files."}' +fi +``` + +**TurnEnd — read the reply aloud:** + +```bash +#!/usr/bin/env bash +# Speak the assistant's final message. Background it so the next prompt +# is not held up behind the speech. +jq -r '.last_assistant_message' | say & +``` + +### Configuration + +Hooks are keyed by event name. Only `command` is required: + +```jsonc +{ + "hooks": { + "PreToolUse": [{ "matcher": "^bash$", "command": "./hooks/check.sh" }], + "PostToolUse": [{ "matcher": "^bash$", "command": "./hooks/redact.sh" }], + "TurnEnd": [{ "command": "./hooks/notify-idle.sh" }], + "PreCompact": [{ "command": "./hooks/maybe-skip-compact.sh" }], + }, +} +``` ## Building Hooks @@ -226,9 +359,12 @@ The available environment variables are: | `CRUSH_SESSION_ID` | Current session ID. | | `CRUSH_CWD` | Working directory. | | `CRUSH_PROJECT_DIR` | Project root directory. | +| `CLAUDE_PROJECT_DIR` | Same as `CRUSH_PROJECT_DIR`, for scripts written for Claude Code. | | `CRUSH_TOOL_INPUT_COMMAND` | For `bash` calls: the shell command being run. | | `CRUSH_TOOL_INPUT_FILE_PATH` | For file tools: the target file path. | +The `CRUSH_TOOL_*` variables are only set on events that carry a tool call. + The `CRUSH`, `AGENT`, and `AI_AGENT` markers are also set by the `bash` tool, so a script can detect "am I running under Crush?" the same way in either context. @@ -240,14 +376,20 @@ Standard input provides the full context as JSON: ```jsonc { "event": "PreToolUse", // Hook event name + "hook_event_name": "PreToolUse", // Same thing, under Claude Code's name "session_id": "313909e", // Current session ID "cwd": "/home/user/project", // Working directory "tool_name": "bash", // The tool being called "tool_input": { "command": "rm -rf /" }, // The tool's input + "tool_use_id": "call_01", // The tool call ID } ``` Note that `tool_input` field contains the raw JSON the model sent to the tool. +Events without a tool call omit the `tool_*` fields, and some events add one +of their own (`tool_response`, `last_assistant_message`, `error`, `decision`, +`source`, `reason`, `trigger`); see the [reference](#reference) for the full +list. To parse the stdin JSON in your hook script, read from stdin and use a tool like `jq`: @@ -284,12 +426,12 @@ if some_bad_condition; then fi ``` -| Exit Code | Meaning | -| --------- | ---------------------------------------------------------------- | -| 0 | Success. Stdout is parsed as JSON (see fields below). | -| 2 | **Block the tool.** Stderr is used as the deny reason (no JSON). | -| 49 | **Halt the turn.** Stderr is used as the halt reason (no JSON). | -| Other | Non-blocking error. Logged and ignored — the tool call proceeds. | +| Exit Code | Meaning | +| --------- | --------------------------------------------------------------------------------------------------------------- | +| 0 | Success. Stdout is parsed as JSON (see fields below). | +| 2 | **Block the tool.** Stderr is the deny reason (falling back to the JSON `reason`, if you printed one). | +| 49 | **Halt the turn.** Stderr is the halt reason. | +| Other | Non-blocking error, logged and ignored. As in Claude Code, a JSON object on stdout still counts and is applied. | The difference between exit 2 and exit 49: @@ -547,15 +689,39 @@ process.stdin.on("end", () => { ## Claude Code compatibility -Crush hooks are broadly compatible with [Claude Code -hooks](https://docs.claude.com/en/docs/claude-code/hooks): the config shape, -stdin payload, output envelope, and exit codes line up so most Claude Code -hooks run under Crush unchanged. This document covers the Crush-specific API -only — anything not documented here isn't guaranteed to work. +Crush hooks are largely compatible with [Claude Code +hooks](https://code.claude.com/docs/en/hooks). Most hook scripts written for +Claude Code run under Crush unchanged: -One intentional divergence: Crush treats `updated_input` as a shallow-merge -patch against the original `tool_input` rather than a full replacement. Keys -you omit are preserved. See [Output](#output) for details. +- **Payload fields** use Claude Code's names where one exists: + `hook_event_name`, `session_id`, `cwd`, `tool_name`, `tool_input`, + `tool_use_id`, `tool_response`, `error`, `last_assistant_message`, `source`, + `reason`, `trigger`. `CLAUDE_PROJECT_DIR` is exported alongside + `CRUSH_PROJECT_DIR`. +- **Output** accepts Claude Code's forms as well as Crush's: + `hookSpecificOutput` (`permissionDecision`, `permissionDecisionReason`, + `updatedInput`, `updatedToolOutput`, `additionalContext`), the top-level + `decision: "block"` with `reason`, and `continue: false` with `stopReason`. +- **Exit codes** behave the same: 0 applies the JSON, 2 blocks with stderr as + the reason, and any other code is a non-blocking error unless stdout held a + JSON object. + +Where Crush differs: + +- **Config shape** is flat: a list of `{matcher, command}` per event, not + Claude Code's nested `matcher` groups with `hooks` arrays. Only `command` + hooks exist; there are no `http`, `prompt`, or `agent` hooks. +- **Event names**: `TurnStart`, `TurnEnd`, `Interrupt`, and `PermissionResult` + are Crush's names for the per-turn events; the table in [Events](#events) + maps each one to its Claude Code counterpart. `PermissionRequest` is + observe-only here; use `PreToolUse` to allow or deny. +- **`updated_input` on `PreToolUse`** is a shallow-merge patch against the + original `tool_input` rather than a full replacement, so keys you omit are + preserved. Claude Code's `updatedInput` is treated the same way. +- **`tool_response`** is the tool's text output as one string, not a + per-tool object. Crush's own field for replacing it is `updated_output`; + Claude Code's `updatedToolOutput` maps onto it. +- **`halt` and exit 49** have no Claude Code equivalent; they end the turn. --- @@ -599,6 +765,9 @@ Present in every hook event: // string. Hook event name. "event": "PreToolUse", + // string. The same name again, under the field Claude Code uses. + "hook_event_name": "PreToolUse", + // string. Current session ID. "session_id": "313909e", @@ -607,9 +776,10 @@ Present in every hook event: } ``` -### Stdin payload — PreToolUse +### Stdin payload — tool events -Extends the common payload: +`PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, and +`PermissionResult` extend the common payload with the tool call: ```jsonc { @@ -619,9 +789,71 @@ Extends the common payload: "tool_name": "bash", // object. Raw JSON input the model sent to the tool. Shape is per-tool. + // For the permission events this is the request the prompt showed. "tool_input": { "command": "npm test", }, + + // string. The tool call ID. + "tool_use_id": "call_01", +} +``` + +`PostToolUse` adds the tool's output, which is what `updated_output` replaces: + +```jsonc +{ + // string. Always present, even when the tool printed nothing. + "tool_response": "PASS\nok \texample.com/testproject\t0.004s", +} +``` + +`PostToolUseFailure` adds the failure instead: + +```jsonc +{ + // string. The tool's error text. + "error": "exit status 1\nFAIL\t...", +} +``` + +`PermissionResult` adds how the prompt was resolved: + +```jsonc +{ + // "granted" | "denied" | "cancelled". Cancelled means the turn was + // cancelled while the prompt was open. + "decision": "granted", +} +``` + +### Stdin payload — turn events + +`TurnStart` and `Interrupt` use only the common fields. `TurnEnd` adds the +assistant's reply and `StopFailure` adds the provider error: + +```jsonc +{ + // string (TurnEnd). The assistant's text for the turn. + "last_assistant_message": "Here's what I found...", + + // string (StopFailure). The provider error. + "error": "Rate limit reached", +} +``` + +### Stdin payload — session and compaction events + +```jsonc +{ + // "startup" | "resume" (SessionStart). Resume means --continue or --last. + "source": "startup", + + // string (SessionEnd). Currently always "exit". + "reason": "exit", + + // "auto" | "manual" (PreCompact, PostCompact). Manual is /compact. + "trigger": "auto", } ``` @@ -671,17 +903,33 @@ Extends the common envelope: } ``` +### Output envelope — PostToolUse + +Same as the common envelope plus `updated_output`, a string that **replaces** +what the model sees as the tool's response. The tool has already +run, so `decision: "deny"` cannot block it; its `reason` is appended to the +output as feedback for the model instead. + +`PostToolUseFailure` accepts `halt` and `context` only. + +### Output envelope — PreCompact + +Same as the common envelope. `halt: true`, `decision: "deny"`, or exit 2 +blocks compaction and leaves the conversation as it was. Claude Code's +`decision: "block"` works too. + ### Exit codes -| Code | Meaning | -| ----- | ------------------------------------------------------------------------ | -| `0` | Success. Stdout is parsed as the output envelope. | -| `2` | Block this tool call. Stderr becomes the deny reason. Stdout is ignored. | -| `49` | Halt the whole turn. Stderr becomes the halt reason. Stdout is ignored. | -| other | Non-blocking error. Logged and ignored; the tool call proceeds. | +| Code | Meaning | +| ----- | ----------------------------------------------------------------------------------------------------- | +| `0` | Success. Stdout is parsed as the output envelope. | +| `2` | Block. Stderr becomes the reason, falling back to the envelope's `reason`. | +| `49` | Halt the whole turn. Stderr becomes the halt reason. | +| other | Non-blocking error, logged and ignored. If stdout holds a JSON object it is applied all the same. | -Exit `2` only applies to events that can block something. On events where -there's nothing to block, it's treated as a non-blocking error. +Exit `2` only blocks on events that can block something (`PreToolUse`, +`PreCompact`). On `PostToolUse` it becomes feedback for the model; on +observe-only events it is logged. ### Aggregation @@ -706,6 +954,15 @@ PreToolUse-specific rules: `tool_input`. Later patches override earlier ones on colliding keys. Patches are **ignored** if the final decision is deny or halt. +PostToolUse-specific rules: + +6. `updated_output` **replaces** the tool output. When several hooks set it, + the last one in config order wins. + +PreCompact-specific rules: + +7. `halt` or `deny` blocks compaction and leaves the conversation as it was. + ### Environment variables See [Environment Variables](#environment-variables) above for the full list. diff --git a/internal/skills/builtin/crush-config/SKILL.md b/internal/skills/builtin/crush-config/SKILL.md index 92fad14bd..556a46293 100644 --- a/internal/skills/builtin/crush-config/SKILL.md +++ b/internal/skills/builtin/crush-config/SKILL.md @@ -212,9 +212,12 @@ option ui exit-banner compact ## Hooks runtime -Hooks are user-defined shell commands that fire on agent events. Currently only -`PreToolUse` is supported, which runs before a tool executes. This behavior is -the same however the hook is defined (`hook add` or JSON). +Hooks are user-defined shell commands that fire on agent events. Crush supports +13 events: `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, +`PermissionRequest`, `PermissionResult`, `SessionStart`, `SessionEnd`, +`TurnStart`, `TurnEnd`, `StopFailure`, `Interrupt`, `PreCompact`, and +`PostCompact`. `--matcher` is only accepted on the first five. This behavior +is the same however the hook is defined (`hook add` or JSON). ### How hooks work @@ -232,13 +235,21 @@ Event names are case-insensitive and accept snake_case: `PreToolUse`, ```json { "event": "PreToolUse", + "hook_event_name": "PreToolUse", "session_id": "abc-123", "cwd": "/path/to/project", "tool_name": "bash", - "tool_input": { "command": "ls -la" } + "tool_input": { "command": "ls -la" }, + "tool_use_id": "call_01" } ``` +Events without a tool call omit the `tool_*` fields. Some add one of their +own: `tool_response` (PostToolUse), `error` (PostToolUseFailure, +StopFailure), `last_assistant_message` (TurnEnd), `decision` +(PermissionResult), `source` (SessionStart), `reason` (SessionEnd), and +`trigger` (PreCompact, PostCompact). + ### Hook environment variables | Variable | Description | @@ -248,6 +259,7 @@ Event names are case-insensitive and accept snake_case: `PreToolUse`, | `CRUSH_SESSION_ID` | Current session ID | | `CRUSH_CWD` | Current working directory | | `CRUSH_PROJECT_DIR` | Project root directory | +| `CLAUDE_PROJECT_DIR` | Same value, for scripts written for Claude Code | | `CRUSH_TOOL_INPUT_COMMAND` | Value of `command` from tool input (if present) | | `CRUSH_TOOL_INPUT_FILE_PATH` | Value of `file_path` from tool input (if present) | @@ -262,23 +274,31 @@ Event names are case-insensitive and accept snake_case: `PreToolUse`, - `decision`: `allow` to explicitly allow, `deny` to block, `none` (or omit). - `reason`: explanation (used when denying). - `context`: extra context appended to the tool result. -- `updated_input`: replacement JSON for the tool input; last non-empty wins. +- `updated_input`: on PreToolUse, a shallow-merge patch against the tool + input. +- `updated_output`: on PostToolUse, a string that replaces the tool output. +- `halt`: `true` ends the turn (blocks compaction on PreCompact). **Exit code 2** — the tool call is blocked; stderr is the deny reason. -**Any other exit code** — non-blocking error; the tool call proceeds. +**Exit code 49** — the whole turn is halted; stderr is the reason. + +**Any other exit code** — non-blocking error; the tool call proceeds, unless +stdout holds a JSON object, which is applied anyway. ### Decision aggregation - **Deny wins over allow** — any deny blocks the call. - **Allow wins over none** — a lone allow lets it proceed. - Deny reasons and context strings are concatenated (newline-separated). -- For `updated_input`, the last non-empty value wins. +- `updated_input` patches shallow-merge in config order on PreToolUse; + `updated_output` on PostToolUse is last-one-wins. ### Claude Code compatibility -Crush also accepts the Claude Code hook output format, so existing hooks work -unchanged: +Crush also accepts the Claude Code hook output formats (`hookSpecificOutput`, +top-level `decision: "block"` with `reason`, `continue: false`), so existing +hooks work unchanged: ```json { diff --git a/internal/skills/builtin/crush-hooks/SKILL.md b/internal/skills/builtin/crush-hooks/SKILL.md index bc1cab1ec..9efc50626 100644 --- a/internal/skills/builtin/crush-hooks/SKILL.md +++ b/internal/skills/builtin/crush-hooks/SKILL.md @@ -16,8 +16,31 @@ need to author correct hooks. ## Supported Events -Only `PreToolUse` is currently supported. Event names are case-insensitive and -accept snake_case (`PreToolUse`, `pretooluse`, `pre_tool_use` all work). +Crush supports 13 hook events: `PreToolUse`, `PostToolUse`, +`PostToolUseFailure`, `PermissionRequest`, `PermissionResult`, +`SessionStart`, `SessionEnd`, `TurnStart`, `TurnEnd`, `StopFailure`, +`Interrupt`, `PreCompact`, `PostCompact`. Event names are case-insensitive +and accept snake_case (`PreToolUse`, `pretooluse`, `pre_tool_use` all work); +an unknown name is a config error. The five events that carry a tool call +(`PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, +`PermissionResult`) accept `matcher`; the rest run every configured hook and +reject `matcher`. + +Only `PreToolUse` (allow/deny/halt/rewrite/context), `PostToolUse` +(halt/replace output/context/feedback), `PostToolUseFailure` +(halt/context), and `PreCompact` (block) honor output. The rest are +observe-only. Turn and tool events fire only for the top-level agent, never +for sub-agents (`agentic_fetch`, the task tool), so a `TurnEnd` hook runs +once per user prompt. + +Per-event payload extras: `PostToolUse` has `tool_response` (the output +string), `PostToolUseFailure` and `StopFailure` have `error`, `TurnEnd` has +`last_assistant_message`, `PermissionResult` has `decision` +(`granted`/`denied`/`cancelled`), `SessionStart` has `source` +(`startup`/`resume`), `SessionEnd` has `reason`, and the compaction events +have `trigger` (`auto`/`manual`). Payloads also carry `hook_event_name` and +`tool_use_id`, and hooks see `CLAUDE_PROJECT_DIR`, so scripts written for +Claude Code usually work unchanged. ## Configuration @@ -56,18 +79,23 @@ the input/output contract is identical regardless of language. | `CRUSH_SESSION_ID` | Current session ID | | `CRUSH_CWD` | Working directory | | `CRUSH_PROJECT_DIR` | Project root directory | +| `CLAUDE_PROJECT_DIR` | Same value, for Claude Code scripts | | `CRUSH_TOOL_INPUT_COMMAND` | For `bash` calls: the shell command | | `CRUSH_TOOL_INPUT_FILE_PATH` | For file tools: the target file path | +The `CRUSH_TOOL_*` variables are only set on events that carry a tool call. + **JSON on stdin:** ```json { "event": "PreToolUse", + "hook_event_name": "PreToolUse", "session_id": "313909e", "cwd": "/home/user/project", "tool_name": "bash", - "tool_input": {"command": "rm -rf /"} + "tool_input": {"command": "rm -rf /"}, + "tool_use_id": "call_01" } ``` @@ -80,7 +108,7 @@ Communicate back via exit code (+ stderr) or JSON on stdout. | 0 | Success. Stdout is parsed as the JSON envelope below. | | 2 | Block this tool call. Stderr becomes the deny reason. | | 49 | Halt the whole turn. Stderr becomes the halt reason. | -| Other | Non-blocking error. Logged and ignored; tool call proceeds. | +| Other | Non-blocking error unless stdout holds a JSON object. | Exit 2 blocks one tool call (agent sees the reason and can try again); exit 49 ends the whole turn (user takes over). Default to deny — reach for halt only @@ -111,6 +139,12 @@ policy violation). - **`updated_input`**: **shallow-merge patch** against `tool_input`, not a replacement. Keys you include overwrite; keys you don't are preserved. Nested objects are replaced wholesale, not deep-merged. Ignored on deny/halt. +- **`updated_output`** (`PostToolUse` only): a string that replaces the tool + output. The last hook to set it wins. +- On `PostToolUse`, `decision: "deny"` cannot block (the tool already ran); + its `reason` is appended to the output as feedback for the model. +- Claude Code forms are accepted too: `hookSpecificOutput`, top-level + `decision: "block"` with `reason`, and `continue: false` with `stopReason`. ## Aggregation (Multiple Hooks) @@ -168,15 +202,17 @@ Config: `{"matcher": "^(edit|write|multiedit)$", "command": "./hooks/go-context. #!/usr/bin/env bash set -euo pipefail -read -r input -rewritten=$(echo "$input" | jq -r '.tool_input.command' | some-rewriter) +rewritten=$(jq -r '.tool_input.command' | some-rewriter) +jq -n --arg cmd "$rewritten" '{context: "Rewrote command", updated_input: {command: $cmd}}' +``` -cat <