← claude-core
Recursos
▮ CLAUDE-CORE · ACADEMY CLAUDE CODE v2.1.143 10 RECEITAS · 7 FAMÍLIAS ▮

HOOK COOKBOOK

Dez receitas de hook prontas pra produção — uma por necessidade real, cobrindo as sete famílias de eventos de ciclo de vida. Copia, cola, customiza. Cada receita tem <20 linhas de bash.

O que é um hook, em 30 segundos

Um hook é um script de shell que o Claude Code roda em um evento específico do ciclo de vida. Ele lê um payload JSON do stdin, decide via exit code (0 = permite, 2 em evento Pre* = BLOQUEIA com stderr mostrado pro agent, qualquer outro = só loga), e opcionalmente escreve additionalContext no stdout pro agent consumir.

Anatomia de uma receita

§1

Injetar CHANGELOG + estado não comitado no início da sessão

SessionStart

Quando — Claude abre uma sessão e o usuário quer as últimas notas de release e o estado atual do git apresentados automaticamente, em vez de perguntar "o que mudou?" toda vez.

▸ Payload (stdin)

{
  "session_id": "abc-123",
  "hook_event_name": "SessionStart",
  "source": "startup",                  // startup | resume | clear | compact
  "transcript_path": "/.../transcript.jsonl",
  "cwd": "/home/user/project"
}

▸ Script — .claude/hooks/session-start.sh

#!/usr/bin/env bash
set -euo pipefail
{
  echo "=== CHANGELOG (latest) ==="
  head -n 40 CHANGELOG.md 2>/dev/null || echo "(no CHANGELOG.md)"
  echo
  echo "=== UNCOMMITTED ==="
  git status --short 2>/dev/null || echo "(not a git repo)"
}

▸ Configuração

// .claude/settings.json
{ "hooks": {
    "SessionStart": [{ "hooks": [{
      "type": "command",
      "command": "bash .claude/hooks/session-start.sh"
    }] }]
} }

PegadinhaSessionStart dispara em todo source incluindo compact. Se você quer só startup, branche em source via jq -r '.source' primeiro.

§2

Bloquear saída se SESSION.md está desatualizado com WIP

Stop

Quando — você termina sessões com WIP que a próxima sessão esquece. O hook chia uma vez: "ou comita, ou atualiza SESSION.md, e aí termina normalmente."

▸ Script — .claude/hooks/session-stop.sh

#!/usr/bin/env bash
set -euo pipefail
dirty=$(git status --porcelain 2>/dev/null || true)
[[ -z "$dirty" ]] && exit 0
session_mtime=$(stat -c %Y .claude/SESSION.md 2>/dev/null || echo 0)
started=$(stat -c %Y .claude/.session-state/started-at 2>/dev/null || echo 0)
[[ "$session_mtime" -gt "$started" ]] && exit 0
[[ -f .claude/.session-state/nagged ]] && exit 0
touch .claude/.session-state/nagged
echo "Before ending: uncommitted WIP exists but SESSION.md was not updated this session. Update it (or commit), then end normally." >&2
exit 2

▸ Configuração

{ "hooks": {
    "Stop": [{ "hooks": [{
      "type": "command",
      "command": "bash .claude/hooks/session-stop.sh"
    }] }]
} }

Pegadinha — o marcador nagged é por sessão; sem ele o hook bloquearia pra sempre (loop infinito). Sempre inclui um guard "bloqueia no máximo uma vez".

§3

Auto-expandir @PR/123 em prompts pro diff do PR

UserPromptSubmit

Quando — você referencia PRs o tempo todo. Em vez de colar o diff toda vez, digita @PR/123 e o hook busca e inlina o diff.

▸ Payload

{
  "hook_event_name": "UserPromptSubmit",
  "user_message": "review @PR/123 for security issues"
}

▸ Script — .claude/hooks/expand-pr.sh

#!/usr/bin/env bash
set -euo pipefail
msg=$(jq -r '.user_message')
prs=$(echo "$msg" | grep -oE '@PR/[0-9]+' | sed 's|@PR/||' | sort -u || true)
[[ -z "$prs" ]] && exit 0
echo "=== referenced PR diffs ==="
for n in $prs; do
  echo "--- PR #$n ---"
  gh pr diff "$n" 2>/dev/null | head -c 4096 || echo "(failed to fetch)"
done

▸ Configuração

