Хуки 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 вы бы первым прибили хуком?
Источник: habr.com
Похожие записи
Оцените материал:
Присоединяйтесь и подпишитесь на рассылку самых свежих новостей по Email
Получайте свежие новости и идеи на почту. Без спама — только самое интересное.
Нажимая «Подписаться», вы соглашаетесь с политикой конфиденциальности.
