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.
What's new in 6.2.0
forced-eval.mjs no longer fires on every prompt — it now fires on the 1st real prompt, then every
10th, via a session-keyed counter (same pattern as think-short-prompt-counter.mjs). The injected
REMINDER_TEXT also shrank 636 → 472 chars. See the detailed description below.
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 without taxing every single turn. Fires on the 1st real prompt of a session, then every 10th prompt after that (10, 20, 30, …), 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 |
| Cadence | 1st real prompt, then every 10th (INTERVAL = 10) |
Exact injected text (REMINDER_TEXT, from hooks/lib/reminder.mjs — the same text role-recall.mjs re-injects after a compaction; 472 chars):
[ROLE] Manager: check .claude/agents/ (project first); domain expert -> delegate regardless of size, else self.
[SPLIT] One agent for an hour = drift you cannot observe: bounded units (1 deliverable, ~5 files, ~20 min), fan out in ONE message; real data handoff = dependency, else parallel; spawn prompt: goal + scope + done-so-far + consumer + acceptance.
[BRANCH] No branch/PR instruction -> stay current, else main; take over ALL workspace changes incl. other sessions.
What it does:
- Receives the user prompt
- Skips meta-replies first (see below) — they never touch the counter
- Bumps a session-keyed counter (private 0700 tmp dir, atomic write, lstat-only — same pattern as
think-short-prompt-counter.mjs) and injects only when the new count is 1 or a multiple of 10 - 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 the 1st real user prompt of a session, then every 10th one after that
(prompts 10, 20, 30, …) — not every prompt. Meta-replies that carry no task
(yes/no/ok/thanks/continue/confirm-style answers, a bare number, or a single letter) are
skipped before the counter is touched, so they never consume or land on an inject slot. Coverage
after a compaction is unaffected — role-recall.mjs re-injects the same text unconditionally on
every compaction, independent of this cadence.
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.
Brewcode Overview
Plugin overview, skills, and agents.
Updating plugins
/brewtools:plugin-update to check and update the brewcode plugin suite in one command.
See the FAQ for details.