3 ловушки hooks в Claude Code: exit 1, таймаут и .env

3 ловушки hooks в Claude Code: exit 1, таймаут и .env

Я неделю не понимал, почему хук не блокирует правки .env. Оказалось - exit 1 в Claude Code не останавливает действие. Только exit 2 работает как стоп.

Поставил себе хук на PreToolUse против любых правок файлов вида .env, .env.local, .env.production. В JSON-конфиге всё аккуратно: matcher на Edit|Write|MultiEdit, shell-команда вытаскивает путь через jq, грепит по шаблону, на совпадении возвращает exit 2 с понятным сообщением. Claude должен был получать stderr и пасовать. Не пасовал. Раз в неделю в .env уезжали то лишний токен, то переменная с дублирующим именем, переписывающая рабочую. Каждый раз я думал, что просто забыл хук обновить. Не забыл. Хук падал на пустом входе, jq возвращал exit 1, Claude Code видел «не блок» и спокойно продолжал.

Дальше - что я узнал про exit-коды в hooks Claude Code, три капкана которые ломают всю защиту незаметно для тебя, и шаблон теста, который ловит такие баги до коммита. Никаких теорий жизненного цикла - только то, что реально стреляет на практике.

Чёрная магия exit-кодов: почему exit 1 не блокирует

Самая первая вещь, которую стоит зафиксировать в голове - в Claude Code exit-коды работают не как в обычной shell-логике. По POSIX-конвенции exit 0 это успех, любой ненулевой - ошибка. В Claude Code exit 1 ошибкой считается тоже, но эта ошибка не блокирует действие модели. Блок даёт только exit 2.

Вот разбивка по каждому варианту:

exit 0 → всё прошло, действие модели идёт дальше, stderr игнорируется exit 1 → хук упал с ошибкой, но Claude Code считает её НЕ блокирующей действие модели всё равно проходит, stderr попадает в системный лог exit 2 → жёсткий стоп, действие модели не выполняется stderr возвращается Клоду как причина блокировки, он реагирует

Если ты приходишь из мира bash и DevOps, exit 1 для тебя автоматически = «вызывающий процесс должен остановиться». Здесь не должен. Это первое, что я бы повесил себе на стену перед написанием первого хука.

Проверить это можно за минуту прямо в терминале. Берёшь JSON, который Claude Code прислал бы хуку, и подаёшь его на stdin своей команды:

echo '{"tool_input":{"file_path":".env"}}' | bash -c ' FILE=$(jq -r ".tool_input.file_path // empty") [ -z "$FILE" ] && exit 0 echo "$FILE" | grep -qE "(^|/)\.env(\.|$)" && { echo "BLOCKED .env edit" >&2 exit 2 } || exit 0 ' echo "exit code: $?"

Ожидаешь увидеть в stderr BLOCKED .env edit и exit code: 2. Если получил exit code: 0 - регекс не сработал. Если получил exit code: 1 - где-то падает jq или grep на неожиданном входе. Это нормальный режим разработки хуков: написал команду, прогнал в терминале с фейковым stdin, увидел корректный код, и только потом катишь в settings.json.

Ниже разберу три ловушки по очереди. Все три встречаются почти в каждом первом хуке, который пишут с нуля.

Молчаливый exit 1: хук падает, а Claude этого не замечает

Самая частая и самая болезненная. Хук падает на технической ошибке (нет файла, jq не распарсил пустой JSON, grep не нашёл нужный шаблон в шаблонной команде), shell возвращает exit 1, Claude Code считает это «хук просто не справился» и продолжает действие модели как ни в чём не бывало. Никакой обратной связи Клоду не идёт. Ты смотришь на лог, видишь «hook failed», но действие уже выполнено.

У меня это выглядело так. Хук-защитник .env был написан с прямым доступом к полю через jq -r '.tool_input.file_path'. Если Claude Code присылал событие, где tool_input пустой или поля file_path нет (а такое бывает на Read/Glob-операциях, если matcher настроен слишком широко), jq возвращал пустую строку или падал с ошибкой. exit code хука уходил в 1, Claude продолжал, файл редактировался. Я неделю думал, что хук работает.

Что лечит - две строки кода. Во-первых, проверка на null до основной логики. Во-вторых, явный || exit 0 в конце команды, чтобы любой неожиданный сценарий проваливался в безопасный exit 0, а не в нестабильный exit 1.

{ "hooks": { "PreToolUse": [ { "matcher": "Edit|Write|MultiEdit", "hooks": [ { "type": "command", "command": "FILE=$(jq -r '.tool_input.file_path // empty'); [ -z \"$FILE\" ] && exit 0; echo \"$FILE\" | grep -qE '(^|/)\\.env(\\.|$)' && { echo 'Запрещено редактировать .env напрямую. Скажи мне, какую переменную добавить, я открою файл сам.' >&2; exit 2; } || exit 0" } ] } ] } }

