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

#HookEventMatcherChannelTimeoutPurpose
1forced-evalUserPromptSubmitadditionalContext2sManager-role + split-discipline reminder
2session-startSessionStartadditionalContext3sSession init, permission_mode tag
3role-recallSessionStartcompactadditionalContext2sRe-inject [ROLE]/[SPLIT]/[BRANCH] after a compaction
4compact-recallSessionStartcompactadditionalContext2sRe-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:

  1. Read JSON from stdin (via readStdin())
  2. Receive fields: session_id, cwd, source (SessionStart), permission_mode (all events), transcript_path (compact-recall only)
  3. Output JSON to stdout (via output())
  4. Write logs to stderr

Shared utilities:

  • hooks/lib/utils.mjs — I/O, configuration, logging, and projectRoot(): CLAUDE_PROJECT_DIR env var when it points at a real path, else walk upward from the hook’s cwd for the nearest .git or .claude directory, else the starting cwd itself — never throws, never guesses beyond that
  • hooks/lib/reminder.mjs — the one normative copy of [ROLE]/[SPLIT]/[BRANCH], imported by forced-eval.mjs and role-recall.mjs so the two hooks cannot drift apart

Detailed description

1. forced-eval.mjs

UserPromptSubmit additionalContext

Keeps 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.

ParameterValue
EventUserPromptSubmit
ChanneladditionalContext
Timeout2000 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_TEXT via additionalContext — UserPromptSubmit cannot rewrite the prompt (updatedInput is 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 additionalContext

Initializes the session, checks for brewcode/Claude updates, manages Plan Mode symlinks, and reports the active permission_mode in the system message for audit.

ParameterValue
EventSessionStart
ChanneladditionalContext
Timeout3000 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:

SourceBehavior
initLog session_id, append permission_mode tag
resumeLog session_id, append permission_mode tag
clearCreate symlink LATEST.md -> newest plan

LATEST.md symlink:

  1. Checks ~/.claude/plans/ for .md files
  2. Picks the newest one (by mtime)
  3. 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 additionalContext

Re-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.

ParameterValue
EventSessionStart
Matchercompact
ChanneladditionalContext
Timeout2000 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_TEXT from hooks/lib/reminder.mjs, capped at 9000 chars — the same import forced-eval.mjs uses, so the two cannot drift
  • Stateless: compactions can chain, and it fires again on every one

4. compact-recall.mjs

SessionStart / compact additionalContext

Re-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.

ParameterValue
EventSessionStart
Matchercompact
ChanneladditionalContext
Timeout2000 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:

BranchConditionInjects
plan-filelast recorded plan path still exists on disk[PLAN] — read that file before any other action
plan-latestno usable planFilePath in the transcript, but <project-root>/.claude/plans/LATEST.md exists[PLAN] — read that project-local link before any other action
plan-missingplan path recorded, but the file is gone (~/.claude/plans gets pruned)[PLAN] — rebuild from the compact summary plus TaskList
plan-in-summaryplan mode ran, no plan file was ever recorded[PLAN] — follow a plan if the summary holds one, else fall back to intent
intentnone 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.