Hooks
Hooks are Node.js scripts attached to Claude Code lifecycle events.
They inject context and manage session state. Brewcode registers 4 hooks in hooks.json,
plus a shared lib/ that holds the reminder text and I/O helpers they import.
Summary table
| # | Hook | Event | Matcher | Channel | Timeout | Purpose |
|---|---|---|---|---|---|---|
| 1 | forced-eval | UserPromptSubmit | — | additionalContext | 2s | Manager-role + split-discipline reminder |
| 2 | session-start | SessionStart | — | additionalContext | 3s | Session init, permission_mode tag |
| 3 | role-recall | SessionStart | compact | additionalContext | 2s | Re-inject [ROLE]/[SPLIT]/[BRANCH] after a compaction |
| 4 | compact-recall | SessionStart | compact | additionalContext | 2s | Re-anchor plan, intent and task graph after a compaction |
Execution flow
UserPromptSubmit
└── forced-eval.mjs [ROLE] delegate-to-expert + [SPLIT] bounded units + [BRANCH] default-to-main
SessionStart
└── session-start.mjs Version check, plan-symlink, permission_mode tag
SessionStart (matcher: compact)
├── role-recall.mjs Same [ROLE]/[SPLIT]/[BRANCH] text, unconditional
└── compact-recall.mjs [PLAN] or [INTENT], plus [TASKS] when a task graph exists
Hook files
brewcode/hooks/
forced-eval.mjs UserPromptSubmit handler
session-start.mjs SessionStart handler
role-recall.mjs SessionStart handler, matcher "compact"
compact-recall.mjs SessionStart handler, matcher "compact"
hooks.json Hook registry (event bindings)
lib/
utils.mjs Shared I/O, configuration, logging
reminder.mjs REMINDER_TEXT: [ROLE] + [SPLIT] + [BRANCH]
tests/
run.sh Runs every suite, reports pass/fail counts
suite-session-start.mjs Covers session-start.mjs and compact-recall.mjs -- 68 checks, all passing
I/O protocol
All hooks follow a unified protocol:
- Read JSON from stdin (via
readStdin()) - Receive fields:
session_id,cwd,source(SessionStart),permission_mode(all events),transcript_path(compact-recall only) - Output JSON to stdout (via
output()) - Write logs to stderr
Shared utilities:
hooks/lib/utils.mjs— I/O, configuration, logging, andprojectRoot():CLAUDE_PROJECT_DIRenv var when it points at a real path, else walk upward from the hook’scwdfor the nearest.gitor.claudedirectory, else the startingcwditself — never throws, never guesses beyond thathooks/lib/reminder.mjs— the one normative copy of[ROLE]/[SPLIT]/[BRANCH], imported byforced-eval.mjsandrole-recall.mjsso the two hooks cannot drift apart
Detailed description
1. forced-eval.mjs
UserPromptSubmit additionalContextKeeps delegation discipline in front of the model. Intercepts every user prompt and appends three short lines: the Manager role rule, the split rule, and the branch-default rule. There is no skill-activation nudge — modern models pick skills on their own.
| Parameter | Value |
|---|---|
| Event | UserPromptSubmit |
| Channel | additionalContext |
| Timeout | 2000 ms |
Exact injected text (REMINDER_TEXT, from hooks/lib/reminder.mjs — the same text role-recall.mjs re-injects after a compaction):
[ROLE] Manager: scan agents (project .claude/agents/ first) - expert for this domain exists -> delegate regardless of size; no expert or trivial one-off -> self.
[SPLIT] One agent for an hour = drift you cannot observe: split into bounded units (1 deliverable, ~5 files, ~20 min), fan out in ONE message; a dependency must be a REAL data handoff, else parallel; every spawn prompt carries goal + scope + what is already done + who consumes the result + acceptance.
[BRANCH] Stay on the current branch; none chosen -> main. No explicit branch/PR instruction -> work on main and take over ALL workspace changes, incl. from other sessions.
What it does:
- Receives the user prompt
- Injects
REMINDER_TEXTviaadditionalContext— UserPromptSubmit cannot rewrite the prompt (updatedInputis silently dropped in CC 2.1.x) - Output is capped at 9000 chars (10K disk-spill safety, CC 2.1.174+)
When it fires: On every user input, including slash commands. Skipped only for
a wrong hook_event_name, an empty prompt, and meta-replies that carry no task:
yes/no/ok/thanks/continue/confirm-style answers, a bare number, or a single letter.
2. session-start.mjs
SessionStart additionalContextInitializes the session, checks for brewcode/Claude updates, manages Plan Mode symlinks, and reports the active permission_mode in the system message for audit.
| Parameter | Value |
|---|---|
| Event | SessionStart |
| Channel | additionalContext |
| Timeout | 3000 ms |
The hook reads permission_mode from the hook payload and appends it to systemMessage so every session records its trust level (default / plan / bypassPermissions). Output is capped at ~9000 chars (10K disk-spill safety, CC 2.1.174+).
Logic by session source:
| Source | Behavior |
|---|---|
init | Log session_id, append permission_mode tag |
resume | Log session_id, append permission_mode tag |
clear | Create symlink LATEST.md -> newest plan |
LATEST.md symlink:
- Checks
~/.claude/plans/for.mdfiles - Picks the newest one (by mtime)
- If the file is less than 60 seconds old — creates
.claude/plans/LATEST.md->~/.claude/plans/<newest>.md
If <root>/.claude/plans is itself a symlink out of the project, the hook detects it, logs a warning, and
returns without creating anything — it no longer follows that symlink to write LATEST.md somewhere outside
the project root.
3. role-recall.mjs
SessionStart / compact additionalContextRe-injects the same [ROLE]/[SPLIT]/[BRANCH] frame as forced-eval.mjs, unconditionally, the moment a session starts
back up after a compaction — the one point where the summary has just collapsed every earlier copy of it.
| Parameter | Value |
|---|---|
| Event | SessionStart |
| Matcher | compact |
| Channel | additionalContext |
| Timeout | 2000 ms |
Why it exists: an auto-compaction has no prompt, so forced-eval.mjs never fires for it. Without this hook the
session quietly stops delegating after a few compactions.
What it does:
- Fires only when
input.source === 'compact'; any other source (startup,resume,clear,fork) returns{}— those already carry the frame - Injects
REMINDER_TEXTfromhooks/lib/reminder.mjs, capped at 9000 chars — the same importforced-eval.mjsuses, so the two cannot drift - Stateless: compactions can chain, and it fires again on every one
4. compact-recall.mjs
SessionStart / compact additionalContextRe-anchors plan, original intent and task graph after a compaction, so the session does not start a brand-new task
graph on top of the one it already had. additionalContext on SessionStart is the only channel that reaches the
model here — PostCompact stdout is UI-only, and UserPromptSubmit never fires on an auto-compaction because there
is no prompt.
| Parameter | Value |
|---|---|
| Event | SessionStart |
| Matcher | compact |
| Channel | additionalContext |
| Timeout | 2000 ms |
It reads only this session’s transcript_path — one readFileSync, guarded by a statSync check (must be a
regular file, at most 64 MB) — then scans the raw buffer for a handful of markers, no JSONL parsing. Measured on
an 8.13 MB transcript: the scan itself (one buffer read plus five substring scans) is ~6 ms; the full process
wall clock is ~30 ms standalone and ~55 ms spawned from a node parent, where node startup dominates.
Decision ladder, first match wins:
| Branch | Condition | Injects |
|---|---|---|
plan-file | last recorded plan path still exists on disk | [PLAN] — read that file before any other action |
plan-latest | no usable planFilePath in the transcript, but <project-root>/.claude/plans/LATEST.md exists | [PLAN] — read that project-local link before any other action |
plan-missing | plan path recorded, but the file is gone (~/.claude/plans gets pruned) | [PLAN] — rebuild from the compact summary plus TaskList |
plan-in-summary | plan mode ran, no plan file was ever recorded | [PLAN] — follow a plan if the summary holds one, else fall back to intent |
intent | none of the above | [INTENT] — re-read the user’s original task from the compact summary |
plan-latest exists because the hook’s only signal, "planFilePath":", is written INTO the transcript —
a plan that predates the transcript (after --resume or /clear) leaves no such marker to scan for. The
project-local LATEST.md symlink is the same one session-start.mjs maintains on source === 'clear', so
this rung can only ever point at this project’s own most recent plan, never a foreign one.
[TASKS] is appended whenever the transcript contains a TaskCreate call, and is ordered before the plan is acted
on — the built-in task reminder can lag several turns and show empty, so TaskList is the authority.
Guarantee: on source === 'compact' it always injects something — every failure path degrades to [INTENT],
never to silence, and it never names a plan from outside this session’s own transcript.
Trade-off: two plans in one session — the LAST recorded plan path wins.
Latest Release
Download, changelog, and installation instructions.
View on GitHub
Source code, README, and configuration files.