Разница в трёх местах. // empty в jq - возвращает пустую строку вместо ошибки, если поля нет. [ -z "$FILE" ] && exit 0 - явный безопасный выход на пустом входе. || exit 0 в конце - страховка от любого падения внутри логики. Это правило безопасной разработки хуков, и я бы поставил его в шаблон по умолчанию для всех PreToolUse-запретов.

Если интересно копнуть глубже в саму механику событий - у меня есть полный гайд по 31 событию хуков Claude Code с разбором 4 типов обработчиков (Command, HTTP, Prompt, Agent), 7 готовыми примерами под копипаст и тестом из 5 вопросов «когда правило идёт в hook».

60 секунд на хук: почему eslint вылетает на больших файлах

Стандартный таймаут для одного хука в Claude Code - 60 секунд. Кажется, что с запасом: что такое 60 секунд для shell-команды? У меня был хук на PostToolUse с матчером на Edit|Write|MultiEdit, который запускал npx eslint --fix "$FILE". На небольших файлах работало моментально. На больших файлах (1000-3000 строк типичной React-компоненты с кучей импортов) eslint считал свои правила по 8-12 секунд.

Сам по себе один файл проходил. Проблема всплыла, когда Claude писал серию правок подряд - правил три-четыре файла за минуту. Хуки начали накапливаться в очереди, ESLint крутился по 10 секунд на каждый, общая длина обработки переваливала за минуту. Claude Code молча убивал часть хуков по таймауту. Я заметил это, только когда в лог вышло hook timed out, а на проде через день обнаружил несколько файлов с длинным тире, которое должно было блокироваться предыдущим хуком №4.

Лечится одной строкой. Любую тяжёлую работу выносишь в фон через & без ожидания результата:

{ "hooks": { "PostToolUse": [ { "matcher": "Edit|Write|MultiEdit", "hooks": [ { "type": "command", "command": "FILE=$(jq -r '.tool_input.file_path // empty'); [ -z \"$FILE\" ] && exit 0; echo \"$FILE\" | grep -qE '\\.(ts|tsx|js|jsx)#39; && (npx eslint --fix \"$FILE\" 2>/dev/null &); exit 0" } ] } ] } }

Конкретно (npx eslint --fix "$FILE" 2>/dev/null &) запускает eslint в подоболочке в фоне, stderr выкидывается в /dev/null, чтобы не засорять лог. Хук завершается с exit 0 за миллисекунды. Eslint крутится своим темпом, и если упадёт - ничего не сломает.

Логика тут такая. PostToolUse + exit 2 нужен только для финальной проверки результата (типа «после редактирования файла убедись, что в нём нет длинного тире, иначе блок»). Тяжёлое форматирование - это не проверка, это правка постфактум. Для правок exit 2 не нужен, нужен фон.

Если хочешь жёсткую блокирующую проверку - оставляй её синхронной и держи в рамках 2-3 секунд. Всё что дольше - в фон. Это правило экономит дни отладки.

Невидимый .env: почему OPENAI_API_KEY не доезжает до хука

Этот баг я ловил полдня. Сделал хук на PostToolUse, который после изменений в .env.example должен был дёргать curl на сервис аудита с моим OPENAI_API_KEY. Ключ у меня лежит в проектном .env (а не в ~/.zshrc - я не люблю смешивать секреты разных проектов). Запускаю хук - получаю 401 от сервиса. Лезу в shell - переменной нет.

Объяснение простое и неочевидное. Хук запускается под shell от родительского процесса Claude Code. Этот shell наследует переменные окружения из твоего .zshrc или .bashrc, плюс то, что Claude Code пробрасывает явно. Проектный .env он не читает: это файл приложения, shell его игнорирует. Если твой хук зависит от чего-то, что лежит в .env - надо это явно сделать самому в shell-команде:

{ "hooks": { "PostToolUse": [ { "matcher": "Edit|Write|MultiEdit", "hooks": [ { "type": "command", "command": "FILE=$(jq -r '.tool_input.file_path // empty'); [ -z \"$FILE\" ] && exit 0; echo \"$FILE\" | grep -q '.env.example' && { cd \"$PWD\" && set -a && source .env 2>/dev/null && set +a && curl -sS -X POST https://my-audit.example.com -H \"Authorization: Bearer $OPENAI_API_KEY\" -d \"{\\\"file\\\":\\\"$FILE\\\"}\" >/dev/null 2>&1 & }; exit 0" } ] } ] } }

Ключевые куски. cd "$PWD" - переходим в текущую директорию проекта (если хук стартовал откуда-то ещё, типа из ~/.claude глобального уровня). set -a && source .env && set +a - читаем .env так, чтобы переменные стали доступны последующим командам в этой строке. Дальше curl уходит с правильным заголовком авторизации.

Особенно больно в монорепо, где у разных пакетов свои .env. В таких случаях я держу в начале каждого хука явный cd к корню репозитория через git:

ROOT=$(git rev-parse --show-toplevel 2>/dev/null) && cd "$ROOT" && source .env

