andrey.epifantsev 2f96a13861 fix(hooks): финальное ревью — checkout main, префиксы с опциями, ссылки, литеральные пути, push текущей ветки (#6)
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>
2026-10-16 02:24:07 +04:00

327 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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