docs(hooks): improve hook documentation

Christian Rocha created

Change summary

docs/hooks/FUTURE.md |   3 
docs/hooks/README.md | 157 +++++++++++++++++++++++++++++++++------------
2 files changed, 117 insertions(+), 43 deletions(-)

Detailed changes

docs/hooks/FUTURE.md πŸ”—

@@ -4,7 +4,8 @@ This document tracks planned features and design notes for hooks that are not
 yet implemented. Nothing here is part of the current contract. Treat it as a
 scratchpad for what's next, not as documentation of current behavior.
 
-> [!NOTE] This document was largely LLM-generated.
+> [!NOTE]
+> This document was largely LLM-generated.
 
 ## `context_files`
 

docs/hooks/README.md πŸ”—

@@ -1,38 +1,97 @@
 # Hooks
 
-> [!NOTE] This document was designed for both humans and agents.
+> [!NOTE]
+> This document was designed for both humans and agents.
 
-Hooks are user-defined shell commands that fire at specific points during
-Crush's execution, giving you deterministic control over an agent's wily
-behavior.
+Hooks are user-defined shell scripts that run when various events happen during
+the agent lifecycle, allowing you to both build on top of Crush, customize
+its behavior, and exert deterministic control over an agent's wily behavior.
 
-Hooks in Crush are shell-based, with a focus on simplicity. This allows hooks to
-effectively be written in any language. In this document we'll primarily focus
-on Bash for simplicity's sake, though we'll include some examples in other
-languages at the end, too.
+Hooks are just shell commands, and were designed to be both simple and future
+forward.
 
 ### Hot Hook Facts
 
-- Hooks run before permission checks. If a hook denies a tool call, you'll
-  never see a permission prompt for it. If a hook explicitly allows a tool
-  call, you'll _also_ never see a prompt β€” Crush treats `"decision": "allow"`
-  as affirmative pre-approval.
-- Hooks only fire 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.
-- Hooks are also compatible with hooks in Claude Code, however this document
-  covers the Crush-specific API only. One intentional divergence: Crush treats
-  `updated_input` as a shallow-merge patch rather than a full replacement β€” see
-  [Output](#output) below.
+- Hooks 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
+- Crush ships with a builtin `crush-hook` skill 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. If there's a hook you'd like to see, let us know.
+  the full gamut; please let us know which hooks you'd like to see next
+- Hooks run in parallel for speed, but their results compose in config order
+  for determinism
+
+### Some things you can do with hooks:
+
+- Block "dangerous" commands: no more `git push -f` or `cabal init`
+- Rewrite tool input: turn `node` calls info `deno`, scrub secrets from
+  commands, rewrite all mentions of "Haskell" into "Haskell, The Best
+  Language", and so on
+- Inject context: add notes to the model's context whenever certain tools are
+  called. For example: "remember to run gofumpt after editing Go files"
+- Auto-approve tools: skip the permission prompt for bash commands that
+  you know are safe
+- Log certain tool calls
+
+…And lots more. Show us what you're building!
+
+## Baby's First Hook
+
+Let's just dive into it and make a simple hook. This particular hook will
+disallow the use of Haskell (but we love you, Simon Peyton Jones).
+
+### Config
+
+The first thing we need to do is hook up our hook. Let's add the following to
+our local `crush.json`. You can, of course, do this globally, too.
+
+```jsonc
+{
+  // As expected, hooks go in a "hooks" object.
+  "hooks": {
+    // PreToolUse is an event that fires before a tool is used.
+    "PreToolUse": [
+      {
+        // What tool do we want to hook into? In this case, Bash, because it
+        // runs the stuff we wanna block.
+        "matcher": "^bash$",
+
+        // The path to our actual hook script.
+        "command": "./no-haskell.sh",
+      },
+    ],
+  },
+}
+```
+
+Now, let's make our `no-haskell.sh` hook script.
+
+```bash
+#!/usr/bin/env bash
+
+# Disallow ghc, cabal, and stack. Pipe the bash command output
+# ($CRUSH_TOOL_INPUT_COMMAND) to grep and match on a regexp.
+if echo "$CRUSH_TOOL_INPUT_COMMAND" | grep -qE '(^| )((ghc|cabal|stack)(\.exe)?)( |$)'; then
+
+  # Someone is trying to use Haskell. Let's send a message back to the model
+  # and user explaining why we're blocking this. Note that we send all feedback
+  # like this to stderr.
+  echo "No Haskell allowed, kiddo." >&2
+
+  # Now, block the tool call by exiting with code 2.
+  exit 2
+fi
+```
+
+That's basically it. For the full guide on how hooks work, however, read on.
+
+---
 
 ## Configuration
 
-Hooks can be added to your `crush.json` at both the global and project-level,
-with project level hooks taking precedence.
+Hooks can be added to your `crush.json` (or `.crush.json`) at both the global
+and project-level, with project level hooks taking precedence.
 
 ```jsonc
 {
@@ -48,8 +107,8 @@ with project level hooks taking precedence.
 }
 ```
 
-Hooks are keyed by event name. Only `command` is required; omit `matcher` to
-match all tools.
+Remember, hooks will run in parallel but resolve in config order. Last hook
+wins when rewriting input, but first deny wins when blocking.
 
 ## Events
 
@@ -58,29 +117,24 @@ Here are the events you can hook into (spoiler: there's currently just one):
 ### PreToolUse
 
 This hook fires before every tool call. Use it to block dangerous commands,
-enforce policies, rewrite tool input, or inject context the model should see.
+enforce policies, rewrite tool input, inject context the model should see, log
+stuff, and so on.
 
 **Matched against**: the tool name (e.g. `bash`, `edit`, `write`,
 `mcp_github_create_pull_request`).
 
-> [!NOTE] Event names are case insensitive and snake-caseable, so `PreToolUse`,
+> [!NOTE]
+> Event names are case insensitive and snake-caseable, so `PreToolUse`,
 > `pretooluse`, `PRETOOLUSE`, `pre_tool_use`, and `PRE_TOOL_USE` all work.
 
-## Baby's First Hook
-
-Hooks are just shell scripts. Go crazy.
+**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.
 
-```bash
-#!/usr/bin/env bash
-
-# Log all bash tool calls to a file.
-printf "%s: %s / %s" \
-    "$(date -Iseconds)" \
-    "$CRUSH_SESSION_ID" \
-    "$CRUSH_TOOL_INPUT_COMMAND" >> ./bash.log
-```
-
-That's basically it. For the full guide on how hooks work, however, read on.
+Hooks are keyed by event name. Only `command` is required, and you can omit
+`matcher` to match all tools.
 
 ## Building Hooks
 
@@ -93,6 +147,11 @@ When a hook fires, Crush:
 4. Waits for all to finish (or time out), then aggregates results **in config
    order**: deny wins over allow, allow wins over none; `updated_input` patches
    shallow-merge in order.
+5. Applies the result **before** permission checks. If the aggregated decision
+   is `deny`, the tool call is blocked and you never see a permission prompt
+   for it. If it's `allow`, Crush treats that as affirmative pre-approval and
+   also skips the prompt. Silence (no decision) falls through to the normal
+   permission flow.
 
 Note that you can omit `matcher` and match in your shell script instead, however
 you'll incur some additional overhead as Crush will `exec` each script.
@@ -436,6 +495,20 @@ 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.
+
+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.
+
+---
+
 ## Reference
 
 This is the official reference of the narrative above. If prose and this section