andrey.epifantsev a2170232d9 docs(plan): план hooks-write-target — 5 задач, код из проверенного прототипа (#6)
Сценарий спеки: запись через симлинк, указывающий в main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 01:52:36 +04:00

19 KiB
Raw Blame History

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 <ветка> 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

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: Без исключения для 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: 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 экстрактор эту запись не выгружает