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 <