Это даёт стабильное поведение независимо от того, откуда хук запустился. Один раз потратил час, разбираясь, почему в монорепо хуки apps/web тянут .env пакета apps/api. Ответ оказался банальным: я не делал явный cd, Claude Code запускал хук из текущей рабочей директории, а она была не та, что я ожидал.

Шесть прогонов в терминале вместо недели отладки

Все три ловушки выше - воспроизводятся на стенде до того, как ты добавишь хук в settings.json. Это та практика, которая отделяет «у меня вечно хуки чудят» от «у меня хуки железно работают».

Каждый хук в Claude Code это shell-команда. У неё есть стандартный stdin (JSON-объект от Claude Code), stdout (которое возвращается Клоду как контекст), stderr (которое возвращается Клоду как причина блокировки на exit 2), и exit code. Все четыре можно эмулировать с помощью одной строки в bash:

echo '<твой JSON-вход>' | bash -c '<команда хука>' echo "exit code: $?"

Шесть сценариев, которыми я всегда прогоняю каждый новый хук:

  1. Базовый позитив - вход, на котором хук должен сработать (например {"tool_input":{"file_path":".env"}} для защиты .env). Ожидаешь нужный stderr и нужный exit code.
  1. Базовый негатив - вход, на котором хук должен пропустить (например {"tool_input":{"file_path":"src/index.ts"}} для того же хука). Ожидаешь exit 0 и пустой stderr.
  1. Пустой tool_input - вход {"tool_input":{}}. Хук должен молча выйти с exit 0, не упасть с exit 1 на jq.
  1. Полностью пустой JSON - вход {}. То же самое: молчаливый exit 0.
  1. Битый JSON - вход not-a-json. Хук должен выйти с exit 0 (потому что мы добавили || exit 0 в конце), а не уронить очередь.
  1. Файл с пробелами в имени - например {"tool_input":{"file_path":"my docs/.env"}}. Хук должен корректно обработать пробелы (поэтому пути всегда в двойных кавычках).

Прогон занимает минут пять. Возвращает обратно недели отладки.

Скрипт-обёртку держу прямо рядом с .claude/settings.json:

# .claude/test-hook.sh HOOK_CMD="$1" shift for SCENARIO in "$@"; do echo "=== $SCENARIO ===" echo "$SCENARIO" | bash -c "$HOOK_CMD" echo "exit code: $?" echo "" done

Используется так:

./.claude/test-hook.sh "$(cat hook-1-cmd.sh)" \ '{"tool_input":{"file_path":".env"}}' \ '{"tool_input":{"file_path":"src/index.ts"}}' \ '{"tool_input":{}}' \ '{}' \ 'not-a-json'

На выходе видишь, что хук делает в каждом сценарии. Я в первый месяц работы с hooks сэкономил так дни. Сейчас просто не пишу хуки без этого скрипта.

Тест пяти вопросов: какие правила реально переехать в хук

Не каждое правило стоит превращать в хук. Я для себя свёл выбор к простому тесту из пяти вопросов. Если хотя бы три ответа «да» - правило идёт в хук. Меньше трёх - остаётся в текстовом контракте с моделью.

Тест:

  1. Это правило про действие, а не про контекст? («не запускай rm -rf» - действие. «у меня TypeScript-проект» - контекст.)
  2. Это правило про безопасность, деньги или данные? («не трогай .env», «не пушь ключи в публичный репозиторий».)
  3. Можно проверить через grep, regex или exit code? («после Edit .md проверь, что нет длинного тире».)
  4. Если правило нарушится, последствия будут болезненно отменять? («force push в main» - сложно откатить. «забыл точку в конце» - легко.)
  5. Я уже видел, как Claude забывал это правило хотя бы один раз?

Пример «не используй длинное тире, только дефис». Действие? Да. Безопасность? Нет. Проверяется grep? Да. Сложно отменить? Нет. Видел нарушение? Да. Три из пяти - в хук.

Контр-пример «у меня вайб-кодеры, не разработчики, пиши без сложных терминов». Действие? Скорее контекст. Безопасность? Нет. Проверяется регексом? Слишком сложно. Сложно отменить? Нет. Видел нарушение? Да. Один из пяти - остаётся в CLAUDE.md.

У меня через тест прошли 5 правил из 80 строк CLAUDE.md. Они переехали в хуки. Остальные 75% остались в текстовом файле. Это контекст проекта, тон, маршрутизация. Хуки решают действия.

Если правило остаётся в CLAUDE.md - у меня есть готовый шаблон CLAUDE.md и 6 правил под копипаст с разбором, какие куски реально читаются моделью, а какие висят балластом.

Что я бы сделал на твоём месте

Если у тебя сейчас не было ни одного хука - не нужно тащить себе сразу 7 готовых. Возьми один блокирующий и один форматирующий, доведи их до железной работы, потом докидывай по одному.

Моя последовательность для нового проекта:

  • Первый хук - защита .env через PreToolUse + exit 2 с null-проверкой и || exit 0 в конце. У меня этот один хук убрал почти все инциденты с .env за четыре месяца.
  • Второй хук - блокировка длинного тире и других «ИИ-маркеров» через PostToolUse + exit 2 с проверкой по UTF-8 байтам
