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: implemented. See the Execution model
section in README.md for the current behavior and contract.