Урок 05 / 14

05. Hooks: детерминированный контроль над agent loop

Hooks — это git hooks для Claude Code: точки в lifecycle, в которых harness гарантированно исполнит ваш скрипт. В Claude Code v2.1.89 их 28+ событий, exit code 2 блокирует действие модели, JSON-ответ {"action":"block"} делает то же.

Hooks — это git hooks, но для Claude Code. Точки в lifecycle, в которые harness исполняет ваш скрипт. Если skill — это рекомендация модели, hook — это обязательный шаг harness’а, который гарантированно случится.


5.1. Зачем нужны hooks

📘 Из docs (hooks-guide): «Hooks provide deterministic control over Claude Code’s behavior, ensuring certain actions always happen rather than relying on the LLM to choose to run them».

Сравнение «то же самое через skill vs hook»:

ЗадачаЧерез skillЧерез hook
Запустить линтер после правкиСкилл «после Edit запусти pnpm lint», описание в CLAUDE.mdPostToolUse matcher: Edit|Write → bash-команда
Запретить редактировать secrets/«Не трогай папку secrets» в CLAUDE.mdPreToolUse matcher: Edit|Write → exit 2 при secrets/*
Засинхронить с CI после StopСкилл «после задачи запусти CI»Stop hook → bash «push branch + open PR»
Уведомить Slack о завершенииЭто вообще не скилл, это операцияStop или Notification hook → curl webhook

⚠️ Skills модель может проигнорировать. Hooks — нет. Это и плюс, и минус: hooks дают железную дисциплину, но и тормозят, если их повесить на каждый чих.


5.2. Где определяются hooks

Все источники объединяются. Если несколько hooks матчатся на одно событие — выполняются все в порядке регистрации.


5.3. Полный список событий

📘 Реальный список в Claude Code v2.1.89 значительно богаче, чем перечисляют в большинстве туториалов:

СобытиеКогда срабатывает
SessionStartПри старте сессии
SessionEndПри завершении сессии
UserPromptSubmitПользователь отправил сообщение
UserPromptExpansionРаскрытие шорткатов в промпте
InstructionsLoadedПосле загрузки CLAUDE.md / skills / agents
PreToolUseПеред вызовом любого tool
PostToolUseПосле успешного вызова tool
PostToolUseFailureПосле неуспешного вызова tool
PermissionRequestКогда tool требует подтверждения
PermissionDeniedКогда пользователь отказал
SubagentStartПеред запуском subagent
SubagentStopПосле завершения subagent
TaskCreatedСоздана задача (TaskCreate)
TaskCompletedЗадача выполнена
TeammateIdleTeammate в Agent Team простаивает
NotificationСистемное уведомление
StopКонец цикла «модель ↔ tools» (модель закончила отвечать)
StopFailureСессия завершилась с ошибкой
PreCompactПеред автокомпакцией
PostCompactПосле автокомпакции
ConfigChangeИзменилась конфигурация
CwdChangedСменилась рабочая директория
FileChangedФайл изменился (filesystem watch)
WorktreeCreateСоздан git worktree
WorktreeRemoveУдалён git worktree
ElicitationMCP elicitation запрос
ElicitationResultMCP elicitation ответ

5.4. Структура hooks.json

📘 Каноничный пример из docs:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit|MultiEdit",
        "hooks": [
          {
            "type": "command",
            "command": "bash ${CLAUDE_PLUGIN_ROOT}/scripts/scan-secrets.sh",
            "timeout": 30
          }
        ]
      },
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "prompt",
            "prompt": "Evaluate if this bash command is safe for production. Check destructive ops, missing safeguards, security issues.",
            "timeout": 20
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "bash ${CLAUDE_PLUGIN_ROOT}/scripts/run-linter.sh"
          }
        ]
      }
    ],
    "Stop": [
      {
        "matcher": ".*",
        "hooks": [
          {
            "type": "command",
            "command": "bash ${CLAUDE_PLUGIN_ROOT}/scripts/notify-team.sh",
            "timeout": 30
          }
        ]
      }
    ]
  }
}

Поля:

  • matcher — regex по имени tool’а (для PreToolUse/PostToolUse) или паттерн по контексту. .* или * означает «всё».
  • typecommand (запуск shell-команды) или prompt (отдельный prompt модели как «второе мнение»).
  • command / prompt — что исполнять.
  • timeout — секунды; если hook не уложился, он падает.

5.5. Тип command: что получает скрипт и что должен вернуть

Stdin: JSON с информацией о событии:

{
  "session_id": "...",
  "transcript_path": "/tmp/claude-session-xxx/transcript.jsonl",
  "cwd": "/home/me/travel-agent",
  "tool_name": "Edit",
  "tool_input": {
    "file_path": "apps/api/src/routes/trips.ts",
    "old_string": "...",
    "new_string": "..."
  }
}

Stdout: обычный вывод. Для PostToolUse — может быть фидбэк, который добавится в контекст модели.

Exit code:

КодЭффект
0OK, продолжать
2Блокировка действия. Stderr показывается модели как причина. Используется для PreToolUse, чтобы запретить вызов tool.
другоеHook упал, harness сообщит ошибку, но действие продолжится

JSON-протокол (для тонкого контроля):

{
  "hookSpecificOutput": {
    "permissionDecision": "deny",
    "permissionDecisionReason": "File matches deny rule for secrets/*"
  }
}

Возможные permissionDecision: allow, deny, ask, defer.

⚠️ PreToolUse hook с deny блокирует действие даже в bypassPermissions mode. Это не «можно обойти, если очень хочется» — это финальный гейт.


5.6. Тип prompt: hook как отдельный запрос к модели

📘 Пример:

{
  "type": "prompt",
  "prompt": "Analyze edit result for potential issues: syntax errors, security vulnerabilities, breaking changes. Provide feedback.",
  "timeout": 20
}

Что происходит: harness делает дополнительный запрос к модели с этим prompt’ом и tool result. Ответ возвращается в основную сессию как «inspector says …».

💡 Это удобно для AI-second-opinion. Минус: каждый такой hook — это дополнительные токены.


5.7. Конкретные примеры для Travel Agent

5.7.1. Auto-format после правки кода

🔧 .claude/settings.json:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "bash .claude/hooks/format.sh"
          }
        ]
      }
    ]
  }
}

🔧 .claude/hooks/format.sh:

#!/bin/bash
input=$(cat)
file_path=$(echo "$input" | jq -r '.tool_input.file_path')

# Только TS/TSX/JS/JSON
if [[ "$file_path" =~ \.(ts|tsx|js|jsx|json|md)$ ]]; then
  pnpm prettier --write "$file_path" 2>&1 || true
  if [[ "$file_path" =~ \.(ts|tsx)$ ]]; then
    pnpm eslint --fix "$file_path" 2>&1 || true
  fi
fi

Эффект: после каждой правки кода Claude’ом запускается prettier/eslint. Модель не может «забыть» — гарантировано.

5.7.2. Запретить редактирование секретов

🔧 .claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write|MultiEdit",
        "hooks": [
          {
            "type": "command",
            "command": "bash .claude/hooks/no-secrets.sh"
          }
        ]
      }
    ]
  }
}

🔧 .claude/hooks/no-secrets.sh:

#!/bin/bash
input=$(cat)
file_path=$(echo "$input" | jq -r '.tool_input.file_path')

case "$file_path" in
  *.env*|*secrets/*|*credentials*|*.pem|*.key)
    echo "Edits to secret files are blocked by policy: $file_path" >&2
    exit 2
    ;;
esac
exit 0

Эффект: модель никогда не отредактирует .env, секреты, ключи. Даже если очень захочет.

5.7.3. Sanity-check Bash команд

🔧 .claude/settings.json:

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

🔧 .claude/hooks/bash-guard.sh:

#!/bin/bash
input=$(cat)
cmd=$(echo "$input" | jq -r '.tool_input.command')

# Запрет деструктивных команд
if echo "$cmd" | grep -qE '(rm -rf /|:(){ :|:& };:|dd if=/dev/zero|mkfs\.|drop database)'; then
  echo "Destructive command blocked: $cmd" >&2
  exit 2
fi

# Предупреждение про прод-окружение
if echo "$cmd" | grep -q 'NODE_ENV=production'; then
  echo "Warning: command uses production env" >&2
  # exit 0 — пропускаем, но в логах остаётся
fi

exit 0

5.7.4. Slack-уведомление в конце сессии

🔧 .claude/settings.json:

{
  "hooks": {
    "Stop": [
      {
        "matcher": ".*",
        "hooks": [
          {
            "type": "command",
            "command": "bash .claude/hooks/notify-stop.sh",
            "timeout": 10
          }
        ]
      }
    ]
  }
}

🔧 .claude/hooks/notify-stop.sh:

#!/bin/bash
[[ -z "$SLACK_WEBHOOK_URL" ]] && exit 0

input=$(cat)
session_id=$(echo "$input" | jq -r '.session_id')
cwd=$(echo "$input" | jq -r '.cwd')

curl -s -X POST -H 'Content-type: application/json' \
  --data "{\"text\":\"Claude session done in \`$cwd\` (id: $session_id)\"}" \
  "$SLACK_WEBHOOK_URL" > /dev/null

5.7.5. Авто-коммит чек-поинтов

🔧 Скрипт, который после каждых 3 успешных PostToolUse делает git add . && git commit -m "wip: claude checkpoint":

#!/bin/bash
COUNT_FILE="/tmp/claude-checkpoint-$(echo "$CLAUDE_SESSION_ID" | head -c 8)"
count=$(cat "$COUNT_FILE" 2>/dev/null || echo 0)
count=$((count + 1))
echo "$count" > "$COUNT_FILE"

if (( count % 3 == 0 )); then
  git add -A
  git commit -m "wip: claude checkpoint #$count" --no-verify > /dev/null 2>&1 || true
fi

💡 Полезно для долгих сессий — есть к чему откатиться.


5.8. Hooks внутри plugin

📘 Когда упаковываете hook в плагин — добавьте hooks/hooks.json в плагин:

travel-agent-plugin/
├── .claude-plugin/plugin.json
├── hooks/
│   ├── hooks.json              # маппинг событий
│   └── scripts/
│       ├── format.sh
│       └── no-secrets.sh
└── ...

В hooks.json внутри плагина используется ${CLAUDE_PLUGIN_ROOT} — это переменная, указывающая на корень установленного плагина:

{
  "PostToolUse": [
    {
      "matcher": "Edit|Write",
      "hooks": [
        {
          "type": "command",
          "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/format.sh"
        }
      ]
    }
  ]
}

См. главу 07-plugins.md для полной структуры плагина.


5.9. Hooks vs slash-команды vs MCP tools

МеханизмКогда вызываетсяКто вызывает
HookНа lifecycle-событииHarness автоматически
Slash-командаПользователем /cmdПользователь вручную
SkillПо match’у descriptionМодель решает
Tool (MCP/built-in)По решению моделиМодель в agent loop

Hooks — единственный из этих механизмов, который гарантированно срабатывает без участия модели.


5.10. Как отлаживать hooks

# Включить debug-логи hooks
export CLAUDE_HOOKS_DEBUG=1
claude

# Логи hooks
tail -f ~/.claude/logs/hooks.log

💡 В bash-скрипте hook’а полезно дублировать stdin в файл для разбора:

#!/bin/bash
input=$(cat)
echo "$input" >> /tmp/claude-hook-input.jsonl
# ...

5.11. Антипаттерны

Hook на каждом Read. Вы повесили хук на PostToolUse: .* и теперь после каждого чтения файла дёргается shell. Тормоза.

Hook без timeout. Ваш скрипт зависает — сессия зависает.

exit 2 без stderr. Hook блокирует действие, но модель не понимает почему. Всегда пишите причину в stderr.

Долгие операции в hook. Hook на Stop, который запускает деплой на 5 минут — это анти-UX. Запускайте в фоне (&) или через очередь.

Hook, ломающий permissions. PreToolUse с permissionDecision: allow — обход всех проверок. Опасно.

Дублирование skill’ов hook’ами. Если процедура многошаговая и зависит от ситуации — это skill. Hook — для атомарного «всегда так».


Дальше → 06. MCP-серверы: stdio/SSE/HTTP, scope