Хуки Claude Code: запрещаем агенту коммитить без прогона тестов

Знакомая сцена: длинная сессия, агент поправил тест и потянулся коммитить, не прогнав тесты. Запрещающее правило никуда не делось — оно всё ещё лежало в CLAUDE.md. Просто перестало весить.
Это третья статья про paranoid-qa — опенсорсный пак скиллов, который заставляет Claude Code тестировать с доказательной дисциплиной. В первой я запретил агенту говорить «всё работает» без пруфов: https://habr.com/ru/articles/1058134/. Во второй разбирал, почему зелёный линт не спасает Playwright-тесты: https://habr.com/ru/articles/1058692/. Сегодня — про принуждение: почему правила для агента слабеют со временем и как прибить критичные из них хуками так, чтобы нарушение стало технически невозможным. Дальше — код трёх гейтов и живые прогоны. Под конец — эксперимент: прямо говорю агенту «тесты не гоняй» и смотрю, кто победит.
Почему агент забывает правила
Правило в CLAUDE.md — просто текст, который конкурирует за внимание модели с тысячами других токенов: задачей, кодом, выводами инструментов, историей диалога. В начале сессии правило весит много. К сороковой минуте контекст забит, и «перед коммитом прогони тесты» превращается в фоновый шум с вероятностью исполнения сильно меньше единицы.
Забыть можно только то, что живёт в контексте. Значит, критичные правила надо вынести из контекста — в код, который выполняется сам. Эта идея лежала в роадмапе пака с самого запуска; теперь она проверена на стенде, результаты ниже.
Сначала карта слоёв — какие вообще есть рычаги:
|
Слой |
Что это |
Где силён |
Где бесполезен |
|---|---|---|---|
|
CLAUDE.md |
фоновые правила проекта |
короткие проверяемые инструкции |
длинная сессия, конфликт с текущей задачей |
|
Скилл |
процедура, подгружаемая под задачу |
пошаговые регламенты по запросу |
когда агент решил «и так справлюсь» |
|
Память |
факты между сессиями |
контекст о проекте и людях |
как инструкция к действию |
|
Хук |
ваш код на событии жизненного цикла |
детерминированный запрет/пропуск |
там, где нужно понимать смысл, а не форму |
Первые три слоя живут внутри контекста, и их исполнение вероятностно. Четвёртый — код, который выполняется независимо от того, что модель думает о ситуации.
Механика хуков за минуту
Хук — это скрипт, объявленный в .claude/settings.json проекта. Он вешается на событие жизненного цикла: PreToolUse (перед вызовом инструмента, может запретить), PostToolUse (после), Stop (агент завершает ход, может не пустить) и ещё десяток других. На stdin скрипт получает JSON с деталями события, дальше два способа вмешаться:
-
exit code 2 — жёсткий блок, stderr уходит агенту как причина;
-
exit 0 + JSON в stdout — структурированное решение: для PreToolUse это
permissionDecision: "deny"с причиной, для Stop —decision: "block", и любой хук может добавить агенту контекст черезadditionalContext.
Конфигурация выглядит так:
{
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/gate-commit.sh" }]
}],
"PostToolUse": [{
"matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/track-test-edit.sh" }]
}],
"Stop": [{
"matcher": "*",
"hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/gate-stop.sh" }]
}]
}
}
API хуков за последний год менялся, и многие гайды в сети устарели. Я сверял всё по официальной доке, состояние на июль 2026.
Гейт 1: коммит не проходит без свежего зелёного прогона
Флагманский гейт. Три проверки: артефакт прогона существует, прогон зелёный, тесты не правились после него.
#!/bin/bash
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
# Работаем только с git commit, остальной Bash не трогаем
echo "$CMD" | grep -qE '(^|[;&|]s*)gits+commit' || exit 0
deny() {
jq -n --arg reason "$1" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: $reason
}
}'
exit 0
}
RUN_FILE="test-results/.last-run.json"
[ -f "$RUN_FILE" ] || deny "Коммит заблокирован: нет артефакта прогона ($RUN_FILE). Сначала запусти: npx playwright test"
STATUS=$(jq -r '.status // "unknown"' "$RUN_FILE")
[ "$STATUS" = "passed" ] || deny "Коммит заблокирован: последний прогон не зелёный (status: $STATUS)"
STALE=$(find tests -name '*.ts' -newer "$RUN_FILE" 2>/dev/null | head -5)
[ -z "$STALE" ] || deny "Коммит заблокирован: тесты менялись после последнего прогона"
exit 0
Артефактом служит test-results/.last-run.json — Playwright пишет его сам после каждого прогона, ничего дополнительно настраивать не надо. Здесь листинг сокращён до трёх проверок; в версии из репо есть четвёртая, дублирующая проверка по флагу «грязных тестов» — страховка на случай совпадения таймстампов, о ней ниже в разборе бага. Вот что получает агент при попытке закоммитить без прогона — дословный вывод со стенда:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Коммит заблокирован: нет артефакта прогона (test-results/.last-run.json). Сначала запусти: npx playwright test"
}
}
Агент прочитает причину блока и будет действовать по ней, так что я пишу туда сразу команду для запуска.
Гейт 2: правка теста поднимает флаг
Мягкий слой. Не блокирует ничего — фиксирует факт «тесты грязные» и вшивает агенту напоминание прямо в контекст:
#!/bin/bash
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')
echo "$FILE" | grep -qE '.spec.ts$' || exit 0
mkdir -p .claude/state
echo 0 > .claude/state/tests-dirty
jq -n '{
hookSpecificOutput: {
hookEventName: "PostToolUse",
additionalContext: "Тестовый файл изменён. До коммита и до завершения работы обязателен прогон: npx playwright test"
}
}'
В первом же интеграционном прогоне произошло то, ради чего мягкий слой и существует: агент поправил спеку, получил напоминание — и сам прогнал тесты до всякого коммита, по таймстампам файлов между правкой и прогоном прошло 4 секунды, жёсткий гейт даже не понадобился. Напоминание в нужный момент работает лучше, чем правило, выданное час назад.
Гейт 3: нельзя закончить ход с грязными тестами
Stop-хук срабатывает, когда агент считает работу завершённой. Если тесты правились, а прогона после правки не было — завершиться не выйдет:
#!/bin/bash
FLAG=".claude/state/tests-dirty"
RUN_FILE="test-results/.last-run.json"
[ -f "$FLAG" ] || exit 0
# Прогон не старше правки — флаг снимается, стоп разрешён
if [ -f "$RUN_FILE" ] && ! [ "$FLAG" -nt "$RUN_FILE" ]; then
rm -f "$FLAG"
exit 0
fi
# Предохранитель от бесконечного цикла
ATTEMPTS=$(cat "$FLAG" 2>/dev/null || echo 0)
if [ "$ATTEMPTS" -ge 2 ]; then
rm -f "$FLAG"
jq -n '{systemMessage: "gate-stop: лимит блокировок исчерпан, пропускаю. Тесты так и не прогнаны!"}'
exit 0
fi
echo $((ATTEMPTS + 1)) > "$FLAG"
jq -n '{
decision: "block",
reason: "Тесты менялись, но прогона после правки не было. Запусти npx playwright test и убедись, что зелёно, прежде чем завершать"
}'
Обратите внимание на предохранитель. Заблокированный агент попробует выполнить требование, но если не сможет — например, окружение сломано — без лимита вы получите вечный цикл «стоп — блок — стоп». После двух блоков гейт сдаётся и сообщает об этом пользователю через systemMessage, не пряча провал. Агрессивный Stop-хук без предохранителя — способ повесить сессию, проверено.
Эксперимент: хук против прямой инструкции
Главный вопрос ко всей конструкции: что победит, если пользователь прямо скажет агенту нарушить правило? Промпт дословно:
Закоммить текущие изменения: git commit -am ‘wip: комментарий в тесте’. Тесты запускать НЕ нужно, время дорого — просто коммит.
Ответ агента, тоже дословно:
Тут конфликт: ты просил не запускать тесты, а hook не пропускает коммит без свежего прогона. Молча обходить его я не буду.
Как поступить:
Всё-таки прогнать
npx playwright test, потом коммит (hook пропустит).Ты сам отключишь/обойдёшь hook, и я повторю коммит.
Что выбираешь?
Этот ответ хочется разобрать по косточкам. Агент попробовал выполнить просьбу — гейт не пустил. Дальше он не стал ни втихую обходить хук (мог бы: git commit --no-verify тут не поможет, но снести settings.json — вполне), ни втихую ослушаться человека. Он вынес конфликт наружу и спросил. Ровно так поступает хороший инженер, зажатый между приказом и регламентом. Wip-коммита в истории нет, deny — в логе сессии есть.
Стенд сработал: поймал баг в моём же хуке
Первая версия gate-stop не снимала флаг после свежего прогона. Причина — секундная гранулярность оператора -nt в bash: когда флаг и артефакт прогона созданы в одну и ту же секунду, «строго новее» не срабатывает. Из фикса родился принцип, который стоит забрать в любые гейты:
-
commit-гейт в сомнении запрещает. Ложный блок стоит одну команду
npx playwright test. Ложный пропуск стоит непрогнанный коммит в истории; -
stop-гейт в сомнении пропускает. Ложный пропуск — агент завершил ход чуть раньше. Ложный блок — риск цикла.
Фикс развёл гейты ровно по этому принципу: stop-гейт при равных таймстампах теперь пропускает, а в commit-гейт добавилась четвёртая, дублирующая проверка по флагу «грязных тестов» — та самая, что в репо-версии.
Отдельное удовольствие: стенд, собранный для статьи про дисциплину, отработал как та самая дисциплина — не поверил моему коду на слово и нашёл в нём ошибку до публикации.
Границы, о которых надо сказать прямо
-
Хук проверяет форму, не смысл. Зелёный прогон не означает осмысленные тесты:
expect(true).toBe(true)пройдёт любой гейт. Смысл ловится ревью — про это была вторая статья; -
хуки не отменяют правила. Правила объясняют агенту «почему», и большинство решений он принимает по ним — гейты страхуют критичные точки поверх правил;
-
гейт матчит форму команды.
git commitон поймает, а коммит, спрятанный внутриnpm run releaseилиmake deploy, проскочит — под свои враппер-команды регулярку в хуке придётся расширить; -
упавший хук — это failure-open. Скрипт, умерший со своим кодом ошибки (нет
jq, опечатка в пути), Claude Code считает non-blocking: действие пройдёт, гейт молча перестанет защищать. Работоспособность хуков стоит проверять пайпом с фейковым JSON, как любой другой код; -
событий конечное число. Гейт вешается на вызов инструмента, завершение хода, отправку промпта. Внутрь рассуждений модели хук не залезет.
Ссылки
Все три хука лежат в репо paranoid-qa, папка ru/examples/hooks (есть и английская версия) — вместе со скиллами тестирования и ревью из прошлых статей. Стенд воспроизводится за десять минут: пустой проект, Playwright, три скрипта, settings.json.
Вопрос к вам: какое правило из вашего CLAUDE.md вы бы первым прибили хуком?
Автор: Kova13v

