Хуки решают по цели записи + регрессионный корпус хуков (#6, B1+B2, 0.4.0) #12

Merged
aep merged 11 commits from feat/6-guard-main-target into main 2026-10-16 09:11:07 +03:00
29 changed files with 3775 additions and 93 deletions

View File

@ -1,5 +1,15 @@
# Changelog # Changelog
## 0.4.0 — 2026-10-16
- guard-main решает по цели записи, а не по cwd и тексту команды (#6, B1; RT-1, RT-2, RT-7). Цели — файл Edit/Write, в Bash — редиректы (кроме `/dev/null` и `N>&M`), команды записи с учётом `cd`, `git -C`, `sh -c`, git-мутации. Цель вне репозитория или в worktree на ветке — пропуск; неразрешимый путь — пропуск, git-мутация с неопределённым репозиторием — блок. `git push` блокируется, только если пушит в main; `git switch -c` в той же команде переводит последующие коммиты на новую ветку. `pip install` больше не блокируется. Спека — `openspec/changes/hooks-write-target/`.
- **Несовместимо:** исключение для openspec-only коммитов и `openspec archive` на main удалено — архив всегда отдельным PR (решение владельца 1, 11.10.2026).
- require-plan берёт корень, ветку, `.claude/code-paths`, `.bounded` и планы из репозитория правимого файла.
- Общее ядро `hooks/harness_target.py` (python3 stdlib); `guard-main.sh` и `require-plan.sh` — обёртки, регистрация в `settings.json` прежняя. Режим `targets` показывает, какие цели видит хук.
- Сообщения блокировки: `BLOCKED [guard-main]:` / `BLOCKED [require-plan]:`, цель, корень репозитория, выход.
- Регрессионный корпус хуков (B2, RT-6): 126 записей из транскриптов пилота + 3 живых случая, размечены по спеке (`docs/retro/scripts/hook_cases_build.py`), прогон в песочнице — `tests/test-hook-corpus.sh` по источникам и строка «корпус хуков» в `doctor.sh` по установленным копиям. В песочнице старые хуки дают на корпусе 41 блок (37 ложных), новые — 4 (архивные коммиты в main).
- `hook_blocks_export.py` и `retro_blocks.py` считают только ошибки хуков: из 126 выгруженных записей 49 оказались выводом команд со словом BLOCKED (O-1), счёт RT-1 в ретро завышен.
- Тесты: `test-hook-parse`, `test-hook-guard-main`, `test-hook-require-plan`, `test-hook-corpus`, `test-hook-export`.
## 0.3.0 — 2026-10-11 ## 0.3.0 — 2026-10-11
- Роли сессии `dev|admin|analyst|qa`: общий слой `roles/<r>.md` (`~/.claude/roles/`), проектный слой `<workspace>/.claude/roles/<r>.md`; формат из четырёх разделов (спека `docs/superpowers/specs/2026-10-11-roles-and-memory-design.md`). - Роли сессии `dev|admin|analyst|qa`: общий слой `roles/<r>.md` (`~/.claude/roles/`), проектный слой `<workspace>/.claude/roles/<r>.md`; формат из четырёх разделов (спека `docs/superpowers/specs/2026-10-11-roles-and-memory-design.md`).
- Skill `/role <r>`: читает оба слоя, раздел памяти роли, «Читать на старте»; строки `brief:` — фоновому агенту `briefing` (sonnet, только чтение). - Skill `/role <r>`: читает оба слоя, раздел памяти роли, «Читать на старте»; строки `brief:` — фоновому агенту `briefing` (sonnet, только чтение).

View File

@ -4,17 +4,17 @@
Пакет обвязки для разработки с Claude Code: глобальный `CLAUDE.md` с процессом, три блокирующих хука и неблокирующий `role-hint`, скрипты подключения проекта и skill диагностики. Для одного разработчика, который ведёт фичи через superpowers, хранит задачи в Gitea и требования в OpenSpec. Главный сценарий: `bash install.sh` ставит глобальную часть в `~/.claude`, `adopt-project.sh` подключает существующий проект, `new-project.sh` создаёт новый; дальше проект живёт по `docs/HARNESS.md`. Репозиторий — источник; установленные копии в `~/.claude` по месту не правятся. Пакет обвязки для разработки с Claude Code: глобальный `CLAUDE.md` с процессом, три блокирующих хука и неблокирующий `role-hint`, скрипты подключения проекта и skill диагностики. Для одного разработчика, который ведёт фичи через superpowers, хранит задачи в Gitea и требования в OpenSpec. Главный сценарий: `bash install.sh` ставит глобальную часть в `~/.claude`, `adopt-project.sh` подключает существующий проект, `new-project.sh` создаёт новый; дальше проект живёт по `docs/HARNESS.md`. Репозиторий — источник; установленные копии в `~/.claude` по месту не правятся.
## Стек и ограничения ## Стек и ограничения
- Bash (`set -euo pipefail`) и markdown. JSON из stdin хуков разбирается встроенным `python3`; других зависимостей времени выполнения нет. Внешние CLI (`tea`, `openspec`, `codegraph`) проверяются, но не требуются для установки. - Bash (`set -euo pipefail`), markdown и `python3` stdlib (3.8+). Логика guard-main и require-plan — `hooks/harness_target.py`, их `.sh` — обёртки; в остальных хуках и скриптах JSON разбирается встроенным `python3`. Других зависимостей времени выполнения нет. Внешние CLI (`tea`, `openspec`, `codegraph`) проверяются, но не требуются для установки.
- Хук блокирует вызов только через `exit 2` с сообщением в stderr; `exit 0` — пропустить. Любой сбой разбора входа должен приводить к пропуску, не к блокировке. - Хук блокирует вызов только через `exit 2` с сообщением в stderr; `exit 0` — пропустить. Любой сбой разбора входа должен приводить к пропуску, не к блокировке.
- `install.sh` идемпотентен: повторный запуск не ломает `settings.json` и делает бэкапы `CLAUDE.md` и `settings.json` с меткой времени. - `install.sh` идемпотентен: повторный запуск не ломает `settings.json` и делает бэкапы `CLAUDE.md` и `settings.json` с меткой времени.
- Хуки работают из `~/.claude/hooks/`, пути в `settings.hooks.json` через `$HOME`; `CLAUDE_CONFIG_DIR` переопределяет `~/.claude`. - Хуки работают из `~/.claude/hooks/`, пути в `settings.hooks.json` через `$HOME`; `CLAUDE_CONFIG_DIR` переопределяет `~/.claude`.
- macOS и Linux; `python3` должен быть в PATH сессии Claude Code. - macOS и Linux; `python3` должен быть в PATH сессии Claude Code.
## Архитектура ## Архитектура
- `hooks/` — поведение обвязки, единственный «код» с логикой. `guard-main.sh` (PreToolUse на Edit/Write/MultiEdit/Bash): запрет записи на main/master/detached HEAD, исключение для openspec-only коммитов. `require-plan.sh` (PreToolUse на Edit/Write/MultiEdit): правки в каталогах кода только при плане для ветки или файле `.bounded`. `harness-check.sh` (SessionStart): проверка, что проект подключён. `role-hint.sh` (SessionStart, все источники): подсказка роли по `session_title`. `settings.hooks.json` — блок, который `install.sh` сливает в `~/.claude/settings.json`. - `hooks/` — поведение обвязки, единственный «код» с логикой. `guard-main.sh` (PreToolUse на Edit/Write/MultiEdit/Bash): запрет записи в репозиторий на main/master/detached HEAD по цели записи (файл, цели команды Bash, git-мутации), без исключений. `require-plan.sh` (PreToolUse на Edit/Write/MultiEdit): правки в каталогах кода репозитория файла только при плане для ветки или файле `.bounded`. Обе — обёртки над `harness_target.py`: `parse` (команда → цели, без ФС) → `resolve` (путь → корень и ветка) → `decide`; режим `targets` — отладка разбора. `harness-check.sh` (SessionStart): проверка, что проект подключён. `role-hint.sh` (SessionStart, все источники): подсказка роли по `session_title`. `settings.hooks.json` — блок, который `install.sh` сливает в `~/.claude/settings.json`.
- `install.sh`, `adopt-project.sh`, `new-project.sh` — установка и бутстрап. `install.sh` копирует `global/`, `hooks/`, `project-template/`, `adopt-project.sh`, `new-project.sh` и `skills/harness-doctor` в `~/.claude/{CLAUDE.md,hooks,harness,skills}`. `adopt-project.sh` работает на ветке `chore/harness-init` и вливает её ff в текущую ветку; это единственная разрешённая запись на main вне PR. - `install.sh`, `adopt-project.sh`, `new-project.sh` — установка и бутстрап. `install.sh` копирует `global/`, `hooks/`, `project-template/`, `adopt-project.sh`, `new-project.sh` и `skills/harness-doctor` в `~/.claude/{CLAUDE.md,hooks,harness,skills}`. `adopt-project.sh` работает на ветке `chore/harness-init` и вливает её ff в текущую ветку; это единственная разрешённая запись на main вне PR.
- `global/CLAUDE.md` — глобальные правила процесса, ставятся в `~/.claude/CLAUDE.md`. `project-template/` — `CLAUDE.md`, `CLAUDE.harness-section.md` (секция «OpenSpec и superpowers», по ней `harness-check` узнаёт подключённый проект), `ARCHITECTURE.md`, `gitignore`. - `global/CLAUDE.md` — глобальные правила процесса, ставятся в `~/.claude/CLAUDE.md`. `project-template/` — `CLAUDE.md`, `CLAUDE.harness-section.md` (секция «OpenSpec и superpowers», по ней `harness-check` узнаёт подключённый проект), `ARCHITECTURE.md`, `gitignore`.
- `skills/harness-doctor/` — skill диагностики: `scripts/doctor.sh` снимает состояние машины и проекта и прогоняет самотест хуков; `SKILL.md` — чеклист прочтения отчёта. - `skills/harness-doctor/` — skill диагностики: `scripts/doctor.sh` снимает состояние машины и проекта и прогоняет самотест хуков; `SKILL.md` — чеклист прочтения отчёта. `fixtures/hook-cases.jsonl` + `scripts/hook_corpus.py` — регрессионный корпус хуков (реальные команды с ожидаемым вердиктом, прогон в песочнице); лежит в skill, чтобы `doctor.sh` гонял его по установленным копиям.
- `roles/` — общий слой ролей (формат: четыре раздела, см. спеку ролей), `skills/role/` — skill `/role`, `agents/briefing.md` — агент выжимки, `link-roles.sh` — симлинки workspace → репозитории направлений. Ставятся `install.sh` в `~/.claude/{roles,skills/role,agents,harness}`. - `roles/` — общий слой ролей (формат: четыре раздела, см. спеку ролей), `skills/role/` — skill `/role`, `agents/briefing.md` — агент выжимки, `link-roles.sh` — симлинки workspace → репозитории направлений. Ставятся `install.sh` в `~/.claude/{roles,skills/role,agents,harness}`.
- `tests/` — тесты пакета на bash: `run.sh` + `test-*.sh`, работают с источниками, `install.sh` ставят во временный `CLAUDE_CONFIG_DIR`. - `tests/` — тесты пакета на bash: `run.sh` + `test-*.sh`, работают с источниками, `install.sh` ставят во временный `CLAUDE_CONFIG_DIR`.
- `caveman/config.json` — выключенный по умолчанию caveman. `examples/` — реальный `CLAUDE.md` проекта-пилота. - `caveman/config.json` — выключенный по умолчанию caveman. `examples/` — реальный `CLAUDE.md` проекта-пилота.
@ -28,15 +28,20 @@
- `bash skills/harness-doctor/scripts/doctor.sh [проект]` — отчёт и самотест хуков. Только чтение. - `bash skills/harness-doctor/scripts/doctor.sh [проект]` — отчёт и самотест хуков. Только чтение.
- `bash adopt-project.sh <папка> [--no-merge] [--push]` — подключить существующий проект. Из терминала, не из Claude Code. - `bash adopt-project.sh <папка> [--no-merge] [--push]` — подключить существующий проект. Из терминала, не из Claude Code.
- `bash new-project.sh <папка> [ssh-url]` — каркас нового проекта. - `bash new-project.sh <папка> [ssh-url]` — каркас нового проекта.
- `python3 docs/retro/scripts/retro_stats.py <каталог транскриптов>` и `retro_blocks.py` — статистика сессий и блокировок хуков для ретро. - `python3 docs/retro/scripts/retro_stats.py <каталог транскриптов>` и `retro_blocks.py` — статистика сессий и блокировок хуков для ретро; `hook_blocks_export.py <каталог> [...]` — выгрузка блокировок в JSONL (сырьё для тестов хуков); `hook_cases_build.py > skills/harness-doctor/fixtures/hook-cases.jsonl` — сборка размеченного корпуса из выгрузки.
- `python3 skills/harness-doctor/scripts/hook_corpus.py hooks [--summary]` — корпус хуков по источникам. `python3 hooks/harness_target.py targets '<команда>' [cwd]` — какие цели записи видит хук.
## Тесты ## Тесты
- `tests/run.sh` — автоматические тесты источников (хуки через фиктивный JSON, скрипты во временных каталогах, размеры). Самотест хуков в `doctor.sh` проверяет установленные копии; перед ним `bash install.sh`. - `tests/run.sh` — автоматические тесты источников (хуки через фиктивный JSON, скрипты во временных каталогах, размеры). Самотест хуков в `doctor.sh` проверяет установленные копии; перед ним `bash install.sh`.
- Ручная проверка хука: на main в любом репозитории попросить агента создать файл, ожидается `BLOCKED`. - Ручная проверка хука: на main в любом репозитории попросить агента создать файл, ожидается `BLOCKED`.
- `tests/` покрывает `role-hint` и скрипты `install.sh`, `doctor.sh`, `link-roles.sh`; регрессионные тесты `guard-main` и `require-plan` на реальных командах из транскриптов (бэклог ретро B2) пока не сделаны. - Хуки: `test-hook-parse` — разбор команды на цели; `test-hook-guard-main`, `test-hook-require-plan` — сценарии спеки `hook-guards` в песочнице из настоящих репозиториев; `test-hook-corpus` — корпус реальных команд (assert на запись, корпус должен совпадать со сборкой `hook_cases_build.py`); `test-hook-export` — экстрактор. Плюс `role-hint` и скрипты `install.sh`, `doctor.sh`, `link-roles.sh`.
- Правишь разбор или вердикт — сначала случай в `test-hook-parse`/`test-hook-guard-main`; меняется ожидаемый вердикт реальной команды — разметка в `hook_cases_build.py` и пересборка корпуса.
## Текущая работа ## Текущая работа
Бэклог обвязки — `docs/retro/2026-10-11.md`, раздел 4 (B1–B12), решения владельца — раздел 5. Порядок: B1+B2 (guard-main по цели записи, тесты хуков), затем B5+B6 (роли и память, спека `docs/superpowers/specs/2026-10-11-roles-and-memory-design.md`), затем B3/B4/B10. Находки ретро маркируются `RT-n`. Бэклог обвязки — `docs/retro/2026-10-11.md`, раздел 4 (B1–B12), решения владельца — раздел 5. Находки ретро маркируются `RT-n`.
- Сделано: B5+B6 часть 1 — слой ролей (PR #4, 0.3.0). Часть 2 (awp-ca) — issue #2, отложенные находки — issue #5.
- В работе: B1+B2 и отмена исключения openspec-only для guard-main — issue #6, ветка `feat/6-guard-main-target`.
- Дальше: B3/B4/B10, B12.
## OpenSpec и superpowers ## OpenSpec и superpowers
- Вход в любую задачу — superpowers:brainstorming. Skills openspec-propose и openspec-explore сами по себе не запускать. - Вход в любую задачу — superpowers:brainstorming. Skills openspec-propose и openspec-explore сами по себе не запускать.

View File

@ -20,8 +20,9 @@
``` ```
global/CLAUDE.md глобальные правила процесса → ~/.claude/CLAUDE.md global/CLAUDE.md глобальные правила процесса → ~/.claude/CLAUDE.md
hooks/guard-main.sh запрет изменений на main/detached HEAD (Edit/Write/Bash); исключение — коммиты только в openspec/ hooks/guard-main.sh запрет изменений на main/master/detached HEAD (Edit/Write/Bash) по цели записи, без исключений
hooks/require-plan.sh правки в src/ только при наличии плана или файла .bounded hooks/require-plan.sh правки в src/ (или .claude/code-paths) только при плане для ветки или файле .bounded
hooks/harness_target.py ядро обоих хуков: цели записи команды → репозиторий и ветка каждой цели
hooks/settings.hooks.json блок PreToolUse для ~/.claude/settings.json hooks/settings.hooks.json блок PreToolUse для ~/.claude/settings.json
caveman/config.json defaultMode: off → ~/.config/caveman/config.json caveman/config.json defaultMode: off → ~/.config/caveman/config.json
project-template/ CLAUDE.md, ARCHITECTURE.md, gitignore для нового проекта project-template/ CLAUDE.md, ARCHITECTURE.md, gitignore для нового проекта

View File

@ -69,13 +69,15 @@ Fix loop: раунды 1–3 продолжают того же implementer, 4
| Правило | Файл | Механизм | | Правило | Файл | Механизм |
|---|---|---| |---|---|---|
| Никаких изменений на main / detached HEAD | `guard-main.sh`, PreToolUse на Edit/Write/MultiEdit/Bash | проверяет ветку; для Bash блокирует команды, похожие на запись (`>`, `tee`, `sed -i`, `mv`, `rm`, `git commit/push`…), пропускает `git worktree add`; коммит/push в main разрешены только если изменены исключительно файлы в `openspec/` (архивация) | | Никаких изменений на main / master / detached HEAD | `guard-main.sh` → `harness_target.py`, PreToolUse на Edit/Write/MultiEdit/Bash | решает по цели записи, а не по cwd. Цели: файл Edit/Write; в Bash — редиректы (кроме `/dev/null` и `N>&M`), `tee`, `rm`, `mv`, `cp`, `sed -i`, `npm install`, `openspec archive`… с учётом `cd` и `git -C`; git-мутации (`commit`, `add`, `merge`, `reset`…; `push` — только если пушит в main; `switch -c` в той же команде меняет ветку). Цель вне репозитория или в worktree на ветке — пропуск; неразрешимый путь (`$VAR`) — пропуск; git-мутация с неопределённым репозиторием — блок. Исключений нет: архив OpenSpec — отдельным PR |
| Правки в коде только при плане или `.bounded` | `require-plan.sh`, PreToolUse на Edit/Write/MultiEdit | защищает любой `src/` на любой глубине (`api/src/`, `packages/*/src/`) или каталоги из `.claude/code-paths`; ищет план в `docs/superpowers/plans/` для текущей ветки или файл `.bounded` | | Правки в коде только при плане или `.bounded` | `require-plan.sh` → `harness_target.py`, PreToolUse на Edit/Write/MultiEdit | по репозиторию правимого файла, а не по cwd: защищает любой `src/` на любой глубине (`api/src/`, `packages/*/src/`) или каталоги из `.claude/code-paths`; ищет план в `docs/superpowers/plans/` для ветки этого репозитория или файл `.bounded` в его корне |
| Обвязка подключена к проекту | `harness-check.sh`, SessionStart | при старте сессии проверяет `openspec/`, `.codegraph/`, секцию в CLAUDE.md; если чего-то нет — пишет агенту инструкцию запустить `~/.claude/harness/adopt-project.sh` (спросив пользователя; `HARNESS_AUTO_ADOPT=1` — без вопроса) | | Обвязка подключена к проекту | `harness-check.sh`, SessionStart | при старте сессии проверяет `openspec/`, `.codegraph/`, секцию в CLAUDE.md; если чего-то нет — пишет агенту инструкцию запустить `~/.claude/harness/adopt-project.sh` (спросив пользователя; `HARNESS_AUTO_ADOPT=1` — без вопроса) |
| Подсказка роли по заголовку сессии | `role-hint.sh`, SessionStart (все источники) | читает `session_title`; если начинается с «dev, admin, analyst или qa» — одна строка-подсказка вызвать `/role`; ничего не блокирует | | Подсказка роли по заголовку сессии | `role-hint.sh`, SessionStart (все источники) | читает `session_title`; если начинается с «dev, admin, analyst или qa» — одна строка-подсказка вызвать `/role`; ничего не блокирует |
| Тесты зелёные перед коммитом | git pre-commit в проекте (не входит в пакет) | обычный git-хук с командой тестов | | Тесты зелёные перед коммитом | git pre-commit в проекте (не входит в пакет) | обычный git-хук с командой тестов |
Exit 2 блокирует вызов и показывает агенту stderr, поэтому агент сам делает то, что просит сообщение. Оба обхода, замеченные на пилоте (запись через `echo >` вместо Write; push через detached HEAD), закрыты. Exit 2 блокирует вызов и показывает агенту stderr, поэтому агент сам делает то, что просит сообщение. Сообщение начинается с `BLOCKED [guard-main]:` или `BLOCKED [require-plan]:` и называет цель, репозиторий и выход. Оба обхода, замеченные на пилоте (запись через `echo >` вместо Write; push через detached HEAD), закрыты.
Регрессия хуков — корпус `skills/harness-doctor/fixtures/hook-cases.jsonl`: реальные команды из транскриптов пилота с ожидаемым вердиктом. По источникам — `bash tests/run.sh hook`, по установленным копиям — строка «корпус хуков» в `doctor.sh`. Корпус собирается из сырой выгрузки `docs/retro/scripts/hook_cases_build.py`; разметка — в этом скрипте. Разбор одной команды: `python3 ~/.claude/hooks/harness_target.py targets '<команда>' <cwd>`.
## Дисциплина между сессиями ## Дисциплина между сессиями

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""Выгрузка блокировок хуков из транскриптов Claude Code в JSONL — сырьё для регрессионных тестов хуков (B2).
Запуск: python3 hook_blocks_export.py <каталог-транскриптов> [<каталог> ...] > blocks.jsonl
Одна строка — одна блокировка: сессия, время, cwd и ветка на момент вызова, инструмент, команда или путь файла,
первая строка ответа хука. Поле expected не заполняется: вердикт (block/allow) проставляется при разметке.
Выгружаются только ошибки хуков (результат начинается с `PreToolUse:<инструмент> hook error`); вывод команд,
в котором встретилось слово BLOCKED (файлы памяти, исходники хуков), не попадает.
Похожие на секреты подстроки заменяются на <redacted>.
"""
import json, sys, glob, os, re
HOOK_ERROR = re.compile(r"\s*PreToolUse:\S+ hook error")
RE_SECRET = re.compile(
r'(?i)gh[pousr]_[A-Za-z0-9]{20,}|sk-[A-Za-z0-9_-]{20,}|\b[0-9a-f]{40}\b'
r'|(?P<kv>(?:token|password|passwd|secret|api[_-]?key)\s*[=:]\s*)\S+'
r'|(?P<flag>--(?:token|password)\s+)\S+|(?P<auth>Authorization:\s*\S+\s+)\S+'
)
def redact(s):
def sub(m):
keep = m.group("kv") or m.group("flag") or m.group("auth") or ""
return keep + "<redacted>"
return RE_SECRET.sub(sub, s)
def hook_of(txt, tool):
if "[guard-main]" in txt:
return "guard-main(Bash)" if tool == "Bash" else "guard-main(Edit/Write)"
if "[require-plan]" in txt:
return "require-plan"
if "shell write" in txt: # формулировки до 0.4.0
return "guard-main(Bash)"
if "you are on" in txt:
return "guard-main(Edit/Write)"
if "without a plan" in txt:
return "require-plan"
if "commit/push в main" in txt or "ветке main" in txt or "reference-old-arm" in txt or "docs/source" in txt:
return "workspace-guard"
return "other"
for d in sys.argv[1:]:
for f in sorted(glob.glob(os.path.join(os.path.expanduser(d), "*.jsonl"))):
sid = os.path.basename(f)[:8]
pending = {} # tool_use_id -> (name, input, cwd, branch, ts)
for line in open(f, encoding="utf-8", errors="replace"):
try:
j = json.loads(line)
except Exception:
continue
m = j.get("message") or {}
cont = m.get("content")
if j.get("type") == "assistant":
for b in cont or []:
if isinstance(b, dict) and b.get("type") == "tool_use":
pending[b.get("id")] = (b.get("name"), b.get("input") or {}, j.get("cwd", ""),
j.get("gitBranch", ""), j.get("timestamp", ""))
elif j.get("type") == "user" and isinstance(cont, list):
for b in cont:
if not (isinstance(b, dict) and b.get("type") == "tool_result"):
continue
txt = b.get("content", "")
if isinstance(txt, list):
txt = "\n".join(x.get("text", "") for x in txt if isinstance(x, dict))
if not HOOK_ERROR.match(txt):
continue # обычный вывод команды, даже если в нём есть слово BLOCKED
name, inp, cwd, branch, ts = pending.get(b.get("tool_use_id"), ("?", {}, "", "", ""))
if name not in ("Bash", "Edit", "Write", "MultiEdit"):
continue
msg = next((l for l in txt.splitlines() if "BLOCKED" in l), "")
print(json.dumps({
"session": sid, "ts": ts, "cwd": cwd, "branch": branch, "tool": name,
"command": redact(inp.get("command", "")) if name == "Bash" else None,
"file_path": inp.get("file_path") if name != "Bash" else None,
"hook": hook_of(txt, name), "message": redact(msg[:300]), "expected": None,
}, ensure_ascii=False))

View File

@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""Сборка регрессионного корпуса хуков из сырой выгрузки блокировок (B2, #6).
Запуск из корня репозитория:
python3 docs/retro/scripts/hook_cases_build.py > skills/harness-doctor/fixtures/hook-cases.jsonl
Вход: docs/retro/fixtures/hook-blocks.jsonl (hook_blocks_export.py, 12.10.2026, 126 записей).
Выход: первая строка — {"home": …, "layout": […]} (раскладка песочницы), дальше по записи на случай:
поля выгрузки + id, expected (allow|block), why (класс). Вердикт размечен вручную по спеке
openspec/changes/hooks-write-target/specs/hook-guards/spec.md, а не прогоном хука.
"""
import json
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
RAW = os.path.join(HERE, "..", "fixtures", "hook-blocks.jsonl")
HOME = "/Users/andrey.epifancev"
AW, CH = "projects/awp-ca", "projects/cc-skills/claude-harness"
LAYOUT = [{"path": AW}, {"path": AW + "/reference-old-arm"}]
LAYOUT += [{"path": "%s/%s" % (AW, r), "branch": "main"}
for r in ("arm-core", "docs", "tabular-parser", "scan-gateway", "arm-web", "fixtures")]
LAYOUT += [{"path": "%s/.wt/%s/%s" % (AW, n, r), "worktree_of": "%s/%s" % (AW, r), "branch": "feat/" + n}
for n, r in (("web-proto", "arm-core"), ("web-proto", "arm-web"), ("ocr-q1", "fixtures"),
("ocr-q1", "scan-gateway"), ("stmt-template", "arm-web"), ("qa-guide-web", "docs"),
("slice-0", "scan-gateway"), ("slice-3b", "arm-core"))]
LAYOUT += [{"path": CH, "branch": "main"}]
LAYOUT += [{"path": "%s/.wt/%s" % (CH, n), "worktree_of": CH, "branch": b}
for n, b in (("retro", "retro/2026-10-11"), ("roles", "feat/2-roles-and-memory"),
("guard", "feat/6-guard-main-target"))]
LAYOUT += [{"path": ".claude/projects/-Users-andrey-epifancev-projects-awp-ca/memory"},
{"path": ".claude/projects/-Users-andrey-epifancev-projects-cc-skills-claude-harness/memory"},
{"path": ".claude/hooks"}, {"path": ".claude/jobs"}]
# Блок по спеке: архивный коммит openspec прямо в main (решение владельца 1 — архив отдельным PR).
BLOCK = {"c031", "c069", "c070", "c103"}
# Класс для записей, где он не выводится общим правилом ниже.
WHY = {
"c002": "unparseable", "c008": "var-target", "c023": "outside-repo", "c026": "outside-repo",
"c029": "worktree", "c030": "outside-repo", "c040": "worktree-setup", "c044": "worktree", "c045": "worktree",
"c055": "outside-repo", "c064": "worktree-setup", "c072": "switch-then-commit", "c073": "worktree-setup",
"c078": "outside-repo", "c080": "heredoc", "c081": "heredoc", "c083": "outside-repo", "c091": "heredoc",
"c099": "worktree-setup", "c100": "worktree-setup", "c101": "worktree", "c102": "worktree",
"c104": "worktree-setup", "c106": "worktree-setup", "c119": "heredoc",
}
# Ложные блоки этой сессии (15.10.2026), до перехода в worktree: cwd — основной checkout на main.
LIVE_RE = ('git show origin/feat/6-guard-main-target:docs/retro/fixtures/hook-blocks.jsonl | python3 -c \'\n'
'import sys,json,re,collections\n'
'rs=[json.loads(l) for l in sys.stdin if l.strip()]\n'
'c=collections.Counter()\n'
'for r in rs:\n'
' cmd=r.get("command") or ""\n'
' if re.search(r"\\d>{1,2}\\s*/dev/null|\\d>&\\d",cmd): c["bash: N>/dev/null или N>&M"]+=1\n'
' if re.search(r"mkdir\\s+-p\\s+\\S*\\.wt/",cmd): c["bash: mkdir -p .wt/"]+=1\n'
' if re.search(r"\\bsed\\s+-i",cmd): c["bash: sed -i"]+=1\n'
'for k,v in c.most_common(): print(f"{v:4} {k}")\'')
LIVE = [
("l001", "grep-pattern", 'grep -rn "npm i\\|npm install\\|brew install\\|plugin install\\|go install" '
'--include="*.md" --include="*.sh" . | grep -v "^./docs/retro/2026" | head -20'),
("l002", "devnull", 'grep -rn "plugin add\\|marketplace\\|caveman@\\|superpowers@" --include="*.md" '
'--include="*.sh" README.md docs global install.sh 2>/dev/null | head -20'),
("l003", "regex-text", LIVE_RE),
]
DEVNULL = re.compile(r"\d>{1,2}\s*/dev/null|\d>&\d")
def why_of(cid, r):
if cid in BLOCK:
return "main-git"
if cid in WHY:
return WHY[cid]
if "hook error" not in (r.get("message") or ""):
return "not-a-block" # вывод успешной команды, где встретилось слово BLOCKED
if r["tool"] != "Bash":
return "outside-repo"
return "devnull" if DEVNULL.search(r.get("command") or "") else "read-only"
def main():
print(json.dumps({"home": HOME, "layout": LAYOUT}, ensure_ascii=False))
for n, line in enumerate(l for l in open(RAW, encoding="utf-8") if l.strip()):
r = json.loads(line)
cid = "c%03d" % (n + 1)
r.update({"id": cid, "expected": "block" if cid in BLOCK else "allow", "why": why_of(cid, r)})
print(json.dumps(r, ensure_ascii=False))
for cid, why, cmd in LIVE:
print(json.dumps({"id": cid, "session": "live-1015", "cwd": HOME + "/" + CH, "branch": "main",
"tool": "Bash", "command": cmd, "file_path": None, "hook": "guard-main(Bash)",
"message": "", "expected": "allow", "why": why}, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@ -8,6 +8,7 @@ files = sorted(glob.glob(os.path.join(d, "*.jsonl")))
RE_REAL = re.compile(r'(^|[^<\d])>{1,2}[^&]|\btee\b|\bsed\s+-i|\bmv\b|\bcp\b|\brm\b|\btouch\b|\bmkdir\b|\bgit\s+(add|commit|push|apply|stash\s+pop)\b|\bnpm\s+(i|install|init)\b|\bpip\s+install\b') RE_REAL = re.compile(r'(^|[^<\d])>{1,2}[^&]|\btee\b|\bsed\s+-i|\bmv\b|\bcp\b|\brm\b|\btouch\b|\bmkdir\b|\bgit\s+(add|commit|push|apply|stash\s+pop)\b|\bnpm\s+(i|install|init)\b|\bpip\s+install\b')
RE_STDERR = re.compile(r'\d>{1,2}\s*/dev/null|\d>&\d') RE_STDERR = re.compile(r'\d>{1,2}\s*/dev/null|\d>&\d')
RE_HEREDOC_PY = re.compile(r"<<\s*'?(PY|EOF)") RE_HEREDOC_PY = re.compile(r"<<\s*'?(PY|EOF)")
RE_HOOK_ERROR = re.compile(r"\s*PreToolUse:\S+ hook error") # только ошибка хука, не вывод команды со словом BLOCKED
blocks = collections.Counter() blocks = collections.Counter()
by_hook = collections.Counter() by_hook = collections.Counter()
@ -63,10 +64,14 @@ for f in files:
if isinstance(txt, list): if isinstance(txt, list):
txt = "\n".join(x.get("text", "") for x in txt if isinstance(x, dict)) txt = "\n".join(x.get("text", "") for x in txt if isinstance(x, dict))
name, inp = pending.get(b.get("tool_use_id"), ("?", {})) name, inp = pending.get(b.get("tool_use_id"), ("?", {}))
if "BLOCKED" in txt and ("hook" in txt.lower() or "BLOCKED:" in txt[:400]): if RE_HOOK_ERROR.match(txt) and "BLOCKED" in txt:
if name not in ("Bash", "Edit", "Write", "MultiEdit"): if name not in ("Bash", "Edit", "Write", "MultiEdit"):
continue continue
if "shell write" in txt: if "[guard-main]" in txt:
hook = "guard-main(Bash)" if name == "Bash" else "guard-main(Edit/Write)"
elif "[require-plan]" in txt:
hook = "require-plan"
elif "shell write" in txt:
hook = "guard-main(Bash)" hook = "guard-main(Bash)"
elif "you are on" in txt: elif "you are on" in txt:
hook = "guard-main(Edit/Write)" hook = "guard-main(Edit/Write)"

File diff suppressed because it is too large Load Diff

View File

@ -1,44 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# PreToolUse hook: block file changes on main/master and on detached HEAD. # PreToolUse hook (Edit/Write/MultiEdit/Bash): запрет записи в репозиторий на main/master/detached HEAD.
# Covers Edit/Write/MultiEdit directly and Bash commands that look like writes. # Решение — по цели записи, а не по cwd; логика в harness_target.py рядом. Любой сбой — пропуск (exit 0).
# Single exception: commits/pushes to main that touch only openspec/ (archive step). py="${0%/*}/harness_target.py"
set -euo pipefail command -v python3 >/dev/null 2>&1 && [ -f "$py" ] || exit 0
input=$(cat) python3 "$py" guard-main
eval "$(printf '%s' "$input" | python3 -c ' [ $? -eq 2 ] && exit 2
import sys, json, shlex exit 0
d = json.load(sys.stdin); ti = d.get("tool_input", {}) or {}
print("cwd=" + shlex.quote(d.get("cwd", "")))
print("tool=" + shlex.quote(d.get("tool_name", "")))
print("cmd=" + shlex.quote(ti.get("command", "") or ""))' 2>/dev/null || echo 'cwd=; tool=; cmd=')"
[ -n "$cwd" ] || exit 0
git -C "$cwd" rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
branch=$(git -C "$cwd" branch --show-current 2>/dev/null || true)
case "$branch" in main|master|"") ;; *) exit 0 ;; esac
label=${branch:-"detached HEAD"}
only_openspec() { # $1 = git diff range/args; true if every changed path is under openspec/
local files
files=$(git -C "$cwd" diff --name-only $1 2>/dev/null || true)
[ -n "$files" ] && ! printf '%s\n' "$files" | grep -vq '^openspec/'
}
if [ "$tool" = "Bash" ]; then
case "$cmd" in
*"git worktree"*|*"git checkout -b"*|*"git switch -c"*|*"openspec archive"*|*"/.claude/harness/adopt-project.sh"*) exit 0 ;;
esac
# archive exception: commit of staged openspec-only changes, push of openspec-only commits
if printf '%s' "$cmd" | grep -Eq '\bgit\s+commit\b'; then
only_openspec "--cached" && exit 0
fi
if printf '%s' "$cmd" | grep -Eq '\bgit\s+push\b'; then
only_openspec "origin/main...HEAD" && exit 0
fi
if printf '%s' "$cmd" | grep -Eq '(^|[^<])>{1,2}[^&]|\btee\b|\bsed\s+-i|\bmv\b|\bcp\b|\brm\b|\btouch\b|\bmkdir\b|\bgit\s+(add|commit|push|apply|stash\s+pop)\b|\bnpm\s+(i|install|init)\b|\bpip\s+install\b'; then
echo "BLOCKED: shell write on $label ($(printf '%s' "$cmd" | head -c 80)). Create an isolated worktree first (superpowers:using-git-worktrees). Only 'openspec archive' commits may land on main directly." >&2
exit 2
fi
exit 0
fi
echo "BLOCKED: you are on $label. Create an isolated worktree first (superpowers:using-git-worktrees), never edit main directly." >&2
exit 2

716
hooks/harness_target.py Normal file
View File

@ -0,0 +1,716 @@
#!/usr/bin/env python3
"""claude-harness: общее ядро хуков guard-main и require-plan — решение по цели записи, а не по cwd.
Обёртки hooks/guard-main.sh и hooks/require-plan.sh передают сюда JSON вызова из stdin:
python3 harness_target.py guard-main|require-plan < hook.json → exit 0 (пропустить) | 2 (блок, причина в stderr)
Отладка разбора без файловой системы:
python3 harness_target.py targets '<команда>' [cwd] → по строке на цель: «kind path op»
Любой сбой разбора или вызова git — пропуск (exit 0). Только stdlib, Python 3.8+.
Спека: openspec/changes/hooks-write-target/specs/hook-guards/spec.md.
"""
import glob
import json
import os
import re
import subprocess
import sys
from collections import namedtuple
# kind: file | git | switch; path: абсолютный путь или None (не разрешился); op: что делает команда.
# Для switch path — каталог репозитория, op — новая ветка (None — не определена).
Target = namedtuple("Target", "kind path op")
PROTECTED = ("main", "master", "") # "" — detached HEAD
SEPS = (";", "&&", "||", "|", "|&", "&", ";;", "\n", "(", ")")
OPS = sorted(["&&", "||", ";;", ";", "|&", "|", "&>>", "&>", "&", "(", ")", ">>", ">|", ">&", ">",
"<<<", "<<-", "<<", "<&", "<>", "<"], key=len, reverse=True)
OP_CHARS = set(";&|()<>")
WRITE_REDIRS = (">", ">>", ">|", "&>", "&>>")
KEYWORDS = ("if", "then", "else", "elif", "fi", "do", "done", "while", "until", "!", "{", "}")
SKIP_HEADS = ("for", "case", "select", "function")
PREFIXES = ("sudo", "env", "nohup", "command", "exec", "nice", "time", "builtin", "xargs", "timeout", "ionice",
"stdbuf")
# Опции префиксов, которые забирают следующее слово как значение (иначе значение примут за голову команды).
PREFIX_VALUED = {
"sudo": ("-u", "-g", "-C", "-D", "-h", "-p", "-r", "-t", "-U", "--user", "--group", "--chdir"),
"nice": ("-n", "--adjustment"),
"env": ("-u", "--unset", "-S", "--split-string"),
"timeout": ("-s", "--signal", "-k", "--kill-after"),
"ionice": ("-c", "--class", "-n", "--classdata", "-p", "--pid", "-P", "--pgid", "-u", "--uid"),
"stdbuf": ("-i", "--input", "-o", "--output", "-e", "--error"),
"xargs": ("-I", "-n", "-P", "-L", "-d", "-a", "-E", "-s", "--max-args", "--max-procs", "--max-lines",
"--delimiter", "--arg-file", "--replace"),
}
NOFOLLOW = ("rm", "rmdir", "unlink", "ln", "mv") # меняют саму ссылку, а не её цель
CUR_BRANCH = re.compile(r"(\$\(|`)\s*git\s+(branch\s+--show-current|rev-parse\s+--abbrev-ref\s+HEAD|"
r"symbolic-ref\s+(-q\s+)?--short\s+HEAD)\s*(\)|`)")
ALL_PATHS = ("tee", "touch", "mkdir", "rm", "rmdir", "truncate", "unlink", "mv", "shred")
DEST = ("cp", "install", "ln", "rsync")
GIT_MUT = ("commit", "merge", "rebase", "cherry-pick", "revert", "reset", "am", "add", "rm", "mv", "restore")
ASSIGN = re.compile(r"[A-Za-z_][A-Za-z0-9_]*(\[[^]]*\])?\+?=")
FD = re.compile(r"\d+(?=[<>])")
VAR = re.compile(r"[A-Za-z_][A-Za-z0-9_]*|[0-9?$!#*@-]")
def _home():
return os.environ.get("HOME", "")
# ---------- лексер ----------
class Lexer:
"""Токены команды: ("w", текст, динамическое?) и ("op", оператор). Тело heredoc пропускается.
Динамическое слово содержит $VAR, $(…) или `…` — в тексте остаётся сырой вид подстановки.
~, $HOME и ${HOME} раскрываются по переменной окружения HOME.
"""
def __init__(self, s, i=0):
self.s, self.i, self.heredocs = s, i, []
def tokens(self, until_paren=False):
s, out, depth = self.s, [], 0
while self.i < len(s):
c = s[self.i]
if c in " \t":
self.i += 1
elif s.startswith("\\\n", self.i):
self.i += 2
elif c == "\n":
self.i += 1
out.append(("op", "\n"))
self._skip_heredocs()
elif c == "#":
j = s.find("\n", self.i)
self.i = len(s) if j < 0 else j
elif FD.match(s, self.i):
m = FD.match(s, self.i)
self.i = m.end()
op = self._op()
out.append(("op", m.group() + op))
elif c in OP_CHARS:
op = self._op()
if until_paren and op == "(":
depth += 1
elif until_paren and op == ")":
if depth == 0:
return out
depth -= 1
out.append(("op", op))
if op in ("<<", "<<-"):
w = self._word()
if w is not None:
self.heredocs.append((w[0], op == "<<-"))
out.append(("w",) + w)
else:
out.append(("w",) + self._word())
if until_paren:
raise ValueError("unclosed $(")
return out
def _op(self):
op = next(o for o in OPS if self.s.startswith(o, self.i))
self.i += len(op)
return op
def _skip_heredocs(self):
s = self.s
for delim, strip_tabs in self.heredocs:
while self.i < len(s):
j = s.find("\n", self.i)
line = s[self.i:len(s) if j < 0 else j]
self.i = len(s) if j < 0 else j + 1
if (line.lstrip("\t") if strip_tabs else line) == delim:
break
self.heredocs = []
def _word(self):
s, n = self.s, len(self.s)
while self.i < n and s[self.i] in " \t":
self.i += 1
start, buf, dyn = self.i, [], False
while self.i < n:
c = s[self.i]
if c in " \t\n" or c in OP_CHARS:
break
if c == "\\":
if self.i + 1 < n and s[self.i + 1] != "\n":
buf.append(s[self.i + 1])
self.i += 2
elif c == "'":
j = s.find("'", self.i + 1)
if j < 0:
raise ValueError("unclosed '")
buf.append(s[self.i + 1:j])
self.i = j + 1
elif c == '"':
self.i += 1
while True:
if self.i >= n:
raise ValueError('unclosed "')
c = s[self.i]
if c == '"':
self.i += 1
break
if c == "\\" and self.i + 1 < n and s[self.i + 1] in '"\\$`\n':
if s[self.i + 1] != "\n":
buf.append(s[self.i + 1])
self.i += 2
elif c in "$`":
t, d = self._dollar()
buf.append(t)
dyn = dyn or d
else:
buf.append(c)
self.i += 1
elif c in "$`":
t, d = self._dollar()
buf.append(t)
dyn = dyn or d
elif c == "~" and self.i == start and (self.i + 1 >= n or s[self.i + 1] in "/ \t\n;&|()<>"):
buf.append(_home())
self.i += 1
else:
buf.append(c)
self.i += 1
if self.i == start:
return None
return "".join(buf), dyn
def _dollar(self):
"""$…/`…` с позиции i → (текст, динамическое?)."""
s, i = self.s, self.i
if s[i] == "`":
j = i + 1
while j < len(s) and s[j] != "`":
j += 2 if s[j] == "\\" else 1
if j >= len(s):
raise ValueError("unclosed `")
self.i = j + 1
return s[i:j + 1], True
if s.startswith("$(", i): # подстановка и арифметика $((…)): пропуск до парной скобки
sub = Lexer(s, i + 2)
sub.tokens(until_paren=True)
self.i = sub.i
return s[i:self.i], True
if s.startswith("${", i):
j = s.find("}", i)
if j < 0:
raise ValueError("unclosed ${")
self.i = j + 1
return (_home(), False) if s[i + 2:j] == "HOME" else (s[i:j + 1], True)
m = VAR.match(s, i + 1)
if not m:
self.i = i + 1
return "$", False
self.i = m.end()
return (_home(), False) if m.group() == "HOME" else (s[i:m.end()], True)
# ---------- разбор ----------
def _join(cur, p):
"""Путь из команды Bash: у glob — каталог до первого метасимвола, дальше как _abs."""
if p is None:
return None
m = re.search(r"[*?\[]", p)
if m:
p = os.path.dirname(p[:m.start()]) or "."
return _abs(cur, p)
def _abs(cur, p):
"""Абсолютный нормализованный путь; None, если относительный и cwd неизвестен. Литерал, без glob —
для file_path инструментов (src/[slug].ts — имя файла, а не шаблон)."""
if p is None:
return None
if os.path.isabs(p):
return os.path.normpath(p)
if not cur:
return None
return os.path.normpath(os.path.join(cur, p))
def _path(w, cur):
text, dyn = w
return None if dyn else _join(cur, text)
def _operands(args, valued=()):
"""Аргументы-операнды без опций; опции из valued забирают следующее слово как значение."""
out, k, opts_done = [], 0, False
while k < len(args):
t = args[k][0]
if not opts_done and t == "--":
opts_done = True
elif not opts_done and t.startswith("-") and t != "-":
if t in valued:
k += 1
else:
out.append(args[k])
k += 1
return out
def _branch(w):
"""Имя ветки из слова. Для динамического — '<статический префикс>*', если префикс не может стать
защищённой веткой, иначе None (не определена)."""
text, dyn = w
if not dyn:
return text
cut = min([i for i in (text.find("$"), text.find("`")) if i >= 0] or [len(text)])
prefix = text[:cut]
if prefix and not any(p.startswith(prefix) for p in ("main", "master", "HEAD", "refs/heads/main",
"refs/heads/master")):
return prefix + "*"
return None
class _State:
def __init__(self, cwd, out, depth):
self.cur, self.out, self.depth, self.dirs = cwd, out, depth, []
def parse(cmd, cwd, _out=None, _depth=0):
"""Цели записи команды Bash в порядке выполнения. Чистая функция: файловую систему не читает.
cwd — каталог, от которого считаются относительные пути (None — неизвестен)."""
out = [] if _out is None else _out
if _depth > 5:
return out
st = _State(cwd or None, out, _depth)
toks = Lexer(cmd).tokens()
stack, words, redirs, prev = [], [], [], None
i = 0
while i <= len(toks):
t = toks[i] if i < len(toks) else ("op", "\n")
if t[0] == "w":
words.append((t[1], t[2]))
i += 1
continue
op = t[1]
if op in SEPS:
if words or redirs:
new = _simple(words, redirs, st)
if new is not _SAME and op not in ("|", "|&") and prev not in ("|", "|&"):
st.cur = new
words, redirs = [], []
if op == "(":
stack.append(st.cur)
elif op == ")" and stack:
st.cur = stack.pop()
prev = op
i += 1
continue
nxt = toks[i + 1] if i + 1 < len(toks) and toks[i + 1][0] == "w" else None
redirs.append((op, (nxt[1], nxt[2]) if nxt else None))
i += 2 if nxt else 1
return out
_SAME = object()
def _simple(words, redirs, st):
"""Одна простая команда: цели в st.out; возвращает новый каталог для cd/pushd/popd или _SAME."""
cur, out = st.cur, st.out
for op, w in redirs:
o = op.lstrip("0123456789")
if w is None:
continue
if o in WRITE_REDIRS or (o == ">&" and not re.fullmatch(r"\d+-?|-", w[0])):
if not w[1] and w[0].startswith("/dev/"):
continue
out.append(Target("file", _path(w, cur), op))
ws = list(words)
while ws and ws[0][0] in KEYWORDS and not ws[0][1]:
ws.pop(0)
if not ws or ws[0][0] in SKIP_HEADS:
return _SAME
xargs = False
while ws:
h = ws[0][0]
if ASSIGN.match(h):
ws.pop(0)
elif h in PREFIXES and not ws[0][1]:
xargs = xargs or h == "xargs"
ws.pop(0)
valued = PREFIX_VALUED.get(h, ())
while ws:
t = ws[0][0]
if h == "env" and ASSIGN.match(t):
ws.pop(0)
elif h == "env" and (t in ("-C", "--chdir") or t.startswith("--chdir=")):
ws.pop(0)
if "=" in t:
v = (t.split("=", 1)[1], False)
else:
v = ws.pop(0) if ws else ("", True)
cur = None if v[1] else _abs(cur, v[0]) # env -C DIR: каталог только этой команды
elif t.startswith("-") and t != "-":
ws.pop(0)
if t in valued and ws:
ws.pop(0)
else:
break
if h == "timeout" and ws:
ws.pop(0) # длительность
else:
break
if not ws or ws[0][1]:
return _SAME # пусто или голова — переменная: неизвестная команда
head, args = os.path.basename(ws[0][0]), ws[1:]
def file_(w, op):
out.append(Target("file", None if xargs else _path(w, cur), op))
if head in ("cd", "pushd"):
a = _operands(args)
if not a:
new = _home() or None
else:
new = None if a[0][1] or a[0][0] == "-" else _join(cur, a[0][0])
if head == "pushd":
st.dirs.append(cur)
return new
if head == "popd":
return st.dirs.pop() if st.dirs else None
if head in ("sh", "bash", "zsh", "dash"):
for k, (t, d) in enumerate(args):
if t.startswith("-") and not t.startswith("--") and "c" in t[1:]:
if k + 1 < len(args) and not args[k + 1][1]:
parse(args[k + 1][0], cur, out, st.depth + 1)
break
return _SAME
if head == "eval":
parse(" ".join(t for t, d in args), cur, out, st.depth + 1)
return _SAME
if xargs and head in ALL_PATHS + DEST + ("chmod", "chown", "chgrp", "sed", "perl"):
out.append(Target("file", None, head)) # пути придут из stdin
return _SAME
if head in ALL_PATHS:
for w in _operands(args, ("-s", "--size") if head == "truncate" else ()):
file_(w, head)
elif head in ("chmod", "chown", "chgrp"):
for w in _operands(args)[1:]:
file_(w, head)
elif head in DEST:
tdir = next((args[k + 1] for k, (t, d) in enumerate(args[:-1]) if t == "-t"), None)
tdir = tdir or next(((t.split("=", 1)[1], d) for t, d in args if t.startswith("--target-directory=")), None)
ops = _operands(args, ("-t", "-S", "--suffix"))
if tdir:
dst = tdir
elif len(ops) >= 2:
dst = ops[-1]
elif head == "ln" and ops:
dst = (".", False) # ln -s цель — ссылка в текущем каталоге
else:
dst = None
if dst is not None and not (head == "rsync" and re.match(r"[^/]*:", dst[0])):
file_(dst, head)
elif head == "sed":
inplace = any(t == "-i" or t.startswith("-i") or t.startswith("--in-place")
or re.fullmatch(r"-[A-Za-z]*i[A-Za-z]*", t) for t, d in args)
if inplace:
rest = [w for k, w in enumerate(args)
if not (w[0] == "" and k > 0 and args[k - 1][0] == "-i")] # BSD: sed -i '' …
has_script = any(t in ("-e", "-f", "--expression", "--file") or t.startswith("--expression=")
for t, d in rest)
ops = _operands(rest, ("-e", "-f", "-l", "--expression", "--file"))
for w in (ops if has_script else ops[1:]):
file_(w, "sed -i")
elif head == "perl":
if any(re.fullmatch(r"-[A-Za-z]*i\S*", t) for t, d in args):
has_script = any(re.fullmatch(r"-[A-Za-z]*[eE]", t) for t, d in args)
ops = _operands(args, ("-e", "-E", "-pe", "-ne", "-pie", "-npe"))
for w in (ops if has_script else ops[1:]):
file_(w, "perl -i")
elif head == "dd":
for t, d in args:
if t.startswith("of="):
file_((t[3:], d), "dd")
elif head == "tar":
first = args[0][0] if args else ""
if any(t in ("--extract", "--get") or (re.fullmatch(r"-[A-Za-z]+", t) and "x" in t) for t, d in args) \
or (re.fullmatch(r"[A-Za-z]+", first) and "x" in first):
dst = next((args[k + 1] for k, (t, d) in enumerate(args[:-1]) if t == "-C"), None)
dst = dst or next(((t.split("=", 1)[1], d) for t, d in args if t.startswith("--directory=")), None)
file_(dst or (".", False), "tar -x")
elif head == "unzip":
if not any(t in ("-l", "-t", "-v", "-Z", "-p") for t, d in args):
dst = next((args[k + 1] for k, (t, d) in enumerate(args[:-1]) if t == "-d"), None)
file_(dst or (".", False), "unzip")
elif head == "patch":
if "--dry-run" not in [t for t, d in args]:
dst = next((args[k + 1] for k, (t, d) in enumerate(args[:-1]) if t == "-d"), None)
dst = dst or next(((t.split("=", 1)[1], d) for t, d in args if t.startswith("--directory=")), None)
file_(dst or (".", False), "patch")
elif head in ("npm", "yarn", "pnpm"):
if not any(t in ("-g", "--global") for t, d in args):
ops = _operands(args)
sub = ops[0][0] if ops else ("install" if head == "yarn" else "")
if sub in ("install", "i", "ci", "add", "init"):
out.append(Target("file", cur, head + " " + sub))
elif head == "openspec":
ops = _operands(args)
if ops and ops[0][0] in ("archive", "new", "init", "update"):
out.append(Target("file", cur, "openspec " + ops[0][0]))
elif head == "git":
_git(args, st, cur)
return _SAME
def _git(args, st, cur):
repo, k, unknown = cur, 0, False
while k < len(args):
t, d = args[k]
if t == "-C" and k + 1 < len(args):
v = args[k + 1]
if v[1]:
unknown = True
else:
repo = _join(repo, v[0])
k += 2
elif t in ("-c", "--namespace", "--exec-path", "--config-env") and k + 1 < len(args):
k += 2
elif t.startswith("--git-dir") or t.startswith("--work-tree"):
unknown = True
k += 1 if "=" in t else 2
elif t.startswith("-"):
k += 1
else:
break
if k >= len(args):
return
sub, rest = args[k][0], args[k + 1:]
path = None if unknown or repo is None else repo
words = [t for t, d in rest]
add = st.out.append
if sub in GIT_MUT:
add(Target("git", path, sub))
elif sub == "apply":
if not any(w in ("--check", "--stat", "--numstat", "--summary") for w in words):
add(Target("git", path, "apply"))
elif sub == "stash":
s2 = next((w for w in words if not w.startswith("-")), "push")
if s2 not in ("list", "show"):
add(Target("git", path, "stash"))
elif sub == "checkout":
flag = next((j for j, w in enumerate(words) if w in ("-b", "-B", "--orphan")), None)
if flag is not None and flag + 1 < len(rest):
add(Target("switch", path, _branch(rest[flag + 1])))
elif "--" in words and words.index("--") < len(words) - 1:
add(Target("git", path, "checkout --"))
else:
# checkout <x> без «--»: ветка или путь — неизвестно; защищённые имена и «-» — точно смена ветки,
# переменная — ветка не определена (последующие git-мутации блокируются)
ops = _operands(rest)
if ops and (ops[0][1] or ops[0][0] in ("main", "master", "-")):
add(Target("switch", path, None if ops[0][1] or ops[0][0] == "-" else ops[0][0]))
elif sub == "switch":
name, found = None, False
for j, w in enumerate(words):
if w in ("-c", "-C", "--create", "--force-create", "--orphan") and j + 1 < len(rest):
name, found = _branch(rest[j + 1]), True
break
if w in ("-d", "--detach"):
name, found = "", True
break
if not found:
ops = _operands(rest)
if ops:
name, found = (None if ops[0] == ("-", False) else _branch(ops[0])), True # «-» — предыдущая
if found:
add(Target("switch", path, name))
elif sub == "push":
_push(rest, path, add)
def _push(rest, path, add):
delete, everything, tags, pos, k = False, False, False, [], 0
while k < len(rest):
t = rest[k][0]
if t in ("--delete", "-d"):
delete = True
elif t == "--tags":
tags = True
elif t in ("--all", "--mirror", "--branches"):
everything = True
elif t in ("--repo", "-o", "--push-option", "--receive-pack", "--exec"):
k += 1
elif not t.startswith("-"):
pos.append(rest[k])
k += 1
if everything:
add(Target("git", path, "push --all"))
return
refs = pos[1:]
if not refs:
if not delete and not tags: # --tags без refspec пушит только теги
add(Target("git", path, "push"))
return
for text, dyn in refs:
src, colon, dst = text.lstrip("+").partition(":")
if delete or not colon:
dst = src
if dst.startswith("refs/heads/"):
dst = dst[len("refs/heads/"):]
if dyn and CUR_BRANCH.fullmatch(dst):
add(Target("git", path, "push")) # цель — $(git branch --show-current) и т. п., то есть текущая ветка
continue
if dst == "HEAD" and not delete and not colon:
add(Target("git", path, "push"))
continue
name = _branch((dst, dyn))
if name is None:
add(Target("git", path, "push?"))
elif name in ("main", "master"):
add(Target("git", path, "push>" + name))
# ---------- файловая система и вердикт ----------
_cache = {}
def _git_out(d, *args):
env = {k: v for k, v in os.environ.items() if k not in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE")}
r = subprocess.run(["git", "-C", d] + list(args), stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
universal_newlines=True, timeout=5, env=env)
return r.stdout.strip() if r.returncode == 0 else None
def resolve(path, follow=True):
"""(корень, ветка, реальный путь) git-репозитория, в котором лежит path, или None — вне репозитория.
Ветка '' — detached HEAD. Симлинки раскрываются до поиска (в том числе висячие: запись создаст файл у цели
ссылки), затем берётся ближайший существующий предок. follow=False — для команд, меняющих саму ссылку
(rm, ln, mv): раскрывается только каталог, последний элемент пути остаётся как есть."""
if follow:
real = p = os.path.realpath(path)
else:
p = os.path.realpath(os.path.dirname(path)) # поиск от каталога: exists/isdir разыменовали бы ссылку
real = os.path.join(p, os.path.basename(path))
while not os.path.exists(p):
parent = os.path.dirname(p)
if parent == p:
return None
p = parent
d = p if os.path.isdir(p) else os.path.dirname(p)
if d not in _cache:
root = _git_out(d, "rev-parse", "--show-toplevel")
branch = _git_out(root, "branch", "--show-current") if root else None
_cache[d] = (root, branch or "") if root else None
hit = _cache[d]
if not hit:
return None
return hit[0], hit[1], real
def _label(branch):
return "detached HEAD" if branch == "" else branch
WT_HINT = "Работай в worktree на ветке (superpowers:using-git-worktrees)."
def decide_guard(tool, ti, cwd):
"""Сообщение блокировки guard-main или None."""
cwd = cwd or None
if tool == "Bash":
targets = parse(ti.get("command") or "", cwd)
elif tool in ("Edit", "Write", "MultiEdit"):
fp = ti.get("file_path") or ""
targets = [Target("file", _abs(cwd, fp), tool)] if fp else []
else:
return None
over = {} # корень → ветка после git switch/checkout -b в этой же команде (None — не определена)
for t in targets:
if t.kind == "switch":
r = resolve(t.path) if t.path else None
if r:
over[r[0]] = t.op
continue
if t.path is None:
if t.kind == "git":
if t.op == "push?":
return "BLOCKED [guard-main]: git push — целевая ветка не определена. Укажи ветку явно."
return ("BLOCKED [guard-main]: git %s — репозиторий не определён (переменная в -C, --git-dir или "
"--work-tree). Укажи git -C <абсолютный путь>." % t.op)
continue
r = resolve(t.path, follow=not (t.kind == "file" and t.op in NOFOLLOW))
if not r:
continue
root, branch, real = r
if root in over:
branch = over[root]
if t.kind == "git":
if t.op.startswith("push>") or t.op == "push --all":
return "BLOCKED [guard-main]: git %s — репозиторий %s. В main только через PR." % (
t.op.replace(">", " в "), root)
if t.op == "push?":
return "BLOCKED [guard-main]: git push — целевая ветка не определена. Укажи ветку явно."
if branch is None or branch in PROTECTED:
return "BLOCKED [guard-main]: git %s — репозиторий %s на %s. В main только через PR. %s" % (
t.op, root, "неизвестной ветке" if branch is None else _label(branch), WT_HINT)
elif branch in PROTECTED:
return "BLOCKED [guard-main]: запись в %s — репозиторий %s на %s. %s" % (
os.path.relpath(real, root), root, _label(branch), WT_HINT)
return None
def decide_plan(fp, cwd):
"""Сообщение блокировки require-plan или None."""
path = _abs(cwd or None, fp) if fp else None
r = resolve(path) if path else None
if not r:
return None
root, branch, real = r
rel = os.path.relpath(real, root)
cp = os.path.join(root, ".claude", "code-paths")
if os.path.isfile(cp):
prefixes = [l.split("#", 1)[0].strip().rstrip("/") for l in open(cp, encoding="utf-8", errors="replace")]
protected = any(p and rel.startswith(p + "/") for p in prefixes)
else:
protected = "/src/" in "/" + rel
if not protected or os.path.exists(os.path.join(root, ".bounded")):
return None
plans = sorted(glob.glob(os.path.join(root, "docs", "superpowers", "plans", "*.md")))
if plans:
if branch == "" or len(plans) == 1:
return None
for p in plans:
if branch in open(p, encoding="utf-8", errors="replace").read():
return None
return ("BLOCKED [require-plan]: правка %s без плана для ветки %s. Запусти superpowers:brainstorming; "
"для bounded-задачи создай %s/.bounded." % (rel, _label(branch), root))
def main(argv):
mode = argv[1] if len(argv) > 1 else ""
if mode == "targets":
try:
ts = parse(argv[2] if len(argv) > 2 else "", argv[3] if len(argv) > 3 else os.getcwd())
except Exception as e:
print("error %s" % e)
return 1
for t in ts:
print(t.kind, "-" if t.path is None else t.path, "-" if t.op is None else t.op)
return 0
try:
d = json.loads(sys.stdin.read())
ti = d.get("tool_input") or {}
if mode == "guard-main":
msg = decide_guard(d.get("tool_name") or "", ti, d.get("cwd") or "")
elif mode == "require-plan":
msg = decide_plan(ti.get("file_path") or ti.get("path") or "", d.get("cwd") or "")
else:
msg = None
except Exception:
return 0
if msg:
sys.stderr.write(msg + "\n")
return 2
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))

View File

@ -1,33 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# PreToolUse hook: edits in code dirs (any src/ by default, or .claude/code-paths) require a plan for this branch or a .bounded marker. # PreToolUse hook (Edit/Write/MultiEdit): правки в каталогах кода (любой src/ или .claude/code-paths) — только при
set -euo pipefail # плане для ветки или файле .bounded. Репозиторий — тот, где лежит файл; логика в harness_target.py рядом.
input=$(cat) py="${0%/*}/harness_target.py"
read -r cwd fpath < <(printf '%s' "$input" | python3 -c ' command -v python3 >/dev/null 2>&1 && [ -f "$py" ] || exit 0
import sys,json python3 "$py" require-plan
d=json.load(sys.stdin); ti=d.get("tool_input",{}) or {} [ $? -eq 2 ] && exit 2
print(d.get("cwd",""), ti.get("file_path") or ti.get("path") or "")' 2>/dev/null || echo " ") exit 0
[ -n "$cwd" ] && [ -n "$fpath" ] || exit 0
root=$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null || true)
[ -n "$root" ] || exit 0
# Защищаемые пути. По умолчанию — любой каталог src/ на любой глубине (src/, api/src/, packages/*/src/).
# Проект может переопределить списком префиксов (относительно корня, по одному на строку) в .claude/code-paths.
rel=${fpath#"$root"/}
protected=0
if [ -f "$root/.claude/code-paths" ]; then
while IFS= read -r p; do
p=${p%%#*}; p=${p## }; p=${p%% }; [ -n "$p" ] || continue
case "$rel" in "${p%/}"/*) protected=1; break ;; esac
done < "$root/.claude/code-paths"
else
case "/$rel" in */src/*) protected=1 ;; esac
fi
[ "$protected" = 1 ] || exit 0
[ -f "$root/.bounded" ] && exit 0
branch=$(git -C "$cwd" branch --show-current 2>/dev/null || true)
if ls "$root"/docs/superpowers/plans/*.md >/dev/null 2>&1; then
if [ -z "$branch" ] || grep -lq -- "$branch" "$root"/docs/superpowers/plans/*.md 2>/dev/null; then exit 0; fi
# any plan exists but none names this branch: still allow if only one plan (single-feature repo)
[ "$(ls "$root"/docs/superpowers/plans/*.md | wc -l)" -eq 1 ] && exit 0
fi
echo "BLOCKED: editing $fpath without a plan. Run superpowers:brainstorming; for a bounded change create '$root/.bounded' first." >&2
exit 2

View File

@ -25,7 +25,7 @@ check() {
echo " WARN codegraph prompt-hook в settings.json — рекомендуется отключить на время пилота" echo " WARN codegraph prompt-hook в settings.json — рекомендуется отключить на время пилота"
fi fi
echo "== Установленная часть обвязки ($CC)" echo "== Установленная часть обвязки ($CC)"
for f in CLAUDE.md hooks/guard-main.sh hooks/require-plan.sh hooks/harness-check.sh hooks/role-hint.sh \ for f in CLAUDE.md hooks/guard-main.sh hooks/require-plan.sh hooks/harness_target.py hooks/harness-check.sh hooks/role-hint.sh \
roles/dev.md roles/admin.md roles/analyst.md roles/qa.md skills/role/SKILL.md agents/briefing.md \ roles/dev.md roles/admin.md roles/analyst.md roles/qa.md skills/role/SKILL.md agents/briefing.md \
harness/adopt-project.sh harness/new-project.sh harness/link-roles.sh skills/harness-doctor/SKILL.md; do harness/adopt-project.sh harness/new-project.sh harness/link-roles.sh skills/harness-doctor/SKILL.md; do
[ -f "$CC/$f" ] && echo " ok $f" || { echo " MISS $f (bash install.sh)"; ok=1; } [ -f "$CC/$f" ] && echo " ok $f" || { echo " MISS $f (bash install.sh)"; ok=1; }
@ -45,7 +45,8 @@ fi
cp "$HERE/global/CLAUDE.md" "$CC/CLAUDE.md" cp "$HERE/global/CLAUDE.md" "$CC/CLAUDE.md"
# 2. Хуки # 2. Хуки
cp "$HERE/hooks/guard-main.sh" "$HERE/hooks/require-plan.sh" "$HERE/hooks/harness-check.sh" "$HERE/hooks/role-hint.sh" "$CC/hooks/" cp "$HERE/hooks/guard-main.sh" "$HERE/hooks/require-plan.sh" "$HERE/hooks/harness-check.sh" "$HERE/hooks/role-hint.sh" \
"$HERE/hooks/harness_target.py" "$CC/hooks/"
# 2a. Скрипты бутстрапа проекта — агент запускает их из сессии # 2a. Скрипты бутстрапа проекта — агент запускает их из сессии
mkdir -p "$CC/harness" mkdir -p "$CC/harness"
rm -rf "$CC/harness/project-template" rm -rf "$CC/harness/project-template"
@ -93,6 +94,6 @@ json.dump(s, open(sp,"w"), ensure_ascii=False, indent=2)
print("settings.json: hooks merged") print("settings.json: hooks merged")
PY PY
echo "done: $CC/CLAUDE.md, $CC/skills/harness-doctor, $CC/hooks/{guard-main,require-plan,harness-check,role-hint}.sh, $CC/roles/, $CC/skills/role, $CC/agents/briefing.md, $CC/harness/, ~/.config/caveman/config.json" echo "done: $CC/CLAUDE.md, $CC/skills/harness-doctor, $CC/hooks/{guard-main,require-plan,harness-check,role-hint}.sh + harness_target.py, $CC/roles/, $CC/skills/role, $CC/agents/briefing.md, $CC/harness/, ~/.config/caveman/config.json"
echo "Плагины Claude Code ставятся вручную: /plugin install superpowers@claude-plugins-official" echo "Плагины Claude Code ставятся вручную: /plugin install superpowers@claude-plugins-official"
check || true check || true

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-15

View File

@ -0,0 +1,220 @@
# 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` и для воспроизведения непригодно.
- O-1. Настоящих ошибок хука в выгрузке 76 (результат начинается с `PreToolUse:<инструмент> hook error`); 49 записей —
вывод успешных команд, где встретилось слово BLOCKED: `cat` файла памяти `workspace-hooks-and-worktrees.md` и
исходников хуков; ещё одна — вывод теста require-plan. Тем же фильтром пользуется `retro_blocks.py`, так что счёт
RT-1 в ретро завышен. Шумовые записи остаются в корпусе как случаи «пропустить»: в реальности они прошли.
Разбивка сырья (запись может попасть в несколько классов): 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` (c083: правил файл памяти, а не docs, как
считалось в ретро, — тоже ложный блок). По разметке блок ожидается у 4 записей: архивные коммиты openspec прямо в main
с push в main (c031, c069, c070, c103).
## 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: лексер и разбор с `cd`, heredoc и рекурсией `sh -c` — около 450 строк,
внутри 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`)
- Префикс дескриптора (`2>`, `2>&1`) распознаётся лексером только в начале токена, чтобы `echo 2 > f` и `cmd 2>f`
не путались.
- Простые команды разделяются по `;`, `&&`, `||`, `|`, `&` и переводам строк вне кавычек. `( … )` открывает вложенный
контекст каталога; `cd` из него наружу не выходит, через `|` — тоже.
- Голова команды ищется после присваиваний `VAR=x` и префиксов `sudo`, `env`, `time`, `nohup`, `command`, `xargs`.
У `xargs` цели неизвестны (пути приходят из stdin) — файловые цели неразрешимы, git-репозиторий — текущий каталог.
- Тело heredoc пропускается; редирект перед `<<EOF` — цель.
- `sh -c`, `bash -c`, `eval` в голове — аргумент разбирается рекурсивно с текущим каталогом.
- Пути: `~`, `$HOME`, `${HOME}` раскрываются по переменной окружения `HOME`; прочие `$`, `` ` ``, `$(` дают `path=None`.
У glob берётся каталог до первого метасимвола.
- Токенизация — собственный лексер, а не `shlex`: `shlex` режет `$(…)` на скобки и не знает heredoc внутри
подстановки (`git commit -m "$(cat <<'EOF' … EOF)"`, c069). Лексер рекурсивно пропускает `$(…)` до парной скобки с
учётом кавычек и heredoc; содержимое подстановки целями не считается.
- `git switch -c|-C <b>`, `git switch <b>`, `git checkout -b|-B <b>` дают событие смены ветки; `decide` применяет его
к последующим целям того же репозитория (поток «ветка архива → add → commit → push» в одной команде, c072).
- Таблица глаголов и git-подкоманд — в спеке (требования «Редирект», «Команды записи», «Git-мутации», «push»,
«Смена ветки»).
### 3. `resolve` и вердикт
- Относительный путь склеивается с текущим каталогом разбора; берётся ближайший существующий предок, у него
раскрываются симлинки (`realpath`) — запись через симлинк workspace попадает туда, куда указывает ссылка.
- `git -C <предок> 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 размеченный корпус; первая строка — {"home": …, "layout": [...]}
scripts/hook_corpus.py <hooks> [--summary] строит песочницу по layout и прогоняет корпус; exit 0/1
docs/retro/fixtures/hook-blocks.jsonl сырая выгрузка, не меняется
docs/retro/scripts/hook_cases_build.py сборка корпуса из выгрузки: таблица разметки, layout, живые случаи
```
Песочница и прогон — один Python-скрипт, а не два bash-скрипта: раскладка и записи — JSON, `subprocess` на запись
проще и быстрее, чем bash с встроенным python. Корпус — артефакт сборки: тест проверяет, что файл совпадает с выводом
`hook_cases_build.py`, так что разметка правится только в скрипте.
- Корпус лежит в 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` — ручные случаи на правила разбора, проверка вывода `targets`.
- `tests/test-hook-guard-main.sh` — сценарии спеки в песочнице из настоящих репозиториев.
- `tests/test-hook-require-plan.sh` — сценарии require-plan.
- `tests/test-hook-corpus.sh` — весь корпус через `hooks/*.sh`, assert на запись с `id` и `why` в имени.
- `tests/test-hook-export.sh` — экстрактор на фиктивном транскрипте.
- `doctor.sh`: наличие `harness_target.py`, прежние 9 случаев плюс «openspec-only коммит на main → exit 2», строка
корпуса.
- Разовая метрика для PR: в песочнице старые хуки дают на корпусе 41 блок (37 ложных), новые — 4 (0 ложных,
0 пропущенных).
### 7. Экстрактор
`hook_blocks_export.py` и `retro_blocks.py` считают результат блокировкой, только если он начинается с
`PreToolUse:<инструмент> hook error` (O-1); классификация хука — по префиксу `[guard-main]`/`[require-plan]`, затем
по старым фразам. Признак `is_error` не используется: в выгрузке его нет, проверить на старых транскриптах нельзя.
## 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` и спорные; корпус — данные, правятся одной
строкой.
- [Корпус содержит рабочие пути и команды] → секреты заменены при выгрузке (`<redacted>`), репозиторий приватный;
корпус ставится только локально в `~/.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 <x>` без `--` не считается мутацией: без файловой системы не отличить ветку от пути, а
чистый 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).
- 16.10.2026. Добавлено требование «Смена ветки внутри команды» (на разметке корпуса, c072): без него поток архива
отдельным PR в одной команде (`switch -c chore/archive-… && add && commit`) блокировался бы. Цена ошибки: событие
switch в `decide`.
- 16.10.2026. Корпус: песочница и прогон — один `hook_corpus.py` вместо `hook-sandbox.sh` + `hook-corpus.sh`;
разметка — в `hook_cases_build.py`, корпус — его вывод. Цена ошибки: нет, интерфейс для тестов и doctor тот же.
- 16.10.2026. `retro_blocks.py` получает тот же фильтр ошибок хука, что и экстрактор (O-1), иначе метрика ложных
блокировок для `/harness-retro` (B12) считается с шумом. Цена ошибки: числа старого ретро не воспроизводятся
новым скриптом — они и были завышены.
- 16.10.2026. Шумовые записи корпуса (49) не удаляются, а размечаются `not-a-block`/`allow`: это реальные команды,
которые прошли, и новый хук обязан их пропускать. Цена ошибки: нет.
- 16.10.2026. Лексер собственный, без `shlex` (см. Decisions, 2). Цена ошибки: ~150 строк своего разбора; покрыт
`test-hook-parse` и корпусом.
- 16.10.2026, финальное ревью (I-1). `git checkout main|master` — смена ветки; `git checkout -`, `git switch -` и
ветка в переменной — ветка не определена (git-мутации после неё блокируются). `checkout <другое имя>` событием не
считается: без ФС не отличить ветку от пути. Цена ошибки: ложный блок `checkout "$f" && git commit`.
- 16.10.2026, финальное ревью (I-2). Префиксы `timeout`, `ionice`, `stdbuf` добавлены, у `sudo`, `nice`, `env`,
`timeout`, `ionice`, `stdbuf`, `xargs` известны опции со значением; у `timeout` снимается длительность; `env -C DIR`
меняет каталог только этой команды. Цена ошибки: незнакомая опция со значением снова спрячет голову (fail-open).
- 16.10.2026, финальное ревью (M-3). `rm`, `rmdir`, `unlink`, `ln`, `mv` оцениваются по месту ссылки, а не по её
цели. Известные пробелы: `rm -rf link/` с косой чертой (удаляет содержимое цели) и `mv a link-на-каталог` (кладёт
в цель) оцениваются по месту ссылки. Цена ошибки: пропуск записи в main через ссылку на каталог.
- 16.10.2026, финальное ревью (M-4). `file_path` Edit/Write — литерал: glob-обрезка только для путей из команд Bash.
- 16.10.2026, финальное ревью (M-5). `git push --tags` без refspec не считается push ветки; refspec-подстановка
текущей ветки — push текущей ветки. Цена ошибки: нет (подстановки перечислены явно, прочие — «не определена»).
- 16.10.2026, финальное ревью (M-7), решения прототипа, не записанные раньше:
- `git push` с неизвестной целевой веткой (`"$BR"`) блокируется при любой текущей ветке; в сообщении нет корня
репозитория. Цена ошибки: один ложный блок, сообщение просит указать ветку явно.
- Динамическое имя ветки со статическим префиксом, который не может стать защищённым (`feat/$ID`), считается
рабочей веткой (`_branch`). Цена ошибки: нет — `feat/…` не может быть `main`.
- `openspec new|init|update` считаются записью в cwd, как и `archive` (пишут в `openspec/`).
- Целями не считаются все `/dev/*`, а не только четыре пути из спеки (`/dev/fd/N` и т. п. — тоже не файлы
репозитория).
- Git-мутация при пустом `cwd` во входе хука блокируется («репозиторий не определён»). Claude Code `cwd` передаёт
всегда; пропуск такого входа — deferred (M-1).

View File

@ -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 <feature>` и
`push origin --delete <feature>` из 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.

View File

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

View File

@ -17,7 +17,7 @@
### Исполнение ### Исполнение
- Работа только в worktree. Ветка называется по issue: `feat/<issue-id>-<slug>`. Пути worktree в планах и брифах — абсолютные. - Работа только в worktree. Ветка называется по issue: `feat/<issue-id>-<slug>`. Пути worktree в планах и брифах — абсолютные.
- Пока работает фоновый SDD-контроллер, в главной сессии не делать `cd` в основной checkout: cwd оболочки общий, контроллер и implementer-ы получат `BLOCKED: shell write on main` и остановятся. - Пока работает фоновый SDD-контроллер, в главной сессии не делать `cd` в основной checkout: cwd оболочки общий, и команды implementer-ов с относительными путями пойдут в main — guard-main их заблокирует, и они остановятся. Команды с абсолютными путями worktree и `git -C <worktree>` от cwd не зависят.
- Режим по умолчанию: subagent-driven-development. Контроллер SDD запускается фоновым сабагентом **на модели sonnet** (не на модели сессии); сессия только ждёт его отчёт. Для планов до 5 задач — executing-plans (Native). Режим не спрашивать, он задан здесь. - Режим по умолчанию: subagent-driven-development. Контроллер SDD запускается фоновым сабагентом **на модели sonnet** (не на модели сессии); сессия только ждёт его отчёт. Для планов до 5 задач — executing-plans (Native). Режим не спрашивать, он задан здесь.
- Модели исполнителей по SDD Model Selection: задача с готовым кодом в плане — haiku, интеграция — sonnet, ревью всей ветки — opus. Модель в dispatch указывать всегда. - Модели исполнителей по SDD Model Selection: задача с готовым кодом в плане — haiku, интеграция — sonnet, ревью всей ветки — opus. Модель в dispatch указывать всегда.
- Ревьюер задачи проверяет trailer коммита (`Co-Authored-By`, как указано в плане); чужой trailer — находка `T<n>.<k>`, переписывать историю нельзя (force-push запрещён). - Ревьюер задачи проверяет trailer коммита (`Co-Authored-By`, как указано в плане); чужой trailer — находка `T<n>.<k>`, переписывать историю нельзя (force-push запрещён).

View File

@ -30,6 +30,8 @@ description: Диагностика обвязки Claude Code (claude-harness)
**Самотест хуков** — любое `WARN … (ожидалось N)` означает, что хук установлен, но не делает того, что должен. Причины по убыванию частоты: правлен руками; `python3` не в PATH сессии; settings.json зовёт другой путь. Чинится `install.sh`; если и после него WARN — приложить строку к находке, это дефект пакета. **Самотест хуков** — любое `WARN … (ожидалось N)` означает, что хук установлен, но не делает того, что должен. Причины по убыванию частоты: правлен руками; `python3` не в PATH сессии; settings.json зовёт другой путь. Чинится `install.sh`; если и после него WARN — приложить строку к находке, это дефект пакета.
**Корпус хуков** — `ok корпус хуков: N/N`: установленные хуки дают на реальных командах пилота те же вердикты, что размечены. `WARN корпус хуков: N/M` и строки над ним — расхождения: `id`, класс, ожидалось, получено, команда. После `install.sh` расхождение — регрессия пакета; разобрать одну команду: `python3 ~/.claude/hooks/harness_target.py targets '<команда>' <cwd>`. `MISS корпус хуков` — нет `harness_target.py` или раннера, чинится `install.sh`.
**Проект** **Проект**
- `MISS CLAUDE.md`/`openspec/`/`.codegraph/` или `WARN без секции` → обвязка не подключена: `bash ~/.claude/harness/adopt-project.sh <root>` (сам делает ветку и ff-merge; дерево должно быть чистым). - `MISS CLAUDE.md`/`openspec/`/`.codegraph/` или `WARN без секции` → обвязка не подключена: `bash ~/.claude/harness/adopt-project.sh <root>` (сам делает ветку и ff-merge; дерево должно быть чистым).
- «worktree; основной: …» + «MCP смотрит на индекс основного checkout» → в вызовах `codegraph_explore` передавать `projectPath=<root worktree>`; если индекса в worktree нет — `codegraph init` там. - «worktree; основной: …» + «MCP смотрит на индекс основного checkout» → в вызовах `codegraph_explore` передавать `projectPath=<root worktree>`; если индекса в worktree нет — `codegraph init` там.

File diff suppressed because one or more lines are too long

View File

@ -22,6 +22,7 @@ for h in guard-main require-plan harness-check role-hint; do
grep -q "$h.sh" "$CC/settings.json" 2>/dev/null && ok "hook $h.sh установлен и зарегистрирован" || warn "hook $h.sh есть, но не зарегистрирован в settings.json" grep -q "$h.sh" "$CC/settings.json" 2>/dev/null && ok "hook $h.sh установлен и зарегистрирован" || warn "hook $h.sh есть, но не зарегистрирован в settings.json"
else miss "hook $h.sh"; fi else miss "hook $h.sh"; fi
done done
[ -f "$CC/hooks/harness_target.py" ] && ok "hooks/harness_target.py (ядро guard-main и require-plan)" || miss "hooks/harness_target.py (bash install.sh)"
[ -x "$CC/harness/adopt-project.sh" ] && ok "~/.claude/harness/ (adopt/new-project)" || miss "~/.claude/harness/adopt-project.sh" [ -x "$CC/harness/adopt-project.sh" ] && ok "~/.claude/harness/ (adopt/new-project)" || miss "~/.claude/harness/adopt-project.sh"
[ -x "$CC/harness/link-roles.sh" ] && ok "~/.claude/harness/link-roles.sh" || miss "~/.claude/harness/link-roles.sh" [ -x "$CC/harness/link-roles.sh" ] && ok "~/.claude/harness/link-roles.sh" || miss "~/.claude/harness/link-roles.sh"
rmiss=(); for r in dev admin analyst qa; do [ -f "$CC/roles/$r.md" ] || rmiss+=("$r"); done rmiss=(); for r in dev admin analyst qa; do [ -f "$CC/roles/$r.md" ] || rmiss+=("$r"); done
@ -43,6 +44,9 @@ if [ -x "$CC/hooks/guard-main.sh" ]; then
hk guard-main "{\"cwd\":\"$T\",\"tool_name\":\"Write\",\"tool_input\":{\"file_path\":\"$T/x\"}}" 2 "Write на main" hk guard-main "{\"cwd\":\"$T\",\"tool_name\":\"Write\",\"tool_input\":{\"file_path\":\"$T/x\"}}" 2 "Write на main"
hk guard-main "{\"cwd\":\"$T\",\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"echo hi > x\"}}" 2 "Bash echo > на main" hk guard-main "{\"cwd\":\"$T\",\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"echo hi > x\"}}" 2 "Bash echo > на main"
hk guard-main "{\"cwd\":\"$T\",\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"git worktree add ../w -b f\"}}" 0 "git worktree на main" hk guard-main "{\"cwd\":\"$T\",\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"git worktree add ../w -b f\"}}" 0 "git worktree на main"
mkdir -p "$T/openspec"; echo a > "$T/openspec/x.md"; git -C "$T" add openspec 2>/dev/null
hk guard-main "{\"cwd\":\"$T\",\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"git commit -m archive\"}}" 2 "git commit openspec-only на main"
git -C "$T" reset -q 2>/dev/null
git -C "$T" switch -q -c feat/1-x 2>/dev/null git -C "$T" switch -q -c feat/1-x 2>/dev/null
hk guard-main "{\"cwd\":\"$T\",\"tool_name\":\"Write\",\"tool_input\":{\"file_path\":\"$T/x\"}}" 0 "Write на ветке" hk guard-main "{\"cwd\":\"$T\",\"tool_name\":\"Write\",\"tool_input\":{\"file_path\":\"$T/x\"}}" 0 "Write на ветке"
fi fi
@ -58,6 +62,12 @@ if [ -x "$CC/hooks/role-hint.sh" ]; then
[ -z "$o" ] && ok "role-hint: без заголовка → тишина" || warn "role-hint: без заголовка → '$o' (ожидалась тишина)" [ -z "$o" ] && ok "role-hint: без заголовка → тишина" || warn "role-hint: без заголовка → '$o' (ожидалась тишина)"
fi fi
rm -rf "$T" rm -rf "$T"
HC="$CC/skills/harness-doctor/scripts/hook_corpus.py"
if [ -f "$HC" ] && [ -f "$CC/hooks/harness_target.py" ]; then
out=$(python3 "$HC" "$CC/hooks" --summary 2>&1); rc=$?
printf '%s\n' "$out" | grep '^MISMATCH' | sed 's/^MISMATCH / /'
[ "$rc" = 0 ] && ok "$(printf '%s\n' "$out" | tail -1)" || warn "$(printf '%s\n' "$out" | tail -1) — расхождения выше"
else miss "корпус хуков (skills/harness-doctor/scripts/hook_corpus.py, hooks/harness_target.py)"; fi
echo "## Проект: $(cd "$DIR" 2>/dev/null && pwd)" echo "## Проект: $(cd "$DIR" 2>/dev/null && pwd)"
if root=$(git -C "$DIR" rev-parse --show-toplevel 2>/dev/null); then if root=$(git -C "$DIR" rev-parse --show-toplevel 2>/dev/null); then

View File

@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Регрессионный корпус хуков (B2): прогон fixtures/hook-cases.jsonl через guard-main и require-plan в песочнице.
Запуск: python3 hook_corpus.py <каталог хуков> [--summary] [--fixtures <jsonl>]
Песочница строится во временном каталоге по раскладке из первой строки корпуса; префикс домашнего каталога записей
заменяется на её home, HOME хуков подменяется туда же. Bash идёт через guard-main, Edit/Write — через guard-main и
require-plan (блок, если блокирует любой).
Вывод: строка на запись «id<TAB>why<TAB>ожидалось<TAB>получено<TAB>команда» и итог «total N/M»;
с --summary — только расхождения и «корпус хуков: N/M». Код выхода 0 — всё совпало, 1 — нет.
"""
import json
import os
import shutil
import subprocess
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
def _git(*args):
subprocess.run(["git", "-c", "user.name=corpus", "-c", "user.email=corpus@example.invalid"] + list(args),
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def build(home, layout):
for e in layout:
p = os.path.join(home, e["path"])
if "worktree_of" in e:
_git("-C", os.path.join(home, e["worktree_of"]), "worktree", "add", "-q", p, "-b", e["branch"])
elif "branch" in e:
os.makedirs(p, exist_ok=True)
_git("init", "-q", p)
_git("-C", p, "symbolic-ref", "HEAD", "refs/heads/" + e["branch"])
_git("-C", p, "commit", "-q", "--allow-empty", "-m", "init")
else:
os.makedirs(p, exist_ok=True)
def verdict(hooks, case, prefix, home, env):
swap = lambda s: s.replace(prefix, home) if isinstance(s, str) else s
tool = case["tool"]
ti = {"command": swap(case.get("command"))} if tool == "Bash" else {"file_path": swap(case.get("file_path"))}
payload = json.dumps({"cwd": swap(case.get("cwd")), "tool_name": tool, "tool_input": ti})
for name in ["guard-main"] + ([] if tool == "Bash" else ["require-plan"]):
r = subprocess.run(["bash", os.path.join(hooks, name + ".sh")], input=payload, universal_newlines=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, timeout=30)
if r.returncode == 2:
return "block"
if r.returncode != 0:
return "error"
return "allow"
def main(argv):
args = argv[1:]
summary = "--summary" in args
fx = os.path.join(HERE, "..", "fixtures", "hook-cases.jsonl")
if "--fixtures" in args:
fx = args[args.index("--fixtures") + 1]
pos = [a for k, a in enumerate(args) if not a.startswith("--") and (k == 0 or args[k - 1] != "--fixtures")]
if not pos:
print(__doc__.strip().splitlines()[2])
return 1
hooks = os.path.abspath(pos[0])
lines = [json.loads(l) for l in open(fx, encoding="utf-8") if l.strip()]
head, cases = lines[0], lines[1:]
tmp = tempfile.mkdtemp(prefix="hook-corpus-")
try:
home = os.path.join(tmp, "home")
try:
build(home, head["layout"])
except Exception as e:
print("корпус хуков: песочница не собрана (%s)" % e)
return 1
env = {k: v for k, v in os.environ.items() if k not in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE")}
env["HOME"] = home
ok = 0
for c in cases:
got = verdict(hooks, c, head["home"], home, env)
ok += got == c["expected"]
body = (c.get("command") or c.get("file_path") or "").replace(head["home"], "~")
row = "\t".join([c["id"], c["why"], c["expected"], got, " ".join(body.split())[:70]])
if not summary:
print(row)
elif got != c["expected"]:
print("MISMATCH " + row)
print(("корпус хуков: %d/%d" if summary else "total %d/%d") % (ok, len(cases)))
return 0 if ok == len(cases) else 1
finally:
shutil.rmtree(tmp, ignore_errors=True)
if __name__ == "__main__":
sys.exit(main(sys.argv))

View File

@ -1,7 +1,8 @@
# doctor.sh: новые проверки — файлы ролей, самотест role-hint, режим workspace, размеры. # doctor.sh: новые проверки — файлы ролей, самотест role-hint, режим workspace, размеры.
D="$ROOT/skills/harness-doctor/scripts/doctor.sh" D="$ROOT/skills/harness-doctor/scripts/doctor.sh"
CC="$TMP/cc"; mkdir -p "$CC/hooks" "$CC/roles" "$CC/skills/role" "$CC/agents" "$CC/harness" "$CC/projects" CC="$TMP/cc"; mkdir -p "$CC/hooks" "$CC/roles" "$CC/skills/role" "$CC/agents" "$CC/harness" "$CC/projects"
cp "$ROOT"/hooks/*.sh "$CC/hooks/"; chmod +x "$CC/hooks/"*.sh cp "$ROOT"/hooks/*.sh "$ROOT/hooks/harness_target.py" "$CC/hooks/"; chmod +x "$CC/hooks/"*.sh
mkdir -p "$CC/skills"; cp -R "$ROOT/skills/harness-doctor" "$CC/skills/"
cp "$ROOT"/roles/*.md "$CC/roles/"; cp "$ROOT/skills/role/SKILL.md" "$CC/skills/role/"; cp "$ROOT/agents/briefing.md" "$CC/agents/" cp "$ROOT"/roles/*.md "$CC/roles/"; cp "$ROOT/skills/role/SKILL.md" "$CC/skills/role/"; cp "$ROOT/agents/briefing.md" "$CC/agents/"
cp "$ROOT/link-roles.sh" "$ROOT/adopt-project.sh" "$CC/harness/"; cp "$ROOT/global/CLAUDE.md" "$CC/CLAUDE.md" cp "$ROOT/link-roles.sh" "$ROOT/adopt-project.sh" "$CC/harness/"; cp "$ROOT/global/CLAUDE.md" "$CC/CLAUDE.md"
printf '{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"\\"$HOME/.claude/hooks/role-hint.sh\\""}]}]}}' > "$CC/settings.json" printf '{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"\\"$HOME/.claude/hooks/role-hint.sh\\""}]}]}}' > "$CC/settings.json"
@ -19,6 +20,12 @@ assert_grep "машина: агент briefing" 'ok +agent briefing' <(printf '%
assert_grep "машина: link-roles.sh" 'ok +.*link-roles.sh' <(printf '%s' "$out") assert_grep "машина: link-roles.sh" 'ok +.*link-roles.sh' <(printf '%s' "$out")
assert_grep "самотест: role-hint admin" 'ok +role-hint: заголовок admin' <(printf '%s' "$out") assert_grep "самотест: role-hint admin" 'ok +role-hint: заголовок admin' <(printf '%s' "$out")
assert_grep "самотест: role-hint тишина" 'ok +role-hint: без заголовка' <(printf '%s' "$out") assert_grep "самотест: role-hint тишина" 'ok +role-hint: без заголовка' <(printf '%s' "$out")
assert_grep "машина: harness_target.py" 'ok +hooks/harness_target\.py' <(printf '%s' "$out")
assert_grep "самотест: openspec-only коммит на main — блок" 'ok +guard-main: git commit openspec-only на main → exit 2' <(printf '%s' "$out")
assert_grep "самотест: Write на main" 'ok +guard-main: Write на main → exit 2' <(printf '%s' "$out")
assert_grep "самотест: Write на ветке" 'ok +guard-main: Write на ветке → exit 0' <(printf '%s' "$out")
assert_grep "корпус хуков сходится" 'ok +корпус хуков: [0-9]+/[0-9]+' <(printf '%s' "$out")
assert_grep "заголовок раздела проекта (M-8)" '^## Проект: /' <(printf '%s' "$out")
assert_grep "проект: режим workspace" 'workspace' <(printf '%s' "$out") assert_grep "проект: режим workspace" 'workspace' <(printf '%s' "$out")
assert_grep "проект: целая ссылка" 'ok +roles/admin.md' <(printf '%s' "$out") assert_grep "проект: целая ссылка" 'ok +roles/admin.md' <(printf '%s' "$out")
assert_grep "проект: битая ссылка" 'WARN +agents/none.md' <(printf '%s' "$out") assert_grep "проект: битая ссылка" 'WARN +agents/none.md' <(printf '%s' "$out")

10
tests/test-hook-corpus.sh Normal file
View File

@ -0,0 +1,10 @@
# hook_corpus.py: регрессионный корпус хуков (B2) через источники hooks/ — assert на запись.
R="$ROOT/skills/harness-doctor/scripts/hook_corpus.py"
F="$ROOT/skills/harness-doctor/fixtures/hook-cases.jsonl"
assert_file "hook_corpus.py существует" "$R"
assert_file "корпус hook-cases.jsonl" "$F"
assert_eq "корпус совпадает со сборкой из выгрузки" "$(python3 "$ROOT/docs/retro/scripts/hook_cases_build.py" | cmp -s - "$F" && echo same)" "same"
while IFS=$'\t' read -r id why exp got cmd; do
case "$id" in total*) continue ;; esac
assert_eq "корпус $id ($why): $cmd" "$got" "$exp"
done < <(python3 "$R" "$ROOT/hooks")

15
tests/test-hook-export.sh Normal file
View File

@ -0,0 +1,15 @@
# hook_blocks_export.py: выгружаются только ошибки хуков, а не вывод команд со словом BLOCKED.
X="$ROOT/docs/retro/scripts/hook_blocks_export.py"
T="$TMP/transcripts"; mkdir -p "$T"
cat > "$T/s1.jsonl" <<'EOF'
{"type":"assistant","cwd":"/r","gitBranch":"main","timestamp":"t1","message":{"content":[{"type":"tool_use","id":"u1","name":"Bash","input":{"command":"echo x > f"}}]}}
{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"u1","is_error":true,"content":"PreToolUse:Bash hook error: [\"$HOME/.claude/hooks/guard-main.sh\"]: BLOCKED [guard-main]: запись в f — репозиторий /r на main."}]}}
{"type":"assistant","cwd":"/r","gitBranch":"main","timestamp":"t2","message":{"content":[{"type":"tool_use","id":"u2","name":"Bash","input":{"command":"cat hooks/require-plan.sh"}}]}}
{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"u2","content":"echo \"BLOCKED: editing $fpath without a plan. Run superpowers:brainstorming\" >&2"}]}}
{"type":"assistant","cwd":"/r","gitBranch":"main","timestamp":"t3","message":{"content":[{"type":"tool_use","id":"u3","name":"Edit","input":{"file_path":"/r/a.md"}}]}}
{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"u3","is_error":true,"content":[{"type":"text","text":"PreToolUse:Edit hook error: [\"$HOME/.claude/hooks/guard-main.sh\"]: BLOCKED: you are on main. Create an isolated worktree first"}]}]}}
EOF
out=$(python3 "$X" "$T")
assert_eq "две записи: вывод cat с BLOCKED не выгружен" "$(printf '%s\n' "$out" | grep -c .)" "2"
assert_eq "новый префикс → guard-main(Bash)" "$(printf '%s\n' "$out" | sed -n 1p | python3 -c 'import json,sys; r=json.load(sys.stdin); print(r["hook"], r["command"])')" "guard-main(Bash) echo x > f"
assert_eq "старая фраза → guard-main(Edit/Write)" "$(printf '%s\n' "$out" | sed -n 2p | python3 -c 'import json,sys; r=json.load(sys.stdin); print(r["hook"], r["file_path"])')" "guard-main(Edit/Write) /r/a.md"

View File

@ -0,0 +1,88 @@
# guard-main.sh: решение по цели записи (спека hook-guards) в песочнице из настоящих git-репозиториев.
G="$ROOT/hooks/guard-main.sh"
S="$TMP/sb"; mkdir -p "$S/ws" "$HOME/.claude/projects/p/memory"
mk(){ git init -q "$1" && git -C "$1" symbolic-ref HEAD "refs/heads/${2:-main}" && git -C "$1" commit -q --allow-empty -m init; }
mk "$S/m" # репозиторий на main
mk "$S/f"; git -C "$S/f" worktree add -q "$S/wt" -b feat/6-x # основной checkout на main + worktree на ветке
mk "$S/ms" master # на master
mk "$S/d"; git -C "$S/d" checkout -q --detach # detached HEAD
j(){ python3 -c 'import json,sys; k="command" if sys.argv[2]=="Bash" else "file_path"; print(json.dumps({"cwd":sys.argv[1],"tool_name":sys.argv[2],"tool_input":{k:sys.argv[3]}}))' "$@"; }
rc(){ j "$@" | bash "$G" 2>/dev/null; echo $?; }
err(){ j "$@" | bash "$G" 2>&1 >/dev/null; }
# решение по цели, а не по cwd
assert_eq "чтение с 2>/dev/null на main" "$(rc "$S/m" Bash 'grep -rn "npm i\|npm install" . 2>/dev/null | head')" 0
assert_eq "fetch 2>&1 | tail на main" "$(rc "$S/m" Bash 'git fetch origin --prune 2>&1 | tail -3')" 0
assert_eq "Write вне репозитория при cwd на main" "$(rc "$S/m" Write "$HOME/.claude/projects/p/memory/x.md")" 0
assert_eq "Edit в worktree при cwd на main" "$(rc "$S/f" Edit "$S/wt/a.md")" 0
assert_eq "Edit в репозитории на main при cwd в worktree" "$(rc "$S/wt" Edit "$S/m/a.md")" 2
assert_eq "Write на master" "$(rc "$S/ws" Write "$S/ms/a.md")" 2
assert_eq "Write при detached HEAD" "$(rc "$S/ws" Write "$S/d/a.md")" 2
# файловые цели Bash
assert_eq "echo > в main" "$(rc "$S/m" Bash 'echo x > notes.md')" 2
assert_eq "make 2>build.log в main" "$(rc "$S/m" Bash 'make 2>build.log')" 2
assert_eq "sed -i по файлу main" "$(rc "$S/ws" Bash "sed -i 's/a/b/' $S/m/docs/HARNESS.md")" 2
assert_eq "sed -i '' по файлу памяти" "$(rc "$S/m" Bash "sed -i '' 's/a/b/' $HOME/.claude/projects/p/memory/x.md")" 0
assert_eq "docker … sh -c > /tmp" "$(rc "$S/m" Bash "docker compose exec -T db sh -c 'psql -c \"select 1\" > /tmp/x'")" 0
assert_eq "pip install на main" "$(rc "$S/m" Bash 'pip install requests')" 0
assert_eq "npm install на main" "$(rc "$S/m" Bash 'npm install')" 2
assert_eq "цель в переменной" "$(rc "$S/m" Bash 'echo x > "$OUT"')" 0
assert_eq "цель через \$HOME в main" "$(HOME="$S" rc "$S/ws" Bash 'echo x > $HOME/m/f')" 2
# каталог внутри команды
assert_eq "cd в worktree и запись" "$(rc "$S/f" Bash "cd $S/wt && echo x > f")" 0
assert_eq "cd в main и touch" "$(rc "$S/wt" Bash "cd $S/m && touch f")" 2
assert_eq "mkdir -p .wt/ в не-git workspace" "$(rc "$S/ws" Bash 'mkdir -p .wt/6-x')" 0
assert_eq "bash -c с записью в main" "$(rc "$S/m" Bash "bash -c 'echo x > f'")" 2
# git
assert_eq "add && commit в main" "$(rc "$S/m" Bash 'git add -A && git commit -m x')" 2
assert_eq "commit -C worktree при cwd на main" "$(rc "$S/f" Bash "git -C $S/wt commit -m x")" 0
assert_eq "worktree add -C из workspace" "$(rc "$S/ws" Bash "git -C $S/m worktree add ../.wt/x -b feat/x")" 0
assert_eq "pull --ff-only на main" "$(rc "$S/m" Bash 'git pull --ff-only')" 0
assert_eq "stash list на main" "$(rc "$S/m" Bash 'git stash list')" 0
assert_eq "-C в переменной — блок" "$(rc "$S/ws" Bash 'git -C "$REPO" commit -m x')" 2
assert_grep "-C в переменной — подсказка git -C" 'git -C <абсолютный путь>' <(err "$S/ws" Bash 'git -C "$REPO" commit -m x')
assert_eq "switch -c && commit на main" "$(rc "$S/m" Bash 'git switch -q -c chore/archive-x && git add openspec && git commit -m a')" 0
assert_eq "switch -c && touch на main" "$(rc "$S/m" Bash 'git switch -q -c feat/16 && touch .bounded')" 0
assert_eq "switch в переменную && commit — блок" "$(rc "$S/m" Bash 'git switch "$B" && git commit -m x')" 2
assert_eq "switch в переменную && touch — пропуск" "$(rc "$S/m" Bash 'git switch "$B" && touch f')" 0
assert_eq "commit вне репозитория" "$(rc "$S/ws" Bash 'git commit -m x')" 0
# push
assert_eq "push с main" "$(rc "$S/m" Bash 'git push')" 2
assert_eq "push --delete фичи с main" "$(rc "$S/m" Bash 'git push origin --delete feat/6-x')" 0
assert_eq "push фичи с main" "$(rc "$S/m" Bash 'git push origin feat/6-x')" 0
assert_eq "push HEAD:main из worktree" "$(rc "$S/wt" Bash 'git push origin HEAD:main')" 2
assert_eq "push из worktree" "$(rc "$S/wt" Bash 'git push -u origin HEAD')" 0
# без исключения для openspec (решение владельца 1)
mkdir -p "$S/m/openspec"; echo a > "$S/m/openspec/x.md"; git -C "$S/m" add openspec
assert_eq "commit только openspec/ в main" "$(rc "$S/m" Bash 'git commit -m archive')" 2
git -C "$S/m" reset -q
assert_eq "openspec archive в main" "$(rc "$S/m" Bash 'openspec archive x')" 2
# граничные случаи (Review Focus)
ln -s "$S/wt/CLAUDE.md" "$S/ws/CLAUDE.md"; ln -s "$S/m/CLAUDE.md" "$S/ws/MAIN.md"
assert_eq "запись через симлинк в worktree" "$(rc "$S/ws" Write "$S/ws/CLAUDE.md")" 0
assert_eq "запись через симлинк в main" "$(rc "$S/ws" Write "$S/ws/MAIN.md")" 2
assert_eq "пустой cwd и относительный путь — пропуск" "$(rc "" Write "a.md")" 0
assert_eq "запись внутри .git/ — не рабочее дерево" "$(rc "$S/m" Bash 'echo x >> .git/info/exclude')" 0
assert_eq "путь с пробелом в main" "$(rc "$S/m" Bash 'touch "a b.md"')" 2
# финальное ревью: I-1, I-2, M-3, M-4, M-5
assert_eq "checkout main && merge && push из worktree (I-1)" "$(rc "$S/wt" Bash 'git checkout main && git pull && git merge --no-ff feat/6-x && git push')" 2
assert_eq "checkout master && commit (I-1)" "$(rc "$S/wt" Bash 'git checkout master && git commit -am x')" 2
assert_eq "switch - && merge (I-1)" "$(rc "$S/wt" Bash 'git switch - && git merge x')" 2
assert_eq "timeout 60 git push origin main (I-2)" "$(rc "$S/wt" Bash 'timeout 60 git push origin main')" 2
assert_eq "nice -n 5 git commit на main (I-2)" "$(rc "$S/m" Bash 'nice -n 5 git commit -m x')" 2
ln -s "$S/m" "$S/ws/mlink"; ln -s /etc/hosts "$S/m/LINK.md"
assert_eq "rm симлинка в workspace на main-репозиторий (M-3)" "$(rc "$S/ws" Bash "rm $S/ws/mlink")" 0
assert_eq "ln -sfn поверх симлинка в workspace (M-3)" "$(rc "$S/ws" Bash "ln -sfn $S/m $S/ws/mlink")" 0
assert_eq "rm симлинка внутри main, указывающего наружу (M-3)" "$(rc "$S/m" Bash 'rm LINK.md')" 2
assert_grep "путь Edit с [скобками] не режется (M-4)" 'docs/\[x\]\.md' <(err "$S/ws" Write "$S/m/docs/[x].md")
assert_eq "push текущей ветки через подстановку из worktree (M-5)" "$(rc "$S/wt" Bash 'git push -u origin "$(git branch --show-current)"')" 0
assert_eq "push --tags с main (M-5)" "$(rc "$S/m" Bash 'git push --tags')" 0
# сбой разбора и сообщение
assert_eq "не JSON — пропуск" "$(printf 'not json' | bash "$G" 2>/dev/null; echo $?)" 0
assert_eq "незакрытая кавычка — пропуск" "$(rc "$S/m" Bash 'echo "x > f')" 0
mkdir -p "$TMP/nopy"
assert_eq "нет python3 — пропуск" "$(env PATH="$TMP/nopy" /bin/bash "$G" <<< "$(j "$S/m" Write "$S/m/a.md")" 2>/dev/null; echo $?)" 0
m=$(err "$S/ws" Write "$S/m/docs/HARNESS.md")
assert_grep "сообщение: префикс" '^BLOCKED \[guard-main\]:' <(printf '%s' "$m")
assert_grep "сообщение: путь отн. корня" 'docs/HARNESS.md' <(printf '%s' "$m")
assert_grep "сообщение: корень и worktree" "$(basename "$S")/m на main.*worktree" <(printf '%s' "$m")
assert_grep "settings.hooks.json регистрирует guard-main" 'guard-main\.sh' "$ROOT/hooks/settings.hooks.json"

104
tests/test-hook-parse.sh Normal file
View File

@ -0,0 +1,104 @@
# harness_target.py targets: разбор команды Bash на цели записи, без файловой системы (спека hook-guards).
P="$ROOT/hooks/harness_target.py"
assert_file "harness_target.py существует" "$P"
t(){ python3 "$P" targets "$1" /r 2>&1; }
NL=$'\n'
# чтение и дескрипторы
assert_eq "grep с текстом «npm i» — целей нет" "$(t 'grep -rn "npm i\|npm install" docs | head')" ""
assert_eq "2>/dev/null и 2>&1 — не цели" "$(t 'ls x 2>/dev/null; git fetch origin --prune 2>&1 | tail -3')" ""
assert_eq ">&2 и 1>&- — не цели" "$(t 'echo x >&2; echo y 1>&-')" ""
assert_eq "регулярка с > в кавычках" "$(t "python3 -c 're.search(r\"\\d>{1,2}\", s); mkdir'")" ""
assert_eq "комментарий" "$(t 'echo x # > f')" ""
# редиректы
assert_eq "редирект" "$(t 'echo x > notes.md')" "file /r/notes.md >"
assert_eq "дозапись по абсолютному пути" "$(t 'echo x >> /tmp/a.log')" "file /tmp/a.log >>"
assert_eq "stderr в файл" "$(t 'make 2>build.log')" "file /r/build.log 2>"
assert_eq "echo 2 > f — 2 это аргумент" "$(t 'echo 2 > f')" "file /r/f >"
assert_eq "&> в файл" "$(t 'cmd &> out.log')" "file /r/out.log &>"
assert_eq "heredoc: цель — файл, тело не разбирается" "$(t "cat > CLAUDE.md <<'EOF'${NL}a > b${NL}rm -rf x${NL}EOF${NL}echo ok")" "file /r/CLAUDE.md >"
# команды записи
assert_eq "tee -a" "$(t 'echo x | tee -a a.log b.log')" "file /r/a.log tee${NL}file /r/b.log tee"
assert_eq "cp — назначение" "$(t 'cp a b dst/')" "file /r/dst cp"
assert_eq "cp -t" "$(t 'cp -t out a b')" "file /r/out cp"
assert_eq "mv — источник и назначение" "$(t 'mv a b')" "file /r/a mv${NL}file /r/b mv"
assert_eq "rm -rf" "$(t 'rm -rf build')" "file /r/build rm"
assert_eq "chmod — без режима" "$(t 'chmod +x run.sh')" "file /r/run.sh chmod"
assert_eq "mkdir -p .wt/" "$(t 'mkdir -p .wt/6-x')" "file /r/.wt/6-x mkdir"
assert_eq "glob — каталог до метасимвола" "$(t 'rm docs/*.md')" "file /r/docs rm"
assert_eq "sed -i" "$(t "sed -i 's/a/b/' x.md")" "file /r/x.md sed -i"
assert_eq "sed -i '' (BSD)" "$(t "sed -i '' 's/a/b/' x.md")" "file /r/x.md sed -i"
assert_eq "sed -i -e, два файла" "$(t 'sed -i -e s/a/b/ x.md y.md')" "file /r/x.md sed -i${NL}file /r/y.md sed -i"
assert_eq "sed -n — чтение" "$(t 'sed -n 1,5p x.md')" ""
assert_eq "perl -pi -e" "$(t "perl -pi -e 's/a/b/' x")" "file /r/x perl -i"
assert_eq "dd of=" "$(t 'dd if=a of=/r/b.img')" "file /r/b.img dd"
assert_eq "tar -x -C" "$(t 'tar -xzf a.tgz -C out')" "file /r/out tar -x"
assert_eq "tar c — не запись" "$(t 'tar czf a.tgz src')" ""
assert_eq "unzip -d" "$(t 'unzip a.zip -d out')" "file /r/out unzip"
assert_eq "unzip -l — чтение" "$(t 'unzip -l a.zip')" ""
assert_eq "npm install — текущий каталог" "$(t 'npm install')" "file /r npm install"
assert_eq "npm -g install — не репозиторий" "$(t 'npm -g install x')" ""
assert_eq "pip install — не репозиторий" "$(t 'pip install requests')" ""
assert_eq "openspec archive" "$(t 'openspec archive x')" "file /r openspec archive"
assert_eq "openspec validate — чтение" "$(t 'openspec validate x --strict')" ""
assert_eq "docker … sh -c — не локальная запись" "$(t "docker compose exec -T db sh -c 'psql > /tmp/x'")" ""
assert_eq "префиксы sudo/env/присваивания" "$(t 'sudo rm /etc/x; env A=1 touch f; A=1 B=2 touch g')" "file /etc/x rm${NL}file /r/f touch${NL}file /r/g touch"
assert_eq "xargs rm — цели неизвестны" "$(t 'xargs rm < list')" "file - rm"
assert_eq "if/then" "$(t 'if [ -f x ]; then rm x; fi')" "file /r/x rm"
# каталог
assert_eq "cd && запись" "$(t 'cd /w && echo x > f')" "file /w/f >"
assert_eq "cd относительный ;" "$(t 'cd sub; touch f')" "file /r/sub/f touch"
assert_eq "( cd ) не протекает" "$(t '(cd /w; touch a); touch b')" "file /w/a touch${NL}file /r/b touch"
assert_eq "cd в конвейере не протекает" "$(t 'cd /w | cat; touch b')" "file /r/b touch"
assert_eq "pushd/popd" "$(t 'pushd /w && touch a && popd && touch b')" "file /w/a touch${NL}file /r/b touch"
assert_eq "cd - — каталог неизвестен" "$(t 'cd - && touch a')" "file - touch"
assert_eq "bash -c разбирается" "$(t "bash -c 'echo x > f'")" "file /r/f >"
assert_eq "eval разбирается" "$(t 'eval "rm x"')" "file /r/x rm"
# переменные и HOME
assert_eq "цель в переменной" "$(t 'echo x > "$OUT"')" "file - >"
assert_eq "\$HOME раскрывается" "$(t 'echo x > $HOME/f')" "file $HOME/f >"
assert_eq "~ раскрывается" "$(t 'echo x > ~/f')" "file $HOME/f >"
assert_eq "ln в переменную" "$(t 'ln -sfn ../../fixtures "$WT/fixtures"')" "file - ln"
# git
assert_eq "git add && commit" "$(t 'git add -A && git commit -m x')" "git /r add${NL}git /r commit"
assert_eq "git -C" "$(t 'git -C /w commit -m x')" "git /w commit"
assert_eq "git -C в переменной" "$(t 'for r in a b; do git -C "$r" commit -m x; done')" "git - commit"
assert_eq "git --git-dir" "$(t 'git --git-dir=/x/.git commit -m x')" "git - commit"
assert_eq "worktree, pull, fetch, log — не мутации" "$(t 'git worktree add ../w -b f; git pull --ff-only; git fetch; git log')" ""
assert_eq "stash list/show — чтение" "$(t 'git stash list; git stash show')" ""
assert_eq "stash — мутация" "$(t 'git stash')" "git /r stash"
assert_eq "checkout -b — смена ветки" "$(t 'git checkout -b f')" "switch /r f"
assert_eq "checkout -- пути" "$(t 'git checkout -- a.txt')" "git /r checkout --"
assert_eq "checkout main — смена ветки (I-1)" "$(t 'git checkout main && git merge x')" "switch /r main${NL}git /r merge"
assert_eq "checkout ветки фичи — не событие" "$(t 'git checkout feat/x')" ""
assert_eq "switch - — ветка неизвестна (I-1)" "$(t 'git switch - && git merge x')" "switch /r -${NL}git /r merge"
assert_eq "timeout 60 прячет голову (I-2)" "$(t 'timeout 60 git push origin main')" "git /r push>main"
assert_eq "timeout -s KILL 5 (I-2)" "$(t 'timeout -s KILL 5 rm x')" "file /r/x rm"
assert_eq "nice -n 10 (I-2)" "$(t 'nice -n 10 git commit -m x')" "git /r commit"
assert_eq "sudo -u bob (I-2)" "$(t 'sudo -u bob touch f')" "file /r/f touch"
assert_eq "env -C меняет каталог команды (I-2)" "$(t 'env -C /w git commit -m x')" "git /w commit"
assert_eq "xargs -n 1 rm (I-2)" "$(t 'xargs -n 1 rm < list')" "file - rm"
assert_eq "push --tags — не ветка (M-5)" "$(t 'git push --tags')" ""
assert_eq "push текущей ветки через \$(git branch --show-current) (M-5)" "$(t 'git push -u origin "$(git branch --show-current)"')" "git /r push"
assert_eq "push текущей ветки через rev-parse (M-5)" "$(t 'git push origin $(git rev-parse --abbrev-ref HEAD)')" "git /r push"
assert_eq "switch -c && commit" "$(t 'git switch -q -c feat/x && git commit -m y')" "switch /r feat/x${NL}git /r commit"
assert_eq "apply --check — чтение" "$(t 'git apply --check p.diff')" ""
assert_eq "apply — мутация" "$(t 'git apply p.diff')" "git /r apply"
assert_eq "commit с heredoc в подстановке" "$(t "git commit -m \"\$(cat <<'EOF'${NL}fix: it's (done)${NL}EOF${NL})\"")" "git /r commit"
# push
assert_eq "push без refspec" "$(t 'git push')" "git /r push"
assert_eq "push origin HEAD" "$(t 'git push -u origin HEAD')" "git /r push"
assert_eq "push ветки фичи" "$(t 'git push -u origin feat/x')" ""
assert_eq "push --delete ветки фичи" "$(t 'git push origin --delete feat/x')" ""
assert_eq "push HEAD:main" "$(t 'git push origin HEAD:main')" "git /r push>main"
assert_eq "push --delete main" "$(t 'git push origin --delete main')" "git /r push>main"
assert_eq "push --all" "$(t 'git push --all')" "git /r push --all"
assert_eq "push в переменную" "$(t 'git push origin "$BR"')" "git /r push?"
assert_eq "push feat/\$ID — префикс фичи" "$(t 'git push origin feat/$ID-x')" ""
# граничные случаи (Review Focus)
assert_eq "путь с пробелом в кавычках" "$(t 'touch "a b.md"')" "file /r/a b.md touch"
assert_eq "арифметика \$((…)) не ломает разбор" "$(t 'echo $((1+2)) > f')" "file /r/f >"
assert_eq "cd ~/x" "$(t 'cd ~/x && touch f')" "file $HOME/x/f touch"
assert_eq "git -C относительный после cd" "$(t 'cd /w && git -C sub commit -m x')" "git /w/sub commit"
assert_eq "перенос строки обратным слешем" "$(t "git add a \\${NL} b && git commit -m x")" "git /r add${NL}git /r commit"
# сбой разбора
assert_grep "незакрытая кавычка — error" '^error' <(t 'echo "x')

View File

@ -0,0 +1,35 @@
# require-plan.sh: корень, ветка, code-paths, .bounded и планы — из репозитория правимого файла (спека hook-guards).
H="$ROOT/hooks/require-plan.sh"
S="$TMP/rp"; mkdir -p "$S/out"
mk(){ git init -q "$1" && git -C "$1" symbolic-ref HEAD "refs/heads/${2:-main}" && git -C "$1" commit -q --allow-empty -m init; }
mk "$S/a"; touch "$S/a/.bounded" # A: есть .bounded
mk "$S/b" # B: ни плана, ни .bounded
mk "$S/f"; git -C "$S/f" worktree add -q "$S/wt" -b feat/6-x; touch "$S/wt/.bounded"
mk "$S/c"; mkdir -p "$S/c/.claude"; printf 'hooks/ # код\n\nskills/\n' > "$S/c/.claude/code-paths"
mk "$S/p2" feat/6-x; mkdir -p "$S/p2/docs/superpowers/plans"
echo "Ветка: feat/6-x" > "$S/p2/docs/superpowers/plans/2026-10-16-a.md"; echo "другое" > "$S/p2/docs/superpowers/plans/2026-10-11-b.md"
mk "$S/p0" feat/7-y; mkdir -p "$S/p0/docs/superpowers/plans"
echo "Ветка: feat/6-x" > "$S/p0/docs/superpowers/plans/a.md"; echo "другое" > "$S/p0/docs/superpowers/plans/b.md"
mk "$S/p1" feat/7-y; mkdir -p "$S/p1/docs/superpowers/plans"; echo "x" > "$S/p1/docs/superpowers/plans/a.md"
mk "$S/pd"; mkdir -p "$S/pd/docs/superpowers/plans"; echo a > "$S/pd/docs/superpowers/plans/a.md"; echo b > "$S/pd/docs/superpowers/plans/b.md"
git -C "$S/pd" checkout -q --detach
j(){ python3 -c 'import json,sys; print(json.dumps({"cwd":sys.argv[1],"tool_name":"Edit","tool_input":{"file_path":sys.argv[2]}}))' "$@"; }
rc(){ j "$@" | bash "$H" 2>/dev/null; echo $?; }
assert_eq "файл в B без плана при cwd в A с .bounded" "$(rc "$S/a" "$S/b/src/a.py")" 2
assert_eq "файл в worktree с .bounded при cwd на main" "$(rc "$S/f" "$S/wt/src/a.py")" 0
assert_eq "src на глубине без плана" "$(rc "$S/b" "$S/b/api/src/a.py")" 2
assert_eq "имя файла с [скобками] в src без плана (M-4)" "$(rc "$S/b" "$S/b/src/[slug].ts")" 2
assert_eq "не код — пропуск" "$(rc "$S/b" "$S/b/docs/a.md")" 0
assert_eq "вне репозитория — пропуск" "$(rc "$S/b" "$S/out/src/a.py")" 0
assert_eq "code-paths: hooks/ без плана" "$(rc "$S/a" "$S/c/hooks/x.sh")" 2
assert_eq "code-paths: src/ не защищён" "$(rc "$S/c" "$S/c/src/a.py")" 0
assert_eq "два плана, один с веткой" "$(rc "$S/p2" "$S/p2/src/a.py")" 0
assert_eq "два плана без ветки" "$(rc "$S/p0" "$S/p0/src/a.py")" 2
assert_eq "один план — пропуск" "$(rc "$S/p1" "$S/p1/src/a.py")" 0
assert_eq "detached HEAD при планах — пропуск" "$(rc "$S/pd" "$S/pd/src/a.py")" 0
assert_eq "не JSON — пропуск" "$(printf 'not json' | bash "$H" 2>/dev/null; echo $?)" 0
assert_eq "без file_path — пропуск" "$(printf '{"cwd":"%s","tool_input":{}}' "$S/b" | bash "$H" 2>/dev/null; echo $?)" 0
m=$(j "$S/a" "$S/b/src/a.py" | bash "$H" 2>&1 >/dev/null)
assert_grep "сообщение: префикс и путь" '^BLOCKED \[require-plan\]: правка src/a.py' <(printf '%s' "$m")
assert_grep "сообщение: .bounded репозитория файла" "$(basename "$S")/b/\.bounded" <(printf '%s' "$m")
assert_grep "settings.hooks.json регистрирует require-plan" 'require-plan\.sh' "$ROOT/hooks/settings.hooks.json"

View File

@ -8,6 +8,9 @@ assert_file "skills/role/SKILL.md установлен" "$CC/skills/role/SKILL.m
assert_file "agents/briefing.md установлен" "$CC/agents/briefing.md" assert_file "agents/briefing.md установлен" "$CC/agents/briefing.md"
assert_file "hooks/role-hint.sh установлен" "$CC/hooks/role-hint.sh" assert_file "hooks/role-hint.sh установлен" "$CC/hooks/role-hint.sh"
assert_eq "hooks/role-hint.sh исполняемый" "$([ -x "$CC/hooks/role-hint.sh" ] && echo x)" "x" assert_eq "hooks/role-hint.sh исполняемый" "$([ -x "$CC/hooks/role-hint.sh" ] && echo x)" "x"
assert_file "hooks/harness_target.py установлен" "$CC/hooks/harness_target.py"
assert_file "корпус хуков установлен" "$CC/skills/harness-doctor/fixtures/hook-cases.jsonl"
assert_file "раннер корпуса установлен" "$CC/skills/harness-doctor/scripts/hook_corpus.py"
assert_file "harness/link-roles.sh установлен" "$CC/harness/link-roles.sh" assert_file "harness/link-roles.sh установлен" "$CC/harness/link-roles.sh"
assert_file "harness/project-template/workspace" "$CC/harness/project-template/workspace/CLAUDE.md" assert_file "harness/project-template/workspace" "$CC/harness/project-template/workspace/CLAUDE.md"
assert_file "harness/project-template/role-repo" "$CC/harness/project-template/role-repo/ROLE.md" assert_file "harness/project-template/role-repo" "$CC/harness/project-template/role-repo/ROLE.md"
@ -19,6 +22,7 @@ out=$(HOME="$TH" CLAUDE_CONFIG_DIR="$CLAUDE_CONFIG_DIR" bash "$ROOT/install.sh"
assert_grep "--check: roles" 'roles/' <(printf '%s' "$out") assert_grep "--check: roles" 'roles/' <(printf '%s' "$out")
assert_grep "--check: skill role" 'skills/role' <(printf '%s' "$out") assert_grep "--check: skill role" 'skills/role' <(printf '%s' "$out")
assert_grep "--check: agent briefing" 'agents/briefing' <(printf '%s' "$out") assert_grep "--check: agent briefing" 'agents/briefing' <(printf '%s' "$out")
assert_grep "--check: harness_target.py" 'ok +hooks/harness_target\.py' <(printf '%s' "$out")
rm "$CC/agents/briefing.md" rm "$CC/agents/briefing.md"
out=$(HOME="$TH" CLAUDE_CONFIG_DIR="$CLAUDE_CONFIG_DIR" bash "$ROOT/install.sh" --check 2>&1) out=$(HOME="$TH" CLAUDE_CONFIG_DIR="$CLAUDE_CONFIG_DIR" bash "$ROOT/install.sh" --check 2>&1)
assert_grep "--check: MISS briefing" 'MISS +.*briefing' <(printf '%s' "$out") assert_grep "--check: MISS briefing" 'MISS +.*briefing' <(printf '%s' "$out")