andrey.epifantsev 00b496d6da feat: worktree в <repo>/.claude/worktrees/ через EnterWorktree, вход без подтверждения (#25, 0.5.1)
С Claude Code 2.1.206 вход в worktree вне .claude/worktrees/ спрашивает
подтверждение при любых allow-правилах. EnterWorktree с именем создаёт
worktree в <repo>/.claude/worktrees/<slug> на ветке worktree-<slug> от
origin/main и входит без вопроса; ветка переименовывается в
feat/<id>-<slug>.

- roles/dev.md: «Исполнение» — EnterWorktree с именем; п. 1 закрытия —
  archive-<id>; п. 4 — ExitWorktree (keep) снимает блокировку сессии,
  затем git worktree remove и branch -D.
- .claude/worktrees/ в .gitignore: adopt-project.sh, project-template,
  этот репозиторий; doctor.sh — WARN, если не игнорируется.
- Спека hook-guards: сценарии «worktree внутри репозитория» для
  guard-main и require-plan; тесты закрепляют уже верное поведение.
- HARNESS.md (шаг 4, п. 4, подтверждения), SKILL.md, CHANGELOG 0.5.1,
  CLAUDE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:20:35 +04:00

24 KiB
Raw Blame History

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