{ "hooks": {
    "UserPromptSubmit": [{ "hooks": [{
      "type": "command",
      "command": "bash .claude/hooks/expand-pr.sh"
    }] }]
} }

Pegadinha — o stdout de hooks UserPromptSubmit é injetado como additionalContext logo depois da mensagem do usuário. Mantém limitado (head -c 4096) ou um PR grande inunda a janela de contexto.

§4

Logar qual CLAUDE.md / regra carregou quando (debug)

InstructionsLoaded

Quando — você adicionou uma regra com escopo de path (frontmatter paths:) e parece que ela não está disparando. O hook escreve um trace JSONL pra você confirmar.

▸ Script — .claude/hooks/rule-load-debug.sh

#!/usr/bin/env bash
set -euo pipefail
[[ "$&#123;CLAUDE_RULE_LOAD_DEBUG:-0&#125;" = "1" ]] || exit 0
mkdir -p .claude
exec 9>>.claude/.rule-load-debug.jsonl
flock 9
jq -c '{ts:now|todate, file, memory_type, load_reason, globs, trigger_file, session_id}' >&9

▸ Inspecionar

tail -f .claude/.rule-load-debug.jsonl | jq .

PegadinhaInstructionsLoaded dispara dentro do mesmo turno do load que o triggou. Hook é observabilidade pura; stdout/stderr NÃO são injetados na conversa para esse evento.

§5

Bloquear formas destrutivas de shell

PreToolUse(Bash)

Quando — você confia no Claude pra rodar Bash, mas nunca quer ver rm -rf, git push --force, ou instalação de dependência sem lockfile. Bloqueia esses por forma, não por negação global.

▸ Script — .claude/hooks/pre-bash.sh

#!/usr/bin/env bash
set -euo pipefail
cmd=$(jq -r '.tool_input.command')
block() { echo "blocked: $1" >&2; exit 2; }
[[ "$cmd" =~ rm[[:space:]]+-[rRf]+ ]] && block "rm -rf is denied"
[[ "$cmd" =~ git[[:space:]]+push.*--force ]] && block "force push is denied"
[[ "$cmd" =~ (npm|pnpm|bun)[[:space:]]+(i|install) ]] && {
  [[ -f package-lock.json || -f pnpm-lock.yaml || -f bun.lock ]] ||     block "install without lockfile in tree"
}
exit 0

▸ Configuração

{ "hooks": {
    "PreToolUse": [{
      "matcher": "Bash",
      "hooks": [{
        "type": "command",
        "command": "bash .claude/hooks/pre-bash.sh"
      }]
    }]
} }

Pegadinhamatcher: "Bash" no nível do array limita o hook a chamadas Bash. Sem isso, o hook dispara em toda chamada de tool e seu jq query falha em payload com forma errada.

§6

Exigir handoff de delegação com 5 campos

PreToolUse(Agent)

Quando — você sempre acaba com sub-agents under-specificados que inventam o próprio framing. Impõe um handoff estruturado: TASK / CONTEXT / CONSTRAINTS / DELIVERABLE / DONE_WHEN.

▸ Script — .claude/hooks/pre-agent.sh

#!/usr/bin/env bash
set -euo pipefail
prompt=$(jq -r '.tool_input.prompt')
required=(TASK CONTEXT CONSTRAINTS)
outcome=0
for f in "$&#123;required[@]&#125;"; do
  echo "$prompt" | grep -qiE "^[[:space:]]*$f:" || { missing+=" $f"; outcome=1; }
done
echo "$prompt" | grep -qiE "^[[:space:]]*(DELIVERABLE|DONE_WHEN):" || outcome=1

[[ $outcome -eq 0 ]] && exit 0
cat >&2 <<MSG
delegation-gate: missing fields$missing (and/or DELIVERABLE/DONE_WHEN).
Use 5-field handoff:
  TASK:        <one sentence>
  CONTEXT:     <files/paths>
  CONSTRAINTS: <what NOT to do>
  DELIVERABLE: <artifact> | DONE_WHEN: <verifier>
MSG
exit 2

Pegadinha — match case-insensitive (-i) é proposital; agents escrevem Task:, TASK:, task: indiferentemente. Ancorar no início da linha (^) evita match em "task list" dentro da prosa.

§7

Rodar prettier check em todo arquivo editado

PostToolUse(Edit | Write | MultiEdit)

Quando — você quer consistência de format sem prettier --write manual depois de cada edit. O hook roda --check e mostra violações pro agent no próximo turno.

▸ Script — .claude/hooks/post-edit-prettier.sh

