
Знакомая сцена: длинная сессия, агент поправил тест и потянулся коммитить, не прогнав тесты. Запрещающее правило никуда не делось — оно всё ещё лежало в 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*)git\s+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 вы бы первым прибили хуком?
