The last piece (Progressive Disclosure: Shaping Claude Code's Output) ended on one small move. Every reply opens with its kind, [status], [analysis] or [narration], and that token decides which reply-shape rules apply and which checks run when the reply ends. One Stop hook read the token and did the rest.
That's also where the approach runs out. Stop fires once the reply is written, so a hook there can judge the reply and send it back, but anything it wants the agent to know arrives after the words are already on the screen. And the one script keeps growing: the length check, the kind check, a counter for how often the kind goes missing, and next a rule for the docs folder that has nothing to do with the end of a reply.
The first piece in the series (Progressive Disclosure: What, Where, When, and Why) left two promises open: when to move a rule off the page and onto an event, and how you'd know it loaded. This piece keeps both.
Different moments, different rules
A rule set is several lists, one per moment of a turn, and Claude Code can run a script of yours, called a hook, at each of those moments (hooks):
-
A prompt arrives (
UserPromptSubmit). Whatever a hook prints here goes in front of the model before it starts. That's the place for a rule about the work ahead: while you're on a database migration, every migration ships with a down step. -
A tool is about to run (
PreToolUse). A hook here sees which tool and which file. A documentation rule belongs on the write underdocs/, not on every prompt. What it adds lands next to the tool's result, so it shapes the edits after the first one. When even the first write has to follow the rule, the hook blocks the call and puts the rule in the reason. -
The reply ends (
Stop). The reply is finished and can be judged, so this is where checks live. The right check depends on the kind of reply: a status report gets 180 words, a narration 150, an analysis no cap. A reply over its limit goes back once.
Checks split the same way. A migration's down step can't be checked until the file exists, so that check runs after the write, on a fourth moment, PostToolUse. The length check waits for the finished reply and takes its limit from the reply's kind. Claude Code supplies the moments, and matching each rule and check to its moment is left to you.
Why one rule per hook stops scaling
The obvious way to wire this up is one hook per rule: a script and a settings.json entry each. Anthropic's hookify plugin tidies that up. Each rule is a markdown file naming an event, a pattern and an action, and the plugin's own hooks check every rule file on each event. For a handful of rules that warn or block on what the harness can see, a command, a file, a prompt, it's the right start.
Around rule twenty, or once a rule needs to know what the last reply said, the cracks show:
- Each rule sees only the one event in front of it, and can match only what that event carries: a command, a file path, the prompt text.
- Nothing remembers anything between events. Sure, every hook gets a
transcript_path, so a rule could go digging for the last reply itself. Then every rule digs on its own, on every event, and you're back to twenty scripts parsing the same file. - Nothing records what fired, so there's nothing to count.
- With a script per rule, a rule's condition also ends up in three places: a pattern in the script, a matcher in
settings.json, and the rule's text somewhere else.
The fix is to send every event through one place. Here that place is an event bus, a single script Claude Code calls for every hook event you care about. It reads the event once, works out which rules asked for it, and answers the harness. Your settings.json shrinks to one script, registered on each event:
{
"hooks": {
"InstructionsLoaded": [
{
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/bus/bus.sh"
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/bus/bus.sh"
}
]
}
],
"PreToolUse": [
{
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/bus/bus.sh"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/bus/bus.sh"
}
]
}
]
}
}
Put telemetry on it first
Before you move a single rule onto the bus, have it write down what happens. It's the cheapest step, and it changes what you can see.
The agents that build Reporails open every reply with its kind and get checked on it when the reply ends. In the 13 days before I wrote this, across four of our repositories, they made 1,979 reply attempts, resends included, and 106 of them opened without a kind. That's about 1 in 19. All our end-of-reply checks together sent back 497 of those attempts, about 1 in 4. From the terminal you'd catch the odd resend scrolling past and never put a number on it. We have the number because a hook wrote every attempt down.
Claude Code already hands you part of this. Its InstructionsLoaded hook fires whenever a CLAUDE.md or .claude/rules/ file enters context and reports why: session_start, nested_traversal, path_glob_match, include or compact (hooks). Rulestack has a good write-up on logging it by itself (Did Claude Code actually load your rules?). On the bus it's one more event going into the same log.
Here's the whole first version of the bus, .claude/bus/bus.sh:
#!/usr/bin/env bash
# Every hook event comes through here. Step one: write down what happened.
jq -c '{ts: (now | todate), event: .hook_event_name, tool: .tool_name,
file: (.tool_input.file_path // .file_path), reason: .load_reason,
kind: ([(.last_assistant_message // "") | scan("^\\[([a-z]+)\\]")[0]] | first)}
| with_entries(select(.value != null))' >> "$(dirname "$0")/log.jsonl"
Make it executable with chmod +x .claude/bus/bus.sh, and make sure jq is on your PATH. From then on, every event becomes one line in log.jsonl:
{"ts":"2026-09-30T09:27:55Z","event":"InstructionsLoaded","file":"/repo/CLAUDE.md","reason":"session_start"}
{"ts":"2026-09-30T09:27:55Z","event":"UserPromptSubmit"}
{"ts":"2026-09-30T09:27:55Z","event":"InstructionsLoaded","file":"/repo/.claude/rules/docs.md","reason":"path_glob_match"}
{"ts":"2026-09-30T09:27:55Z","event":"PreToolUse","tool":"Write","file":"/repo/docs/cache.md"}
{"ts":"2026-09-30T09:27:55Z","event":"Stop","kind":"status"}
{"ts":"2026-09-30T09:27:55Z","event":"Stop"}
And two questions you can already answer, how often each event fires and how many replies skipped their kind:
$ jq -s 'group_by(.event) | map({(.[0].event): length}) | add' .claude/bus/log.jsonl
{"InstructionsLoaded":2,"PreToolUse":1,"Stop":2,"UserPromptSubmit":1}
$ jq -s 'map(select(.event == "Stop"))
| {replies: length, missing_kind: map(select(.kind == null)) | length}' \
.claude/bus/log.jsonl
{"replies":2,"missing_kind":1}
That's the telemetry layer: which rule files loaded and why, which tools ran on which files, and which replies skipped their kind.
With great responsibility comes great power
Concentrating the events in one place and adding your own logic on top of the harness is a huge responsibility. The power it gives you is just as big.
The scattered pieces come together. Once your rules move onto it, they live in one folder, each with its moment in its own header, and one log records what happened. Listing every rule with its condition is a grep. Seeing what fired last Tuesday is a jq query.
You can bend it to what you need. A settings.json matcher can only filter on what Claude Code puts there: the event, plus one field it picks per event, such as the tool name. InstructionsLoaded's five reasons are all about files or the session. A script can match on anything it can read, and the bus sees every event, including what the last reply declared. Put a domain next to the kind, [status][migration], save it when the reply ends, and a rule can load for the work instead of for a file:
---
on: PreToolUse
tool: Write|Edit
path: */docs/*
declared: migration
---
When you document a migration, name its down step and the release that stops reading the old column.
on is when, tool and path are where, declared is why, and the body is what gets delivered. This rule loads when the agent writes under docs/ while the work is a migration, and stays out of every other turn. The bus can only read the declaration once the reply is finished, so it applies from the next turn on. The domain sticks until another one is declared; the kind belongs to one reply. Getting there takes one more step in the bus: for each rule, check its on, tool, path and declared against the event and the saved declaration, and deliver the body of every rule that matches.
You stop being locked to one agent. The rules and the log are plain files in your repository. The only part of the bus that knows it's talking to Claude Code is the part that reads the hook's input. The other agents that ship hooks send the same kind of input. Codex reads hooks from .codex/hooks.json with largely the same event names as Claude Code, and passes the finished reply as last_assistant_message, the same field the bus reads (Codex hooks). Cursor reads .cursor/hooks.json with names of its own, such as beforeSubmitPrompt, preToolUse and stop (Cursor hooks). Gemini CLI reads hooks from its settings.json, with BeforeTool and AfterAgent (Gemini CLI hooks). All of them pass a JSON payload on stdin with a hook_event_name field, and all of them read exit code 2 as "block this". The answers they expect back differ, so one small adapter per agent maps its event names in and its answers out, and the same rules and the same log serve all of them.
| Agent | Hooks live in | A few of its event names |
|---|---|---|
| Claude Code | .claude/settings.json |
UserPromptSubmit, PreToolUse, Stop
|
| Codex | .codex/hooks.json |
UserPromptSubmit, PreToolUse, Stop
|
| Cursor | .cursor/hooks.json |
beforeSubmitPrompt, preToolUse, stop
|
| Gemini CLI | settings.json |
BeforeTool, AfterAgent
|
The responsibility part
For the rules in .claude/rules/, Claude Code decides when they load. For the rules on your bus, you do. Nothing tells you which moment a rule belongs to, which two rules now say opposite things on the same turn (Opus 5: Cost of Instruction Conflicts), or which one hasn't loaded since you renamed a folder. Rename docs/ to doc/ and the docs rule never matches again. Start declaring [db] where you used to declare [migration], and the migration rules go quiet the same way. Either silence looks exactly like a week where nobody touched the docs or a migration.
The log shows what loaded. It can't show a rule that should have loaded and didn't, or whether a rule that did was worded well enough to steer anything. How you keep a rule set organized once the harness stops doing it for you is the next piece.
I work on Reporails, deterministic diagnostics and governance for the instruction files, rules, and prompts that steer coding agents. It reads the steering surface you wrote down and tells you, with measured evidence, which instructions couple to behavior and which are text the model can ignore.



Top comments (0)