#!/usr/bin/env bash
set -euo pipefail
file=$(jq -r '.tool_input.file_path // .tool_input.path // empty')
[[ -z "$file" ]] && exit 0
[[ "$file" =~ \.(ts|tsx|js|jsx|json|md|css)$ ]] || exit 0
out=$(npx -y prettier --check "$file" 2>&1) && exit 0
echo "prettier-advisory: $file is not formatted" >&2
echo "$out" >&2
exit 0  # advisory: log, do not block

Pegadinha — o matcher "Edit|Write|MultiEdit" usa alternação regex. Nomes de campos de payload diferem: Edit/MultiEdit usam file_path; Write usa file_path; algumas tools legadas usaram path. O fallback // empty no jq cobre todas as formas.

§8

Sugerir alternativa quando o usuário nega uma tool

PermissionDenied

Quando — o agent pede Bash(curl …), você nega, mas preferia que ele usasse outro verbo. O hook injeta uma dica pro agent tentar de novo com a tool certa.

▸ Script — .claude/hooks/perm-denied-hint.sh

#!/usr/bin/env bash
set -euo pipefail
denied=$(jq -r '.tool_name + "(" + (.tool_input | tostring) + ")"')
case "$denied" in
  Bash*curl*)  echo "hint: try the WebFetch tool instead of curl" ;;
  Bash*rm*)    echo "hint: this project blocks rm; if you need to remove, ask the user" ;;
  Bash*gh*pr*) echo "hint: prefer the GitHub MCP server (mcp__github__*) for PR ops" ;;
esac

PegadinhaPermissionDenied dispara depois do usuário negar o prompt. Stdout vira additionalContext no próximo turno do agent. Não moralize — dá um redirect concreto ou fica quieto.

§9

Verificar se o deliverable que o sub-agent disse ter feito existe

SubagentStop

Quando — sub-agents dizem "pronto, criei o arquivo X" mas X não existe. O hook parseia o campo DELIVERABLE: do brief, checa o path, e flagga discrepâncias.

▸ Script — .claude/hooks/subagent-verify.sh

#!/usr/bin/env bash
set -euo pipefail
brief=$(jq -r '.tool_input.prompt // empty')
deliverable=$(echo "$brief" | grep -iE '^[[:space:]]*DELIVERABLE:' | head -1 | sed -E 's/^[^:]*:[[:space:]]*//')
# crude: pull any path-shaped token from the deliverable line
path=$(echo "$deliverable" | grep -oE '[a-zA-Z0-9_./-]+\.(ts|tsx|md|json|astro|sh|py|go|rs)' | head -1 || true)
[[ -z "$path" ]] && exit 0
[[ -f "$path" ]] && exit 0
echo "subagent-advisory: claimed deliverable '$path' not found in tree" >&2
exit 0  # advisory

Pegadinha — extrair paths de prosa é frágil; o script só pega deliverables que nomeiam um path com extensão reconhecida. Pra checks mais ricos, parea DELIVERABLE com DONE_WHEN: e roda o comando em vez disso.

§10

Snapshot dos últimos 12 turnos antes da sumarização

PreCompact

Quando — sessões longas são sumarizadas e o sumarizador descarta wording verbatim, paths de arquivo, identificadores exatos. O hook salva turnos brutos no disco pro próximo session-start re-injetar.

▸ Script — .claude/hooks/pre-compact.sh

#!/usr/bin/env bash
set -euo pipefail
transcript=$(jq -r '.transcript_path // empty')
[[ -f "$transcript" ]] || exit 0
# extract last 12 user prompts + their assistant text
jq -c 'select(.role=="user" or .role=="assistant") | {role, content}' "$transcript" \
  | tail -n 48 > .claude/COMPACT_NOTES.md
echo "wrote .claude/COMPACT_NOTES.md ($(wc -l < .claude/COMPACT_NOTES.md) lines)" >&2

▸ Hook companheiro SessionStart

# in .claude/hooks/session-start.sh
if [[ "$(jq -r '.source')" = "compact" && -f .claude/COMPACT_NOTES.md ]]; then
  echo "=== PRE-COMPACT SNAPSHOT ==="
  cat .claude/COMPACT_NOTES.md
fi

Pegadinha — só PreCompact pode escrever antes do sumarizador truncar contexto, e só SessionStart com source: "compact" pode injetar depois. Nenhum outro hook ponteia esse gap. As duas metades são necessárias.