# 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 пропускается; редирект перед `<`, `git switch `, `git checkout -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 [--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` и спорные; корпус — данные, правятся одной строкой. - [Корпус содержит рабочие пути и команды] → секреты заменены при выгрузке (``), репозиторий приватный; корпус ставится только локально в `~/.claude/skills/harness-doctor/`. ## Migration Plan 1. PR в main, merge владельцем. 2. `bash install.sh` на каждой машине — подменяет тела хуков и кладёт `harness_target.py`; `settings.json` не меняется. 3. `doctor.sh` — самотест и корпус по установленным копиям. 4. Откат: `bash install.sh` из предыдущего тега; регистрация хуков та же. ## Decisions made without discussion - 15.10.2026. Основа корпуса — реальная выгрузка (126 записей), а не синтетика по категориям ретро. Обоснование: владелец выбрал синтетику, когда агент ошибочно считал данные недоступными; выгрузка нашлась в комментарии к #6. Сказано владельцу в чате, возражений нет. Цена ошибки: переразметка корпуса. - 15.10.2026. Запись в git-игнорируемый путь репозитория на main блокируется (без `git check-ignore`). Обоснование: меньше правил, нет случаев в корпусе, где это дало бы ложный блок по делу. Цена ошибки: одно условие в `decide`. - 15.10.2026. `NotebookEdit` не покрывается: matcher хуков не меняется. Цена ошибки: строка в `settings.hooks.json`. - 15.10.2026. `git tag`, `git config`, `git remote`, `git gc` не считаются мутациями main. Цена ошибки: строка в списке мутаций. - 15.10.2026. `git checkout ` без `--` не считается мутацией: без файловой системы не отличить ветку от пути, а чистый main-checkout отменять нечего. `git stash list|show` — чтение. Цена ошибки: условие в списке мутаций. - 15.10.2026. Worktree задачи — `../cc-harness.wt/6-guard-main-target` (вне репозитория), так как `.wt/` не в `.gitignore`. Цена ошибки: нет. - 15.10.2026. Имя change — `hooks-write-target`, capability — `hook-guards`, версия — 0.4.0. Цена ошибки: переименование до archive. - 15.10.2026. Владелец велел после спеки сразу переходить к плану, после плана — к реализации, без отдельного ревью спеки и плана. Режим исполнения — по `roles/dev.md` (SDD; до 5 задач — executing-plans). - 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).