# hook-guards Specification ## Purpose Блокирующие хуки обвязки Claude Code: guard-main не даёт изменить main, master и detached HEAD в обход PR, require-plan не даёт править код без плана или маркера `.bounded`. Хуки решают по цели записи и проверяются регрессионным корпусом реальных команд. ## 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//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: Worktree внутри репозитория - **WHEN** основной checkout на main, worktree на ветке `feat/25-w` лежит в его каталоге `.claude/worktrees/w`, Write файла внутри этого worktree или `echo x > a.md` с cwd в нём - **THEN** guard-main завершается с кодом 0: корень и ветка цели берутся из worktree, а не из объемлющего checkout - **AND** запись в файл основного checkout вне `.claude/worktrees/` из того же worktree завершается с кодом 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//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 && 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 `, иначе по текущему каталогу разбора. Прочие подкоманды, включая `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 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 /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: .bounded основного checkout не действует во вложенном worktree - **WHEN** в корне основного checkout есть `.bounded`, Edit файла `src/a.py` в worktree `.claude/worktrees/w` этого checkout без своего `.bounded` и без плана - **THEN** require-plan завершается с кодом 2 #### 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** экстрактор эту запись не выгружает