I-1, I-2, M-3, M-4, M-5 (переоценены в Important), M-8; спека и design.md синхронизированы (M-7). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
22 KiB
Spec Delta: hook-guards
Purpose
Блокирующие хуки обвязки Claude Code: guard-main не даёт изменить main, master и detached HEAD в обход PR, require-plan
не даёт править код без плана или маркера .bounded. Хуки решают по цели записи и проверяются регрессионным корпусом
реальных команд.
ADDED Requirements
Requirement: Решение по цели записи, а не по cwd
guard-main SHALL блокировать вызов только тогда, когда хотя бы одна цель записи лежит в git-репозитории, текущая ветка которого защищена. cwd сессии MUST использоваться только как база для относительных путей и как начальный каталог разбора команды. Вызов без целей записи MUST пропускаться при любом cwd.
Scenario: Команда только чтения при cwd на main
- WHEN cwd — репозиторий на main, команда
grep -rn "npm i\|npm install" docs 2>/dev/null | head - THEN guard-main завершается с кодом 0
Scenario: Редирект в /dev/null и дублирование дескриптора
- WHEN cwd — репозиторий на main, команда
git fetch origin --prune 2>&1 | tail -3илиls x 2>/dev/null - THEN guard-main завершается с кодом 0
Scenario: Edit файла вне любого репозитория
- WHEN cwd — репозиторий на main, Write в файл
~/.claude/projects/<slug>/memory/x.md - THEN guard-main завершается с кодом 0
Scenario: Edit файла в worktree на ветке при cwd на main
- WHEN cwd — основной checkout на main, Edit файла внутри worktree на ветке
feat/6-x - THEN guard-main завершается с кодом 0
Scenario: Edit файла в репозитории на main
- WHEN Edit файла внутри репозитория, текущая ветка которого main, при любом cwd
- THEN guard-main завершается с кодом 2
Scenario: Запись через симлинк, указывающий в main
- WHEN Write в симлинк вне репозитория, цель которого — ещё не созданный файл в репозитории на main
- THEN guard-main завершается с кодом 2
Requirement: Защищённые состояния ветки
Защищёнными SHALL считаться ветки main и master и состояние detached HEAD. Любая другая ветка MUST считаться
рабочей.
Scenario: Запись в репозитории на master
- WHEN Write в файл репозитория на
master - THEN guard-main завершается с кодом 2
Scenario: Запись при detached HEAD
- WHEN Write в файл репозитория в состоянии detached HEAD
- THEN guard-main завершается с кодом 2
Requirement: Редирект как файловая цель
guard-main SHALL считать файловой целью файл после операторов перенаправления вывода (>, >>, >|, &>, N>,
N>>). /dev/null, /dev/stdout, /dev/stderr, /dev/tty и дублирование дескриптора N>&M MUST NOT считаться
целями.
Scenario: Запись редиректом в main
- WHEN cwd — репозиторий на main, команда
echo x > notes.md - THEN guard-main завершается с кодом 2
Scenario: stderr в файл в main
- WHEN cwd — репозиторий на main, команда
make 2>build.log - THEN guard-main завершается с кодом 2
Requirement: Команды записи как файловые цели
guard-main SHALL считать файловыми целями пути-аргументы известных команд записи: tee, touch, mkdir, rm,
rmdir, truncate, chmod, unlink, все пути mv, назначение cp/install/ln/rsync, файлы sed -i и
perl -i, dd of=, каталог tar -x/unzip/patch, текущий каталог для npm/yarn/pnpm
install|i|ci|add|init. Аргументы прочих команд MUST NOT считаться целями.
Scenario: sed -i по файлу в main
- WHEN команда
sed -i 's/a/b/' <репозиторий на main>/docs/HARNESS.md - THEN guard-main завершается с кодом 2
Scenario: BSD sed -i '' по файлу памяти
- WHEN cwd — репозиторий на main, команда
sed -i '' 's/a/b/' ~/.claude/projects/<slug>/memory/x.md - THEN guard-main завершается с кодом 0
Scenario: Запись в контейнере не считается локальной
- WHEN cwd — репозиторий на main, команда
docker compose exec -T db sh -c 'psql -c "select 1" > /tmp/x' - THEN guard-main завершается с кодом 0
Scenario: Установка пакетов Python
- WHEN cwd — репозиторий на main, команда
pip install requests - THEN guard-main завершается с кодом 0
Scenario: Установка пакетов Node в main
- WHEN cwd — репозиторий на main, команда
npm install - THEN guard-main завершается с кодом 2
Requirement: Каталог внутри команды
guard-main SHALL учитывать cd, pushd и popd внутри команды: они меняют каталог для последующих команд списка,
соединённых ;, &&, ||. Изменение каталога MUST NOT распространяться через | и за пределы ( … ). Команды
sh -c, bash -c и eval SHALL разбираться рекурсивно, когда они стоят головой команды.
Scenario: cd в worktree и запись
- WHEN cwd — основной checkout на main, команда
cd <worktree на ветке> && echo x > f - THEN guard-main завершается с кодом 0
Scenario: cd в main и запись
- WHEN cwd — worktree на ветке, команда
cd <репозиторий на main> && touch f - THEN guard-main завершается с кодом 2
Scenario: Каталог для worktree в не-git workspace
- WHEN cwd — каталог workspace вне git, команда
mkdir -p .wt/6-x - THEN guard-main завершается с кодом 0
Scenario: Запись внутри bash -c
- WHEN cwd — репозиторий на main, команда
bash -c 'echo x > f' - THEN guard-main завершается с кодом 2
Requirement: Неразрешимая файловая цель пропускается
Если путь файловой цели нельзя определить без выполнения команды (переменная кроме HOME, подстановка команды,
cd -), guard-main SHALL пропустить такую цель. ~, $HOME и ${HOME} MUST раскрываться.
Scenario: Цель в переменной
- WHEN cwd — репозиторий на main, команда
echo x > "$OUT" - THEN guard-main завершается с кодом 0
Scenario: Цель через HOME
- WHEN команда
echo x > $HOME/<путь в репозитории на main>/f - THEN guard-main завершается с кодом 2
Requirement: Git-мутации на защищённой ветке
guard-main SHALL блокировать git-мутации — commit, merge, rebase, cherry-pick, revert, reset, am,
apply (кроме --check/--stat), add, rm, mv, restore, stash (кроме list/show), checkout -- <пути> — в репозитории на
защищённой ветке. Репозиторий MUST определяться по git -C <dir>, иначе по текущему каталогу разбора. Прочие
подкоманды, включая worktree, switch, checkout -b, branch, fetch, pull, MUST NOT считаться мутациями.
Scenario: Коммит в main
- WHEN cwd — репозиторий на main, команда
git add -A && git commit -m x - THEN guard-main завершается с кодом 2
Scenario: Коммит в worktree через -C при cwd на main
- WHEN cwd — основной checkout на main, команда
git -C <worktree на ветке> commit -m x - THEN guard-main завершается с кодом 0
Scenario: Создание worktree через -C
- WHEN cwd — workspace вне git, команда
git -C <репозиторий на main> worktree add ../.wt/x -b feat/x - THEN guard-main завершается с кодом 0
Scenario: Обновление main после merge
- WHEN cwd — репозиторий на main, команда
git pull --ff-only - THEN guard-main завершается с кодом 0
Scenario: Список stash на main
- WHEN cwd — репозиторий на main, команда
git stash list - THEN guard-main завершается с кодом 0
Requirement: Смена ветки внутри команды
git switch -c|-C <ветка>, git switch <ветка>, git checkout -b|-B <ветка> и git checkout main|master SHALL
менять ветку репозитория для последующих целей той же команды. Если новую ветку нельзя определить (-, переменная),
последующие git-мутации в этом репозитории MUST блокироваться, а файловые цели — пропускаться.
Scenario: Архивная ветка и коммит в одной команде
- WHEN cwd — репозиторий на main, команда
git switch -q -c chore/archive-x && git add openspec && git commit -m a - THEN guard-main завершается с кодом 0
Scenario: Смена ветки и запись
- WHEN cwd — репозиторий на main, команда
git switch -q -c feat/16 && touch .bounded - THEN guard-main завершается с кодом 0
Scenario: Ветка в переменной и коммит
- WHEN cwd — репозиторий на main, команда
git switch "$B" && git commit -m x - THEN guard-main завершается с кодом 2
Scenario: checkout main и merge из worktree
- WHEN cwd — worktree на ветке, команда
git checkout main && git pull && git merge --no-ff feat/x && git push - THEN guard-main завершается с кодом 2
Scenario: Переключение на предыдущую ветку
- WHEN cwd — worktree на ветке, команда
git switch - && git merge x - THEN guard-main завершается с кодом 2
Requirement: Префиксы команды
Голова команды SHALL определяться после присваиваний VAR=x и префиксов sudo, env, nice, timeout, ionice,
stdbuf, xargs, nohup, command, exec, time, включая значения их опций (nice -n 5, sudo -u u,
timeout -s KILL 5). env -C <каталог> MUST менять каталог только для этой команды.
Scenario: timeout перед push в main
- WHEN cwd — worktree на ветке, команда
timeout 60 git push origin main - THEN guard-main завершается с кодом 2
Scenario: nice -n перед коммитом в main
- WHEN cwd — репозиторий на main, команда
nice -n 5 git commit -m x - THEN guard-main завершается с кодом 2
Requirement: Команды над самой ссылкой
Для rm, rmdir, unlink, ln и mv местом записи SHALL считаться сама ссылка (каталог, где она лежит), а не её
цель; для остальных целей ссылка MUST раскрываться.
Scenario: Удаление ссылки в workspace на main-репозиторий
- WHEN cwd — каталог workspace вне git, команда
rm <workspace>/link, гдеlinkуказывает на репозиторий на main - THEN guard-main завершается с кодом 0
Scenario: Удаление ссылки внутри main
- WHEN cwd — репозиторий на main, команда
rm LINK.md, гдеLINK.md— ссылка наружу - THEN guard-main завершается с кодом 2
Requirement: Git-мутация с неопределённым репозиторием блокируется
Если для git-мутации нельзя определить репозиторий (-C с переменной, --git-dir, --work-tree, неразрешимый
текущий каталог), guard-main SHALL заблокировать вызов и в сообщении предложить git -C <абсолютный путь>.
Scenario: Репозиторий в переменной
- WHEN команда
git -C "$REPO" commit -m x - THEN guard-main завершается с кодом 2, сообщение содержит
git -C
Requirement: push проверяется по целевой ветке
git push SHALL считаться мутацией защищённой ветки, только если он обновляет её на удалённом репозитории: push без
refspec с текущей защищённой ветки, refspec HEAD, main, master или <что угодно>:main|master, флаги --all и
--mirror. Push и удаление других веток MUST пропускаться при любом cwd.
Scenario: push с main
- WHEN cwd — репозиторий на main, команда
git push - THEN guard-main завершается с кодом 2
Scenario: Удаление ветки фичи из main-checkout
- WHEN cwd — репозиторий на main, команда
git push origin --delete feat/6-x - THEN guard-main завершается с кодом 0
Scenario: push ветки фичи из main-checkout
- WHEN cwd — репозиторий на main, команда
git push origin feat/6-x - THEN guard-main завершается с кодом 0
Scenario: push в main из worktree
- WHEN cwd — worktree на ветке, команда
git push origin HEAD:main - THEN guard-main завершается с кодом 2
Requirement: Текущая ветка и теги в push
Refspec-подстановка текущей ветки ($(git branch --show-current), $(git rev-parse --abbrev-ref HEAD),
$(git symbolic-ref --short HEAD)) SHALL считаться push текущей ветки. git push --tags без refspec MUST NOT
считаться push ветки.
Scenario: push текущей ветки через подстановку
- WHEN cwd — worktree на ветке, команда
git push -u origin "$(git branch --show-current)" - THEN guard-main завершается с кодом 0
Scenario: push тегов с main
- WHEN cwd — репозиторий на main, команда
git push --tags - THEN guard-main завершается с кодом 0
Requirement: Без исключения для OpenSpec
guard-main SHALL применять одни и те же правила к изменениям в openspec/, включая openspec archive: архив OpenSpec
делается отдельным PR (решение владельца 11.10.2026).
Scenario: Коммит только openspec/ в main
- WHEN в репозитории на main в индексе только файлы
openspec/, командаgit commit -m archive - THEN guard-main завершается с кодом 2
Scenario: openspec archive в main
- WHEN cwd — репозиторий на main, команда
openspec archive x - THEN guard-main завершается с кодом 2
Requirement: Сбой разбора не блокирует
Любой сбой — невалидный JSON на входе, отсутствие python3, внутренняя ошибка разбора — SHALL приводить к пропуску
вызова (код 0) обоими хуками.
Scenario: Невалидный вход
- WHEN на stdin хука подан текст
not json - THEN хук завершается с кодом 0
Scenario: Нет python3
- WHEN
python3отсутствует в PATH - THEN хук завершается с кодом 0
Requirement: Сообщение блокировки
При блокировке хук SHALL писать в stderr одну строку, которая начинается с BLOCKED [guard-main]: или
BLOCKED [require-plan]: и называет цель (путь или git-команду), корень репозитория и действие для выхода.
Scenario: Сообщение guard-main
- WHEN guard-main блокирует Write в
docs/HARNESS.mdрепозитория на main - THEN stderr начинается с
BLOCKED [guard-main]:и содержитdocs/HARNESS.md, путь корня иworktree
Requirement: require-plan по репозиторию правимого файла
require-plan SHALL определять корень, ветку, .claude/code-paths, .bounded и каталог планов
docs/superpowers/plans/ по git-репозиторию правимого файла, а не по cwd. Файл вне репозитория MUST пропускаться.
Scenario: Файл в другом репозитории без плана
- WHEN cwd — репозиторий A с
.bounded, Edit файлаsrc/a.pyв репозитории B без плана и без.bounded - THEN require-plan завершается с кодом 2
Scenario: Файл в worktree с .bounded при cwd на main
- WHEN cwd — основной checkout, Edit файла
src/a.pyв worktree, в корне которого есть.bounded - THEN require-plan завершается с кодом 0
Scenario: Имя файла со скобками — литерал
- WHEN Edit файла
src/[slug].tsв репозитории без плана и без.bounded - THEN require-plan завершается с кодом 2
Scenario: code-paths из репозитория файла
- WHEN в репозитории файла
.claude/code-pathsсодержитhooks/, Edit файлаhooks/x.shбез плана - THEN require-plan завершается с кодом 2
Requirement: Правила require-plan, которые не меняются
require-plan SHALL защищать по умолчанию любой каталог src/ на любой глубине, а при наличии .claude/code-paths —
только перечисленные префиксы. Правка разрешается при .bounded в корне, при плане, где упомянута ветка, при
единственном плане в каталоге планов и при detached HEAD, если план есть.
Scenario: src на глубине без плана
- WHEN Edit файла
api/src/a.pyв репозитории без планов и без.bounded - THEN require-plan завершается с кодом 2
Scenario: План упоминает ветку
- WHEN в каталоге планов два плана, один из них содержит имя текущей ветки
- THEN require-plan завершается с кодом 0
Requirement: Регрессионный корпус хуков
Пакет SHALL содержать корпус вызовов хуков из реальных транскриптов и живых случаев; каждая запись MUST иметь
идентификатор, ожидаемый вердикт allow|block и код класса. Корпус MUST прогоняться через хуки в песочнице с раскладкой
репозиториев из корпуса: тестами пакета по источникам и doctor.sh по установленным копиям.
Scenario: Расхождение в тестах пакета
- WHEN хук из
hooks/выносит по записи корпуса вердикт, отличный от ожидаемого - THEN
bash tests/run.shпечатает FAIL с идентификатором и классом записи и завершается с ненулевым кодом
Scenario: Отчёт doctor
- WHEN запущен
doctor.shпри установленных хуках - THEN в разделе самотеста есть строка
корпус хуков: <совпало>/<всего>, расхождения перечислены с идентификаторами
Requirement: Выгрузка только настоящих блокировок
Экстрактор блокировок из транскриптов SHALL выгружать только результаты инструмента, которые являются ошибкой хука, и
MUST NOT выгружать обычный вывод команды, в котором встречается слово BLOCKED.
Scenario: cat исходника хука
- WHEN в транскрипте результат
cat hooks/require-plan.shсодержит строкуBLOCKED: editing - THEN экстрактор эту запись не выгружает