@ -0,0 +1,325 @@
# 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/<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** экстрактор эту запись не выгружает