\xe2\x80\x94'. Через 2-3 сессии Claude перестаёт ставить этот символ вообще.
  • Третий хук - освобождение порта 3000 перед npm run dev через PreToolUse + exit 0. Снимает класс ловушек, когда Claude думает, что проблема в коде, а на деле просто старый dev-сервер не закрылся.
  • Только потом - SessionStart с additionalContext, авто-коммит после тестов, HTTP-валидаторы. Это слой 2, до него ещё доехать надо.
  • И главное правило - всегда тест-сценарии до коммита. Шесть прогонов по пять минут спасают недели отладки.

    В Claude Code сейчас 31 событие жизненного цикла. Через год их будет больше, протокол усложнится, появятся новые типы (текущие 4 - Command, HTTP, Prompt, Agent - почти наверняка дополнят MCP-tool-хуками и Subagent-валидаторами). Но разница CLAUDE.md и хуков останется простой: контракт-просьба к модели и shell-команда от процесса. Кто это разделил рано, у того хуки железно работают с первой настройки.

    А вот честный вопрос. Если у тебя хук уже год лежит и якобы блокирует .env - ты проверял его в терминале хоть раз? Прогнал через шесть сценариев выше? Я готов поспорить, что половина читателей сейчас откроет settings.json и обнаружит у себя классический «exit 1 молча проходит». Пиши в комменты, что у тебя там лежит.

    && (npx eslint --fix \\\"$FILE\\\" 2\u003E/dev/null &); exit 0\"\n }\n ]\n }\n ]\n }\n}","lang":"json"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"\u003Cp\u003EКонкретно (npx eslint --fix \"$FILE\" 2>/dev/null &) запускает eslint в подоболочке в фоне, stderr выкидывается в /dev/null, чтобы не засорять лог. Хук завершается с exit 0 за миллисекунды. Eslint крутится своим темпом, и если упадёт - ничего не сломает.\u003C/p\u003E"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"\u003Cp\u003EЛогика тут такая. PostToolUse + exit 2 нужен только для финальной проверки результата (типа «после редактирования файла убедись, что в нём нет длинного тире, иначе блок»). Тяжёлое форматирование - это не проверка, это правка постфактум. Для правок exit 2 не нужен, нужен фон.\u003C/p\u003E"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"\u003Cp\u003EЕсли хочешь жёсткую блокирующую проверку - оставляй её синхронной и держи в рамках 2-3 секунд. Всё что дольше - в фон. Это правило экономит дни отладки.\u003C/p\u003E"}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"Невидимый .env: почему OPENAI_API_KEY не доезжает до хука"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"\u003Cp\u003EЭтот баг я ловил полдня. Сделал хук на PostToolUse, который после изменений в .env.example должен был дёргать curl на сервис аудита с моим OPENAI_API_KEY. Ключ у меня лежит в проектном .env (а не в ~/.zshrc - я не люблю смешивать секреты разных проектов). Запускаю хук - получаю 401 от сервиса. Лезу в shell - переменной нет.\u003C/p\u003E"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"\u003Cp\u003EОбъяснение простое и неочевидное. Хук запускается под shell от родительского процесса Claude Code. Этот shell наследует переменные окружения из твоего .zshrc или .bashrc, плюс то, что Claude Code пробрасывает явно. Проектный .env он не читает: это файл приложения, shell его игнорирует. Если твой хук зависит от чего-то, что лежит в .env - надо это явно сделать самому в shell-команде:\u003C/p\u003E"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"{\n \"hooks\": {\n \"PostToolUse\": [\n {\n \"matcher\": \"Edit|Write|MultiEdit\",\n \"hooks\": [\n {\n \"type\": \"command\",\n \"command\": \"FILE=$(jq -r '.tool_input.file_path // empty'); [ -z \\\"$FILE\\\" ] && exit 0; echo \\\"$FILE\\\" | grep -q '.env.example' && { cd \\\"$PWD\\\" && set -a && source .env 2\u003E/dev/null && set +a && curl -sS -X POST https://my-audit.example.com -H \\\"Authorization: Bearer $OPENAI_API_KEY\\\" -d \\\"{\\\\\\\"file\\\\\\\":\\\\\\\"$FILE\\\\\\\"}\\\" \u003E/dev/null 2\u003E&1 & }; exit 0\"\n }\n ]\n }\n ]\n }\n}","lang":"json"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"\u003Cp\u003EКлючевые куски. cd \"$PWD\" - переходим в текущую директорию проекта (если хук стартовал откуда-то ещё, типа из ~/.claude глобального уровня). set -a && source .env && set +a - читаем .env так, чтобы переменные стали доступны последующим командам в этой строке. Дальше curl уходит с правильным заголовком авторизации.\u003C/p\u003E"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"\u003Cp\u003EОсобенно больно в монорепо, где у разных пакетов свои .env. В таких случаях я держу в начале каждого хука явный cd к корню репозитория через git:\u003C/p\u003E"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"ROOT=$(git rev-parse --show-toplevel 2\u003E/dev/null) && cd \"$ROOT\" && source .env","lang":"bash"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"\u003Cp\u003EЭто даёт стабильное поведение независимо от того, откуда хук запустился. Один раз потратил час, разбираясь, почему в монорепо хуки apps/web тянут .env пакета apps/api. Ответ оказался банальным: я не делал явный cd, Claude Code запускал хук из текущей рабочей директории, а она была не та, что я ожидал.\u003C/p\u003E"}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"Шесть прогонов в терминале вместо недели отладки"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"\u003Cp\u003EВсе три ловушки выше - воспроизводятся на стенде до того, как ты добавишь хук в settings.json. Это та практика, которая отделяет «у меня вечно хуки чудят» от «у меня хуки железно работают».\u003C/p\u003E"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"\u003Cp\u003EКаждый хук в Claude Code это shell-команда. У неё есть стандартный stdin (JSON-объект от Claude Code), stdout (которое возвращается Клоду как контекст), stderr (которое возвращается Клоду как причина блокировки на exit 2), и exit code. Все четыре можно эмулировать с помощью одной строки в bash:\u003C/p\u003E"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"echo '\u003Cтвой JSON-вход\u003E' | bash -c '\u003Cкоманда хука\u003E'\necho \"exit code: $?\"","lang":"bash"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"\u003Cp\u003EШесть сценариев, которыми я всегда прогоняю каждый новый хук:\u003C/p\u003E"}},{"type":"list","cover":false,"hidden":false,"anchor":"","data":{"items":["\u003Cb\u003EБазовый позитив\u003C/b\u003E - вход, на котором хук должен сработать (например {\"tool_input\":{\"file_path\":\".env\"}} для защиты .env). Ожидаешь нужный stderr и нужный exit code."],"type":"OL"}},{"type":"list","cover":false,"hidden":false,"anchor":"","data":{"items":["\u003Cb\u003EБазовый негатив\u003C/b\u003E - вход, на котором хук должен пропустить (например {\"tool_input\":{\"file_path\":\"src/index.ts\"}} для того же хука). Ожидаешь exit 0 и пустой stderr."],"type":"OL"}},{"type":"list","cover":false,"hidden":false,"anchor":"","data":{"items":["\u003Cb\u003EПустой tool_input\u003C/b\u003E - вход {\"tool_input\":{}}. Хук должен молча выйти с exit 0, не упасть с exit 1 на jq."],"type":"OL"}},{"type":"list","cover":false,"hidden":false,"anchor":"","data":{"items":["\u003Cb\u003EПолностью пустой JSON\u003C/b\u003E - вход {}. То же самое: молчаливый exit 0."],"type":"OL"}},{"type":"list","cover":false,"hidden":false,"anchor":"","data":{"items":["\u003Cb\u003EБитый JSON\u003C/b\u003E - вход not-a-json. Хук должен выйти с exit 0 (потому что мы добавили || exit 0 в конце), а не уронить очередь."],"type":"OL"}},{"type":"list","cover":false,"hidden":false,"anchor":"","data":{"items":["\u003Cb\u003EФайл с пробелами в имени\u003C/b\u003E - например {\"tool_input\":{\"file_path\":\"my docs/.env\"}}. Хук должен корректно обработать пробелы (поэтому пути всегда в двойных кавычках)."],"type":"OL"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"\u003Cp\u003EПрогон занимает минут пять. Возвращает обратно недели отладки.\u003C/p\u003E"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"\u003Cp\u003EСкрипт-обёртку держу прямо рядом с .claude/settings.json:\u003C/p\u003E"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"# .claude/test-hook.sh\nHOOK_CMD=\"$1\"\nshift\nfor SCENARIO in \"$@\"; do\n echo \"=== $SCENARIO ===\"\n echo \"$SCENARIO\" | bash -c \"$HOOK_CMD\"\n echo \"exit code: $?\"\n echo \"\"\ndone","lang":"bash"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"\u003Cp\u003EИспользуется так:\u003C/p\u003E"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"./.claude/test-hook.sh \"$(cat hook-1-cmd.sh)\" \\\n '{\"tool_input\":{\"file_path\":\".env\"}}' \\\n '{\"tool_input\":{\"file_path\":\"src/index.ts\"}}' \\\n '{\"tool_input\":{}}' \\\n '{}' \\\n 'not-a-json'","lang":"bash"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"\u003Cp\u003EНа выходе видишь, что хук делает в каждом сценарии. Я в первый месяц работы с hooks сэкономил так дни. Сейчас просто не пишу хуки без этого скрипта.\u003C/p\u003E"}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"Тест пяти вопросов: какие правила реально переехать в хук"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"\u003Cp\u003EНе каждое правило стоит превращать в хук. Я для себя свёл выбор к простому тесту из пяти вопросов. Если хотя бы три ответа «да» - правило идёт в хук. Меньше трёх - остаётся в текстовом контракте с моделью.\u003C/p\u003E"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"\u003Cp\u003EТест:\u003C/p\u003E"}},{"type":"list","cover":false,"hidden":false,"anchor":"","data":{"items":["Это правило про \u003Cb\u003Eдействие\u003C/b\u003E, а не про контекст? («не запускай rm -rf» - действие. «у меня TypeScript-проект» - контекст.)","Это правило про \u003Cb\u003Eбезопасность, деньги или данные\u003C/b\u003E? («не трогай .env», «не пушь ключи в публичный репозиторий».)","Можно проверить через \u003Cb\u003Egrep, regex или exit code\u003C/b\u003E? («после Edit .md проверь, что нет длинного тире».)","Если правило нарушится, последствия будут \u003Cb\u003Eболезненно отменять\u003C/b\u003E? («force push в main» - сложно откатить. «забыл точку в конце» - легко.)","Я уже видел, как Claude \u003Cb\u003Eзабывал это правило хотя бы один раз\u003C/b\u003E?"],"type":"OL"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"\u003Cp\u003EПример «не используй длинное тире, только дефис». Действие? Да. Безопасность? Нет. Проверяется grep? Да. Сложно отменить? Нет. Видел нарушение? Да. Три из пяти - в хук.\u003C/p\u003E"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"\u003Cp\u003EКонтр-пример «у меня вайб-кодеры, не разработчики, пиши без сложных терминов». Действие? Скорее контекст. Безопасность? Нет. Проверяется регексом? Слишком сложно. Сложно отменить? Нет. Видел нарушение? Да. Один из пяти - остаётся в CLAUDE.md.\u003C/p\u003E"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"\u003Cp\u003EУ меня через тест прошли 5 правил из 80 строк CLAUDE.md. Они переехали в хуки. Остальные 75% остались в текстовом файле. Это контекст проекта, тон, маршрутизация. Хуки решают действия.\u003C/p\u003E"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"\u003Cp\u003EЕсли правило остаётся в CLAUDE.md - у меня есть \u003Ca href=\"https://api.vc.ru/v2.8/redirect?to=https%3A%2F%2Fsmyslokod.ru%2Fguides%2Fkak-nastroit-claude-md%3Futm_source%3Dvc%26utm_campaign%3Dhooks-v-claude-code-spoke%26utm_content%3Dkak-nastroit-claude-md&postId=2957002\" rel=\"nofollow noopener\" target=\"_blank\"\u003Eготовый шаблон CLAUDE.md и 6 правил\u003C/a\u003E под копипаст с разбором, какие куски реально читаются моделью, а какие висят балластом.\u003C/p\u003E"}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"Что я бы сделал на твоём месте"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"\u003Cp\u003EЕсли у тебя сейчас не было ни одного хука - не нужно тащить себе сразу 7 готовых. Возьми один блокирующий и один форматирующий, доведи их до железной работы, потом докидывай по одному.\u003C/p\u003E"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"\u003Cp\u003EМоя последовательность для нового проекта:\u003C/p\u003E"}},{"type":"list","cover":false,"hidden":false,"anchor":"","data":{"items":["\u003Cb\u003EПервый хук\u003C/b\u003E - защита .env через PreToolUse + exit 2 с null-проверкой и || exit 0 в конце. У меня этот один хук убрал почти все инциденты с .env за четыре месяца.","\u003Cb\u003EВторой хук\u003C/b\u003E - блокировка длинного тире и других «ИИ-маркеров» через PostToolUse + exit 2 с проверкой по UTF-8 байтам \xe2\x80\x94'. Через 2-3 сессии Claude перестаёт ставить этот символ вообще.
  • Третий хук - освобождение порта 3000 перед npm run dev через PreToolUse + exit 0. Снимает класс ловушек, когда Claude думает, что проблема в коде, а на деле просто старый dev-сервер не закрылся.
  • Только потом - SessionStart с additionalContext, авто-коммит после тестов, HTTP-валидаторы. Это слой 2, до него ещё доехать надо.
  • И главное правило - всегда тест-сценарии до коммита. Шесть прогонов по пять минут спасают недели отладки.

    В Claude Code сейчас 31 событие жизненного цикла. Через год их будет больше, протокол усложнится, появятся новые типы (текущие 4 - Command, HTTP, Prompt, Agent - почти наверняка дополнят MCP-tool-хуками и Subagent-валидаторами). Но разница CLAUDE.md и хуков останется простой: контракт-просьба к модели и shell-команда от процесса. Кто это разделил рано, у того хуки железно работают с первой настройки.

    А вот честный вопрос. Если у тебя хук уже год лежит и якобы блокирует .env - ты проверял его в терминале хоть раз? Прогнал через шесть сценариев выше? Я готов поспорить, что половина читателей сейчас откроет settings.json и обнаружит у себя классический «exit 1 молча проходит». Пиши в комменты, что у тебя там лежит.

    \\xe2\\x80\\x94'. Через 2-3 сессии Claude перестаёт ставить этот символ вообще.","\u003Cb\u003EТретий хук\u003C/b\u003E - освобождение порта 3000 перед npm run dev через PreToolUse + exit 0. Снимает класс ловушек, когда Claude думает, что проблема в коде, а на деле просто старый dev-сервер не закрылся.","\u003Cb\u003EТолько потом\u003C/b\u003E - SessionStart с additionalContext, авто-коммит после тестов, HTTP-валидаторы. Это слой 2, до него ещё доехать надо."],"type":"UL"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"\u003Cp\u003EИ главное правило - всегда тест-сценарии до коммита. Шесть прогонов по пять минут спасают недели отладки.\u003C/p\u003E"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"\u003Cp\u003EВ Claude Code сейчас 31 событие жизненного цикла. Через год их будет больше, протокол усложнится, появятся новые типы (текущие 4 - Command, HTTP, Prompt, Agent - почти наверняка дополнят MCP-tool-хуками и Subagent-валидаторами). Но разница CLAUDE.md и хуков останется простой: контракт-просьба к модели и shell-команда от процесса. Кто это разделил рано, у того хуки железно работают с первой настройки.\u003C/p\u003E"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"\u003Cp\u003EА вот честный вопрос. Если у тебя хук уже год лежит и якобы блокирует .env - ты проверял его в терминале хоть раз? Прогнал через шесть сценариев выше? Я готов поспорить, что половина читателей сейчас откроет settings.json и обнаружит у себя классический «exit 1 молча проходит». Пиши в комменты, что у тебя там лежит.\u003C/p\u003E"}}],"customUri":"lovushki-hukov-v-claude-code-exit-1-tajmaut-i-env","commentEditor":{"enabled":true,"who":null,"text":"","until":null,"reason":null,"type":"everybody"},"commentsSeenCount":null,"counters":{"comments":0,"favorites":0,"reposts":0,"views":429,"hits":52,"reads":null,"online":0,"timespent":null,"shares":0,"reactions":0,"total":481},"isPinned":false,"isNews":false,"ogTitle":null,"ogDescription":"Ловушки хуков в Claude Code, exit 1 не блокирует, таймаут хуков, проблемы с .env, защита файлов, тестирование хуков для надежности.","isAudioAvailable":false,"audioUrl":null,"isAudioAvailableToGenerate":false,"summaryContent":null,"isExistSummaryContent":false,"reactions":{"counters":[],"reactionId":0},"url":"https://vc.ru/ai/2957002-lovushki-hukov-v-claude-code-exit-1-tajmaut-i-env","repostData":null,"dateFavorite":0,"isFavorited":false,"warningFromEditorTitle":null,"warningFromEditor":null,"subscribedToTreads":false,"author":{"id":5977880,"name":"Артемий Миллер","nickname":"artemiy_miller","description":"Вайб кодер со стажем","uri":"/artemiy_miller","avatar":{"type":"image","data":{"uuid":"27738db1-a89b-58e7-86a2-10f831521576","width":1024,"height":1024,"size":89912,"type":"jpg","color":"d17355","hash":"","external_service":[],"base64preview":"/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAQDAwQDAwQEAwQFBAQFBgoHBgYGBg0JCggKDw0QEA8NDw4RExgUERIXEg4PFRwVFxkZGxsbEBQdHx0aHxgaGxr/2wBDAQQFBQYFBgwHBwwaEQ8RGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhr/wAARCAAKAAoDASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAABwUI/8QAJhAAAQMDAgUFAAAAAAAAAAAAAQIDBAURIQAGBwgUMUESEzJhgf/EABUBAQEAAAAAAAAAAAAAAAAAAAUG/8QAHhEAAQQBBQAAAAAAAAAAAAAAAQACAxFBBAUSMcH/2gAMAwEAAhEDEQA/AKm591zqnv6bI3BVpsSpNWVEaakKQGSFn1AoHjsM9hn70207iVvdVPilNNMhJZQQ6WSS5j5fvfQtx9iMR+YFmMww01HqPSda0hASiTddj7gGF3GDe+twx2W2Y7TbLaG20ICUpSkAJAGAB4Gh49I8ONSEeqml3OEtHKAEYs9YoL//2Q=="}},"cover":{"cover":{"type":"image","data":{"uuid":"9afe9dc5-52c0-52a0-967b-4ed40a461f97","width":3712,"height":1152,"size":267737,"type":"jpg","color":"d78668","hash":"","external_service":[],"base64preview":"/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAUDBAQEAwUEBAQFBQUGBwwIBwcHBw8LCwkMEQ8SEhEPERETFhwXExQaFRERGCEYGh0dHx8fExciJCIeJBweHx7/2wBDAQUFBQcGBw4ICA4eFBEUHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh7/wAARCAAKAAoDASEAAhEBAxEB/8QAFwAAAwEAAAAAAAAAAAAAAAAABAUGB//EACgQAAECBAQFBQAAAAAAAAAAAAECBAADBRESEyEiJCUxMmFBRFFx0f/EABQBAQAAAAAAAAAAAAAAAAAAAAX/xAAaEQACAwEBAAAAAAAAAAAAAAAAAgEDERIh/9oADAMBAAIRAxEAPwDIZCaNJbq2M0qcKxKxt7lJKQRb7t6eYBcVBiHEwBqyICyARIAvr8HpBa12zPuj7X0Ksc4SiKzVxl2qr4YBZHEL2jxrpDRvWavkS+avu0e4X+woAH//2Q=="}},"cover_y":0},"achievements":[],"collectibles":[],"lastModificationDate":1783031674,"isSubscribed":false,"isSubscribedToNewPosts":false,"isMuted":false,"isAvailableForMessenger":true,"badgeId":"1ee4281e-b189-6840-aca8-c2b27ffffb1a","isDonationsEnabled":false,"isPlusGiftEnabled":true,"isUnverifiedBlogForCompanyWithoutPro":true,"isRemovedByUserRequest":false,"isFrozen":false,"isDisabledAd":false,"isPlus":true,"isVerified":false,"isPro":false,"yandexMetricaId":null,"badge":"plus","isOnline":false,"tgChannelShortname":null,"category":{"discoveryType":"blogs"},"counters":{"subscribers":25},"isUnsubscribable":true,"type":1,"subtype":"personal_blog"},"subsite":{"id":332941,"name":"AI","description":"Нейросети, искуственный интеллект, машинное обучение","uri":"/ai","avatar":{"type":"image","data":{"uuid":"47d7652c-7ff3-5ad3-b72c-3d0aa7d14f06","width":1200,"height":1200,"size":311374,"type":"png","color":"8dd2f1","hash":"2070ecd4e4745850","external_service":[]}},"cover":{"type":"image","data":{"uuid":"d830f642-8293-f95c-8c0a-cf31c79fd3aa","width":1920,"height":384,"size":110830,"type":"gif","color":"3b3846","hash":"","external_service":[],"duration":0}},"lastModificationDate":1602860409,"isSubscribed":false,"isSubscribedToNewPosts":false,"isMuted":false,"isAvailableForMessenger":false,"isDisabledAd":false,"nickname":"ai","isUnsubscribable":true,"badge":null,"badgeId":null,"isDonationsEnabled":false,"isOnline":false,"isPlus":false,"isUnverifiedBlogForCompanyWithoutPro":false,"isVerified":false,"isRemovedByUserRequest":false,"isFrozen":false,"isPro":false,"type":2,"subtype":"community"},"donations":{"amount":0,"isDonated":false},"isBlur":false,"keywords":[],"media":{"type":"image","data":{"uuid":"f5eebfa8-322e-5911-aa41-2e4787f815e5","width":1920,"height":1080,"size":304661,"type":"png","color":"1e1815","hash":"","external_service":[],"base64preview":"/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAAKAAoDAREAAhEBAxEB/8QAFwAAAwEAAAAAAAAAAAAAAAAABQYHCP/EACIQAAEDBAEFAQAAAAAAAAAAAAECAwQABQYRMRMhMkGhsf/EABUBAQEAAAAAAAAAAAAAAAAAAAUD/8QAHhEAAgEEAwEAAAAAAAAAAAAAAQIAAxESMiFBcdH/2gAMAwEAAhEDEQA/AMG3HH8AhsOOxc3mTFJGkoRbUpKj65d442fhqCuWNsSJd6KoLhwfL/IWg2a0OQo7jmRMNrU0hSkFhwlJIGxvVGu7BjxF6dJCgOXUndLQGNUcnoN9z4D8o5tjGU1E/9k="}},"customCover":null,"robotsTag":"noindex","categories":[10],"isAnonymized":true}}; \xe2\x80\x94'. Через 2-3 сессии Claude перестаёт ставить этот символ вообще.
  • Третий хук - освобождение порта 3000 перед npm run dev через PreToolUse + exit 0. Снимает класс ловушек, когда Claude думает, что проблема в коде, а на деле просто старый dev-сервер не закрылся.
  • Только потом - SessionStart с additionalContext, авто-коммит после тестов, HTTP-валидаторы. Это слой 2, до него ещё доехать надо.
  • И главное правило - всегда тест-сценарии до коммита. Шесть прогонов по пять минут спасают недели отладки.

    В Claude Code сейчас 31 событие жизненного цикла. Через год их будет больше, протокол усложнится, появятся новые типы (текущие 4 - Command, HTTP, Prompt, Agent - почти наверняка дополнят MCP-tool-хуками и Subagent-валидаторами). Но разница CLAUDE.md и хуков останется простой: контракт-просьба к модели и shell-команда от процесса. Кто это разделил рано, у того хуки железно работают с первой настройки.

    А вот честный вопрос. Если у тебя хук уже год лежит и якобы блокирует .env - ты проверял его в терминале хоть раз? Прогнал через шесть сценариев выше? Я готов поспорить, что половина читателей сейчас откроет settings.json и обнаружит у себя классический «exit 1 молча проходит». Пиши в комменты, что у тебя там лежит.