From 079d0a44bc644c622e33022032335b0fc73aab22 Mon Sep 17 00:00:00 2001 From: "andrey.epifantsev" Date: Thu, 15 Oct 2026 23:57:07 +0400 Subject: [PATCH] =?UTF-8?q?docs(spec):=20change=20hooks-write-target=20?= =?UTF-8?q?=E2=80=94=20=D1=85=D1=83=D0=BA=D0=B8=20=D1=80=D0=B5=D1=88=D0=B0?= =?UTF-8?q?=D1=8E=D1=82=20=D0=BF=D0=BE=20=D1=86=D0=B5=D0=BB=D0=B8=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D0=B8=D1=81=D0=B8=20(#6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit proposal, specs/hook-guards (16 требований), design с Decisions made without discussion. B1, B2, решение владельца 1; RT-1, RT-2, RT-6, RT-7. Co-Authored-By: Claude Opus 5.5 --- .../changes/hooks-write-target/.openspec.yaml | 2 + openspec/changes/hooks-write-target/design.md | 168 ++++++++++++ .../changes/hooks-write-target/proposal.md | 53 ++++ .../specs/hook-guards/spec.md | 251 ++++++++++++++++++ 4 files changed, 474 insertions(+) create mode 100644 openspec/changes/hooks-write-target/.openspec.yaml create mode 100644 openspec/changes/hooks-write-target/design.md create mode 100644 openspec/changes/hooks-write-target/proposal.md create mode 100644 openspec/changes/hooks-write-target/specs/hook-guards/spec.md diff --git a/openspec/changes/hooks-write-target/.openspec.yaml b/openspec/changes/hooks-write-target/.openspec.yaml new file mode 100644 index 0000000..a216797 --- /dev/null +++ b/openspec/changes/hooks-write-target/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-15 diff --git a/openspec/changes/hooks-write-target/design.md b/openspec/changes/hooks-write-target/design.md new file mode 100644 index 0000000..9bcd53d --- /dev/null +++ b/openspec/changes/hooks-write-target/design.md @@ -0,0 +1,168 @@ +# Design: хуки решают по цели записи + +Мотивация — `proposal.md`, требования — `specs/hook-guards/spec.md`. Здесь — как это устроено. + +## Context + +- `hooks/guard-main.sh` (44 строки): берёт ветку cwd; для Bash ищет в тексте команды регулярное выражение записи + (`>`, `tee`, `rm`, `git add|commit|push`, `npm i` …); исключения — литералы `git worktree`, `git checkout -b`, + `openspec archive`, `adopt-project.sh` и openspec-only коммит/push. +- `hooks/require-plan.sh` (33 строки): корень и ветка — от cwd; защищённые пути — `src/` или `.claude/code-paths`. +- Ограничения пакета (CLAUDE.md): bash и markdown, `python3` в PATH обязателен, других зависимостей нет; блок — + только `exit 2` с сообщением в stderr; любой сбой разбора — пропуск. +- Самотест хуков в `doctor.sh` — 9 случаев на установленных копиях; `tests/run.sh` хуки guard-main/require-plan не + покрывает. +- Сырьё: `docs/retro/fixtures/hook-blocks.jsonl` — 126 записей из 42 сессий (выгрузка 12.10.2026 на рабочей машине, + коммит 2549e86). Пути — с другой машины (`/Users/andrey.epifancev/projects/awp-ca/...`), поле `branch` в 108 + записях равно `HEAD` и для воспроизведения непригодно. 6 записей — не блокировки, а `cat` исходников хуков. + +Разбивка сырья (запись может попасть в несколько классов): 65 — `N>/dev/null` или `N>&M`; 54 — путь в `~/.claude`; +48 — `cd` внутри команды; 14 — heredoc; 11 — cwd в `.wt/`; 10 — `git -C … worktree`; 10 — Edit/Write вне +репозитория; 8 — `git commit/push`; 3 — `mkdir -p .wt/`; 1 — `sed -i` (единственный перехват по делу). + +## Goals / Non-Goals + +**Goals:** +- Одна модель решения для обоих хуков: цель записи → репозиторий → ветка. +- Разбор команды проверяется без файловой системы; вердикт — на корпусе реальных команд в песочнице. +- Существующие установки обновляются повторным `install.sh` без перерегистрации хуков. + +**Non-Goals:** +- Полный разбор shell: подстановки, переменные, функции, алиасы не вычисляются. +- require-plan для записей через Bash (matcher хука остаётся Edit|Write|MultiEdit). +- Учёт `.gitignore`: запись в игнорируемый путь репозитория на main блокируется так же, как в отслеживаемый. +- allow-правило классификатора для удаления веток (B3), workspace-хук awp-ca (B10). + +## Decisions + +### 1. Ядро на Python, хуки — обёртки +``` +settings.hooks.json (без изменений) + ▼ ▼ +hooks/guard-main.sh hooks/require-plan.sh + нет python3 → exit 0 то же + exec python3 harness_target.py guard-main | require-plan + ▼ +hooks/harness_target.py (stdlib) + parse(cmd, cwd) → [Target(kind=file|git, path|None, op)] чистая функция + resolve(path) → (root, branch) | None git от ближайшего существующего предка + decide(hook, input) → allow | block(сообщение) + CLI: guard-main | require-plan — тело хука (stdin JSON → exit 0/2, stderr) + targets — отладка: цели в JSON +``` +Отдельный `.py`, а не встроенный heredoc: разбор `shlex` с `cd`, heredoc и рекурсией `sh -c` — 150–200 строк, +внутри bash он нечитаем и не тестируется по частям. Имена `.sh` остаются точками входа, поэтому `settings.hooks.json`, +`doctor.sh` и слияние настроек в `install.sh` не меняются. `install.sh` копирует `harness_target.py` рядом с хуками, +`--check` и `doctor.sh` проверяют его наличие. + +Альтернативы: (B) точечно чинить регулярные выражения в bash — дифф мал, но решение остаётся текстовым: `grep "npm i"`, +`cd` внутри команды и cwd оболочки продолжают давать ложные блоки; (C) проверять в Bash только git-мутации — теряется +единственный реальный перехват (`sed -i` в main) и молча проходит распознанная запись редиректом. Выбран A +(владелец, 15.10.2026). + +### 2. Разбор команды (`parse`) +- Токенизация — `shlex` в режиме posix с `punctuation_chars`; префикс дескриптора (`2>`, `2>&1`) распознаётся до + токенизации, чтобы `echo 2 > f` и `cmd 2>f` не путались. +- Простые команды разделяются по `;`, `&&`, `||`, `|`, `&` и переводам строк вне кавычек. `( … )` открывает вложенный + контекст каталога; `cd` из него наружу не выходит, через `|` — тоже. +- Голова команды ищется после присваиваний `VAR=x` и префиксов `sudo`, `env`, `time`, `nohup`, `command`, `xargs`. + У `xargs` цели неизвестны (пути приходят из stdin) — файловые цели неразрешимы, git-репозиторий — текущий каталог. +- Тело heredoc пропускается; редирект перед `< rev-parse --show-toplevel` → корень; `git -C <корень> branch --show-current` → ветка (пусто — + detached). Внутри `.git/` rev-parse не даёт корня — цель считается вне репозитория. Кеш по каталогу на вызов. +- Нет целей — git не вызывается (быстрый путь для чтения). + +| | Репозиторий на main/master/detached | На ветке | Вне репозитория | Не разрешилась | +|---|---|---|---|---| +| **file** | блок | пропуск | пропуск | пропуск | +| **git** | блок | пропуск | пропуск | блок | + +Гибрид выбран владельцем: файловую запись в main видно в `git status` и на ревью, git-мутация в main необратима. + +### 4. require-plan +`resolve(file_path)` даёт корень и ветку файла; дальше прежняя логика: `.claude/code-paths` корня или `src/` на любой +глубине, `.bounded` в корне, `docs/superpowers/plans/*.md` с именем ветки, единственный план, detached HEAD при наличии +плана. Логика переносится в Python как есть и фиксируется тестами. + +### 5. Сообщения +`BLOCKED [guard-main]: запись в <путь отн. корня> — репозиторий <корень> на <ветка>. Работай в worktree на ветке +(superpowers:using-git-worktrees).` Варианты для git-мутации, неопределённого репозитория (`Укажи git -C <абсолютный +путь>`) и require-plan. Префикс в квадратных скобках — для экстрактора и ретро-скриптов; старые английские фразы +экстрактор распознаёт по-прежнему. + +### 6. Корпус и прогон (B2) +``` +skills/harness-doctor/ + fixtures/hook-cases.jsonl размеченный корпус; первая строка — {"layout": [...]} + scripts/hook-sandbox.sh строит раскладку в каталоге + scripts/hook-corpus.sh прогон: строка на расхождение, итог «корпус хуков: N/M», exit 0/1 +docs/retro/fixtures/hook-blocks.jsonl сырая выгрузка, не меняется +``` +- Корпус лежит в skill, потому что `doctor.sh` проверяет установленные копии, а `install.sh` уже копирует + `skills/harness-doctor` целиком. +- Запись: поля выгрузки плюс `id` (`c001`…; живые — `l001`…), `expected`, `why` (класс: `devnull`, `read-only`, + `outside-repo`, `worktree`, `git-nonmut`, `push-feature`, `main-write`, `main-git`, `not-a-block`, …). Спорные — + `why` с суффиксом `?`; владелец смотрит список `block` и спорные. +- `layout`: список `{"path": …, "git": "main" | "<ветка>" | null, "worktree_of": …}` — репозитории awp-ca + (`arm-core`, `docs`, `tabular-parser`, `scan-gateway`, `arm-web`) на main, их worktree `.wt/<имя>/<репо>` на ветках, + `cc-skills/claude-harness` на main и его `.wt/*`, каталоги памяти вне git. +- Прогон: префикс `/Users/andrey.epifancev` в `cwd`, `command`, `file_path` заменяется на `$SANDBOX/home`, `HOME` + подменяется туда же. Edit/Write прогоняются через guard-main (и через require-plan, если у записи `hook` — + require-plan); Bash — через guard-main. +- `tests/test-hook-parse.sh` — 30–40 ручных случаев на правила разбора, проверка вывода `targets`. +- `tests/test-hook-corpus.sh` — весь корпус через `hooks/*.sh`, assert на запись с `id` и `why` в имени. +- Случаи require-plan — в `tests/test-hook-require-plan.sh`. +- `doctor.sh`: прежние 9 случаев плюс «openspec-only коммит на main → exit 2» и строка корпуса. +- Разовая метрика для PR: корпус через старый и новый хук — блокировок было N, стало M. + +### 7. Экстрактор +`hook_blocks_export.py` выгружает результат, только если он помечен ошибкой (`is_error`) и начинается с +`PreToolUse:` / содержит `hook error:`; классификация хука — по префиксу `[guard-main]`/`[require-plan]`, затем по +старым фразам. + +## Risks / Trade-offs + +- [Разбор не видит запись внутри скрипта, `python3 -c`, `make`] → файловые записи и так fail-open по решению владельца; + git-мутации из скриптов не ловятся, как и сейчас. Видно в `git status` и на ревью. +- [Время старта python на каждый Bash-вызов, 30–50 мс] → быстрый путь без вызовов git; старый хук вызывал git на + каждом вызове. +- [Чужой `cd` в main-checkout при относительных командах implementer-а] → блок остаётся и он верный: коммит ушёл бы в + main. Правило «пути worktree абсолютные» уже в `roles/dev.md`; уточнение про относительные команды — там же. +- [Разметка корпуса делается агентом] → владелец проверяет список `block` и спорные; корпус — данные, правятся одной + строкой. +- [Корпус содержит рабочие пути и команды] → секреты заменены при выгрузке (``), репозиторий приватный; + корпус ставится только локально в `~/.claude/skills/harness-doctor/`. + +## Migration Plan + +1. PR в main, merge владельцем. +2. `bash install.sh` на каждой машине — подменяет тела хуков и кладёт `harness_target.py`; `settings.json` не меняется. +3. `doctor.sh` — самотест и корпус по установленным копиям. +4. Откат: `bash install.sh` из предыдущего тега; регистрация хуков та же. + +## Decisions made without discussion + +- 15.10.2026. Основа корпуса — реальная выгрузка (126 записей), а не синтетика по категориям ретро. Обоснование: + владелец выбрал синтетику, когда агент ошибочно считал данные недоступными; выгрузка нашлась в комментарии к #6. + Сказано владельцу в чате, возражений нет. Цена ошибки: переразметка корпуса. +- 15.10.2026. Запись в git-игнорируемый путь репозитория на main блокируется (без `git check-ignore`). Обоснование: + меньше правил, нет случаев в корпусе, где это дало бы ложный блок по делу. Цена ошибки: одно условие в `decide`. +- 15.10.2026. `NotebookEdit` не покрывается: matcher хуков не меняется. Цена ошибки: строка в `settings.hooks.json`. +- 15.10.2026. `git tag`, `git config`, `git remote`, `git gc` не считаются мутациями main. Цена ошибки: строка в + списке мутаций. +- 15.10.2026. `git checkout ` без `--` не считается мутацией: без файловой системы не отличить ветку от пути, а + чистый main-checkout отменять нечего. `git stash list|show` — чтение. Цена ошибки: условие в списке мутаций. +- 15.10.2026. Worktree задачи — `../cc-harness.wt/6-guard-main-target` (вне репозитория), так как `.wt/` не в + `.gitignore`. Цена ошибки: нет. +- 15.10.2026. Имя change — `hooks-write-target`, capability — `hook-guards`, версия — 0.4.0. Цена ошибки: + переименование до archive. +- 15.10.2026. Владелец велел после спеки сразу переходить к плану, после плана — к реализации, без отдельного ревью + спеки и плана. Режим исполнения — по `roles/dev.md` (SDD; до 5 задач — executing-plans). diff --git a/openspec/changes/hooks-write-target/proposal.md b/openspec/changes/hooks-write-target/proposal.md new file mode 100644 index 0000000..06aaf5a --- /dev/null +++ b/openspec/changes/hooks-write-target/proposal.md @@ -0,0 +1,53 @@ +# Proposal: хуки решают по цели записи + +Issue: #6. Источники: ретро `docs/retro/2026-10-11.md` — B1, B2, решение владельца 1; находки RT-1, RT-2, RT-6, RT-7. + +## Why + +guard-main решает по тексту команды и по cwd оболочки, а не по тому, куда идёт запись. По подсчёту ретро около трёх +четвертей блокировок за период пилота ложные: `2>/dev/null` считается записью, `grep "npm i"` — установкой пакета, Edit +файла памяти вне репозитория — записью в main, потому что cwd стоит на main-checkout (RT-1). Чужой `cd` останавливает +фоновый SDD-контроллер (RT-2), отказ хука запускает каскад отказов классификатора auto mode (RT-7). Реальных нарушений +main за тот же период — ноль, перехват по делу — единицы. Регрессионных тестов на реальных командах у хуков нет (RT-6), +поэтому любая правка регулярного выражения проверяется только в бою. require-plan болен тем же: корень, ветку, план и +`code-paths` он берёт от cwd, а не от репозитория правимого файла. + +## What Changes + +- guard-main определяет цели записи — файлы и git-мутации — и решает по ветке репозитория каждой цели. cwd служит + только базой для относительных путей. +- Неразрешимая файловая цель пропускается; git-мутация с неопределённым репозиторием блокируется. +- `git push` считается записью в main, только если он пушит в main; `push origin ` и + `push origin --delete ` из main-checkout проходят. +- **BREAKING:** исключение для openspec-only коммитов и для `openspec archive` на main удаляется (решение владельца 1: + архив всегда отдельным PR). Правила процесса уже приведены к этому в 0.3.0 (`roles/dev.md`); меняется поведение хука. +- `pip install` больше не блокируется (пишет в окружение, не в репозиторий); `npm/yarn/pnpm install` в репозитории на + main — блокируется, как и раньше. +- require-plan берёт корень, ветку, `.claude/code-paths`, `.bounded` и планы из репозитория правимого файла. +- Сообщения блокировки получают стабильный префикс `BLOCKED [guard-main]:` / `BLOCKED [require-plan]:`, называют цель + и выход. +- Регрессионный корпус: 126 записей из транскриптов плюс живые случаи, каждая с ожидаемым вердиктом; прогон из + `tests/run.sh` (источники) и из `doctor.sh` (установленные копии). +- Экстрактор `hook_blocks_export.py` выгружает только настоящие ошибки хуков. + +## Capabilities + +### New Capabilities +- `hook-guards`: блокирующие хуки обвязки — защита main/master/detached HEAD от записи (guard-main), требование плана + для правок в коде (require-plan), их регрессионный корпус. + +### Modified Capabilities + +Нет: `openspec/specs/` пуст, это первая capability обвязки. + +## Impact + +- Код: `hooks/guard-main.sh`, `hooks/require-plan.sh` (становятся обёртками), новый `hooks/harness_target.py` (stdlib + python3, новых зависимостей нет), `install.sh` (копирование и `--check`), `skills/harness-doctor/` (корпус, песочница, + раннер, строка в отчёте), `docs/retro/scripts/hook_blocks_export.py`. +- Тесты: `tests/test-hook-parse.sh`, `tests/test-hook-corpus.sh`, случаи require-plan. +- Документация: `README.md`, `docs/HARNESS.md`, `CLAUDE.md` и `ARCHITECTURE.md` репозитория, `roles/dev.md` (уточнение + про `cd`), `CHANGELOG.md` 0.4.0. +- Установленные копии обновляются повторным `bash install.sh`; регистрация хуков в `settings.json` не меняется. +- Вне задачи: B3 (allow-правило удаления веток), B4 кроме решения 1, B10 (workspace-хук awp-ca), запись в код через Bash + для require-plan. diff --git a/openspec/changes/hooks-write-target/specs/hook-guards/spec.md b/openspec/changes/hooks-write-target/specs/hook-guards/spec.md new file mode 100644 index 0000000..3a92b6d --- /dev/null +++ b/openspec/changes/hooks-write-target/specs/hook-guards/spec.md @@ -0,0 +1,251 @@ +# 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//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 + +### 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: Запись в контейнере не считается локальной +- **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 && 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 `, иначе по текущему каталогу разбора. Прочие +подкоманды, включая `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 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-мутация с неопределённым репозиторием блокируется +Если для 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** экстрактор эту запись не выгружает