andrey.epifantsev 2f96a13861 fix(hooks): финальное ревью — checkout main, префиксы с опциями, ссылки, литеральные пути, push текущей ветки (#6)
I-1, I-2, M-3, M-4, M-5 (переоценены в Important), M-8; спека и design.md синхронизированы (M-7).

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

221 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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).