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>
327 lines
22 KiB
Markdown
327 lines
22 KiB
Markdown
# 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** экстрактор эту запись не выгружает
|