← claude-core
Recursos
▮ CLAUDE-CORE · ACADEMY CLAUDE CODE v2.1.143 10 RECETAS · 7 FAMILIAS ▮

HOOK COOKBOOK

Diez recetas de hook listas para producción — una por necesidad real, cubriendo las siete familias de eventos de ciclo de vida. Copia, pega, personaliza. Cada receta es <20 líneas de bash.

Qué es un hook, en 30 segundos

Un hook es un script de shell que Claude Code ejecuta en un evento específico del ciclo de vida. Lee un payload JSON de stdin, decide vía exit code (0 = permite, 2 en un evento Pre* = BLOQUEA con stderr mostrado al agent, cualquier otro = solo loguea), y opcionalmente escribe additionalContext a stdout para que el agent lo consuma.

Anatomía de una receta

§1

Inyectar CHANGELOG + estado sin commit al iniciar sesión

SessionStart

Cuándo — Claude abre una sesión y el usuario quiere las últimas notas de release y el estado actual de git presentados automáticamente, en vez de preguntar "¿qué cambió?" cada 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)"
}

▸ Configuración

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

TrampaSessionStart dispara en cada source incluyendo compact. Si solo quieres startup, ramifica en source vía jq -r '.source' primero.

§2

Bloquear salida si SESSION.md está desactualizado con WIP sin commit

Stop

Cuándo — terminas sesiones con WIP que la próxima sesión olvida. El hook nag una vez: "o haces commit, o actualizas SESSION.md, y luego terminas 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

▸ Configuración

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

Trampa — el marcador nagged es por sesión; sin él el hook bloquearía para siempre (loop infinito). Siempre incluye un guard "bloquea como máximo una vez".

§3

Auto-expandir @PR/123 en prompts al diff del PR

UserPromptSubmit

Cuándo — referencias PRs constantemente. En vez de pegar el diff cada vez, escribes @PR/123 y el hook lo obtiene y lo inline.

▸ 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

▸ Configuración

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

Trampa — el stdout de hooks UserPromptSubmit se inyecta como additionalContext justo después del mensaje del usuario. Mantenlo acotado (head -c 4096) o un PR grande inunda la ventana de contexto.

§4

Loguear qué CLAUDE.md / regla cargó cuándo (debug)

InstructionsLoaded

Cuándo — añadiste una regla con alcance de path (frontmatter paths:) y parece que no se dispara. El hook escribe un trace JSONL para que confirmes.

▸ 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

▸ Inspeccionar

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

TrampaInstructionsLoaded dispara dentro del mismo turno del load que la disparó. El hook es observabilidad pura; stdout/stderr NO se inyectan en la conversación para este evento.

§5

Bloquear formas destructivas de shell

PreToolUse(Bash)

Cuándo — confías en Claude para ejecutar Bash, pero nunca quieres ver rm -rf, git push --force, o instalación de dependencia sin lockfile. Bloquea esos por forma, no por negación 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

▸ Configuración

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

Trampamatcher: "Bash" a nivel de array limita el hook a llamadas Bash. Sin esto, el hook dispara en cada llamada a tool y tu query jq falla en un payload con forma equivocada.

§6

Exigir un handoff de delegación con 5 campos

PreToolUse(Agent)

Cuándo — sigues recibiendo sub-agents subespecificados que se inventan su propio framing. Impón un handoff estructurado: 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

Trampa — match case-insensitive (-i) es a propósito; los agents escriben Task:, TASK:, task: indistintamente. Anclar al inicio de línea (^) evita match en "task list" dentro de la prosa.

§7

Ejecutar prettier check en cada archivo editado

PostToolUse(Edit | Write | MultiEdit)

Cuándo — quieres consistencia de formato sin prettier --write manual después de cada edit. El hook ejecuta --check y muestra violaciones al agent en su 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

Trampa — el matcher "Edit|Write|MultiEdit" usa alternación regex. Los nombres de campos del payload difieren: Edit/MultiEdit usan file_path; Write usa file_path; algunas tools legacy usaron path. El fallback // empty en jq cubre todas las formas.

§8

Sugerir una alternativa cuando el usuario niega una tool

PermissionDenied

Cuándo — el agent pide Bash(curl …), lo niegas, pero preferirías que usara otro verbo. El hook inyecta una pista para que el agent reintente con la tool correcta.

▸ 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

TrampaPermissionDenied dispara después de que el usuario niega el prompt. El stdout se vuelve additionalContext en el próximo turno del agent. No moralices — da un redirect concreto o quédate callado.

§9

Verificar que el deliverable que el sub-agent dice haber hecho existe

SubagentStop

Cuándo — los sub-agents dicen "listo, creé el archivo X" pero X no existe. El hook parsea el campo DELIVERABLE: del brief, revisa el path, y marca discrepancias.

▸ 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

Trampa — extraer paths de prosa es frágil; el script solo captura deliverables que nombran un path con extensión reconocida. Para checks más ricos, empareja DELIVERABLE con DONE_WHEN: y ejecuta el comando en su lugar.

§10

Snapshot de los últimos 12 turnos antes de la sumarización

PreCompact

Cuándo — sesiones largas son sumarizadas y el sumarizador descarta wording verbatim, paths de archivo, identificadores exactos. El hook guarda los turnos en bruto en disco para que el próximo session-start los re-inyecte.

▸ 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 compañero 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

Trampa — solo PreCompact puede escribir antes de que el sumarizador trunque el contexto, y solo SessionStart con source: "compact" puede inyectar después. Ningún otro hook puentea ese hueco. Ambas mitades son requeridas.