docs/hooks/FUTURE.md
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.
context_filesStatus: planned, not implemented.
Today, a hook that wants to inject reference material into the agent's context
has exactly one knob: context (string or array of strings). Whatever the hook
puts there is concatenated into what the model sees. That's fine for short notes
("current branch: main", "scrubbed secrets") but it scales badly:
README.md or package.json into context burns tokens on
every tool call where the hook fires.context_files is the lazy alternative: the hook returns paths, not
contents. Crush tells the agent the files exist and are relevant, and the agent
decides whether to open them with its existing view tool.
Additive envelope field. Accepts a list of strings:
{
"decision": "allow",
"context": "Scrubbed one secret",
"context_files": ["README.md", "docs/ARCHITECTURE.md"],
}
Paths are resolved relative to CRUSH_CWD. Non-existent paths are dropped with
a debug log (don't fail the hook over a missing file).
Crush appends a short note to the turn's context along the lines of:
## Referenced files
- README.md
- docs/ARCHITECTURE.md
No file contents are inlined. The agent opens them with view if it decides
they're relevant. This keeps cost proportional to need.
Matches the existing rules for lists:
deny or halt.Purely additive. Hooks that don't emit context_files are unaffected. Existing
envelopes keep working unchanged. No version bump required.
context_files paths be constrained to CRUSH_PROJECT_DIR? Probably
yes, to avoid hooks smuggling in arbitrary filesystem reads."README.md:1-40") or keep it dead simple
(whole-file references only)? Start simple; add ranges only if asked for.{"path": "...", "reason": "..."}) would allow that but complicates the
schema. Defer until there's a real user need.Status: not implemented.
Today hooks fire only on the top-level agent's tool calls. Sub-agents
(agent task tool, agentic_fetch, future delegated loops) run without hook
interception so a single delegated turn doesn't trigger the user's hook N times.
The outer sub-agent tool call itself is hooked, so blanket policy like "never spawn sub-agents" or "rewrite prompts sent to the task agent" still works from the coder's side. The sub-agent's inner loop is the part that's exempt.
agentic_fetch."Until someone actually asks, don't ship this. YAGNI.
Additive, per-hook. Zero-value matches current default (skip sub-agents):
{
"hooks": {
"PreToolUse": [
{
"matcher": "^bash$",
"command": "./hooks/audit.sh",
"include_sub_agents": true, // default false
},
],
},
}
Implementation changes where wrapToolsWithHooks decides to skip. Instead of a
single isSubAgent bailout, the runner filters per-hook matches by the hook's
include_sub_agents flag. Hooks that opt in get wrapped into sub-agent tool
slices too; everything else stays skipped.
Purely additive. Hooks that don't set include_sub_agents get the default
(false = skip sub-agents). No wire format change, no version bump. The initial
transition from "hooks fire everywhere" to "hooks skip sub-agents by default"
was a one-time behavior change; adding the opt-in is pure addition.
Extend the stdin payload with "is_sub_agent": true|false so hook scripts that
opt in can branch on caller type ("audit top-level and sub-agent calls
differently"). Also purely additive — hooks that don't read the field are
unaffected.
hooks.include_sub_agents default? A global
toggle is simpler but coarse-grained; per-hook is more flexible and
composable. Start per-hook; a global default can be layered on later with
explicit precedence ("per-hook overrides global").UserPromptSubmit eventStatus: not implemented.
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).
feat/x; last commit: <sha> <title>").context_files (when that lands) so the agent
knows where to look without being force-fed contents.production.env") — with deny and a reason the user sees.@TODO → "please address the TODO in …").Stdin payload extends the common envelope with the prompt:
{
"event": "UserPromptSubmit",
"session_id": "…",
"cwd": "/home/user/project",
"prompt": "fix the login flow",
"attachments": ["screenshot.png"],
}
Output envelope reuses common fields plus one new per-event field,
updated_prompt:
{
"decision": "allow", // optional; deny blocks the submission entirely
"reason": "includes a production secret", // shown to the user when denying
"context": "Current branch: feat/login",
"updated_prompt": "fix the login flow\n\n(from @TODO on line 42)",
}
updated_prompt is a full replacement — not a merge patch — because a
prompt is a single string with no natural key structure. If multiple hooks emit
updated_prompt, later hooks in config order win.
Reuses the universal rules:
halt is sticky. Halts the whole turn before the LLM is called.context concatenates in config order.updated_prompt: last writer wins.decision: "deny" blocks the submission. The user sees reason; the turn
never reaches the LLM.PreToolUseupdated_input: there are no tool inputs at this point.decision: "allow" is functionally identical to silence. It exists only for
symmetry with PreToolUse and to give hook authors a consistent vocabulary.
(Could be argued both ways — consider dropping it here.)EventUserPromptSubmit in internal/hooks/hooks.go.Runner.Run already takes an event name; no interface change.sessionAgent.Run (or the coordinator's Run path) that
fires hooks after creating the user message but before the first LLM call. If
the aggregate decision is deny or halt, abort the turn and surface
reason to the user.context, prepend it to the prompt seen by the LLM (or attach
as a system-message-level note — decide based on how the prompt is threaded
through fantasy).updated_prompt, replace the prompt body before the first LLM
call. The message row in the DB should still store the original prompt so
the user sees what they typed; only the outbound version is rewritten. (Or:
store both, show the original, send the rewritten — mirror how updated_input
is handled today.)/commands prefix? Does UserPromptSubmit fire for slash
commands, or are those intercepted earlier? Probably earlier — hooks see only
freeform prompts that would actually reach the LLM.Status: not implemented.
Today the hook runner uses exec.Command("sh", "-c", hook.Command). On Windows
this fails without WSL or Git Bash on PATH. Even with sh.exe available,
Windows has no kernel shebang handling — ./hooks/foo.sh can't be exec'd
directly the way it can on Unix. Hooks are effectively Unix-only.
Keep the command field as a string. Tokenize it shell-style, examine
argv[0], and branch:
argv[0] starts with ./, ../, /, or ~/ — treat it as a file
invocation. Read the first ≤128 bytes, parse a shebang if present, and
dispatch to the named interpreter via os/exec. Extra args from the command
string pass through to the interpreter.node, bash, jq, builtins,
pipelines, redirects, etc. via its own exec handler.No sentinel: a script with no shebang defaults to mvdan. A script with an
explicit shebang (#!/bin/bash, #!/usr/bin/env python3, etc.) uses the named
interpreter, which the user is responsible for having on PATH. Same contract on
every platform.
command | argv[0] | Route |
|---|---|---|
ls -la | ls | mvdan |
bash -c 'ls' | bash | mvdan (which execs bash) |
node ./script.js | node | mvdan (which execs node) |
./script.sh (no shebang) | ./script.sh | mvdan, fed the file |
./script.sh (#!/bin/bash) | ./script.sh | bash ./script.sh |
./script.py (#!/usr/bin/env python3) | ./script.py | python3 ./script.py |
./script.exe | ./script.exe | os/exec direct |
bash.exe,
python.exe, node.exe, etc.). Crush does the dispatch that the Windows
kernel won't.dispatch(ctx, cmd string, env []string, stdin io.Reader) (stdout, stderr string, exitCode int, err error)
in internal/hooks/.argv[0]; if path, read shebang with a bounded
io.LimitReader and parse. Support:
#!/absolute/interpreter args…#!/usr/bin/env NAME → resolve NAME on PATH#!/usr/bin/env -S NAME args… → treat as above; -S is common enough to
handle. Other env flags can error.interp.ExitStatus and os/exec's
ProcessState.ExitCode() both become a single int.exec.CommandContext for its
children, so a cancelled hook kills both the interpreter and any children.
Verify with a sleep 60 test.interp.Runner per hook invocation (parallel hooks must not share
state).Runner.runOne in internal/hooks/runner.go replaces its
exec.Command("sh", "-c", …) with a call to dispatch(…). Everything
downstream (exit-code 2 / 49 / other dispatch, stdout JSON parsing,
stderr-as-reason) stays identical.
Cross-platform matrix:
echo hi; exit 2; pipelines; redirections.#!/bin/bash on Unix — invokes system bash.#!/usr/bin/env python3 — invokes python if present, skips if not.#!/usr/bin/env -S node --foo — extra flags preserved../missing-file — non-blocking error, hook proceeds as "no opinion"../script.exe exec'd directly; bash-shebang script fails
gracefully when bash isn't on PATH.env
flags, args with spaces, CRLF, missing interpreter. Well-trodden but needs
real tests.relative/path.sh (no leading
./) gets mvdan'd, not file-dispatched. Matches shell intuition — at a bash
prompt, relative/path.sh doesn't run unless . is on PATH — but worth
documenting.bash.exe or python.exe. Users bring their own interpreters.crush_approve etc.). Hooks stay portable and
testable under bare bash..sh-extension filter on discovery. Hook file shape is driven by shebang,
not filename.#!/usr/bin/env crush
and have it mean something). If we want that later, it's an additive feature,
not a dependency of this work.