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

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

Знакомая сцена: длинная сессия, агент поправил тест и потянулся коммитить, не прогнав тесты. Запрещающее правило никуда не делось — оно всё ещё лежало в 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 не пропускает коммит без свежего прогона. Молча обходить его я не буду.

Как поступить:

  1. Всё-таки прогнать npx playwright test, потом коммит (hook пропустит).

  2. Ты сам отключишь/обойдёшь 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

Источник

Оставить комментарий