andrey.epifantsev 079d0a44bc docs(spec): change hooks-write-target — хуки решают по цели записи (#6)
proposal, specs/hook-guards (16 требований), design с Decisions made without discussion. B1, B2, решение владельца 1; RT-1, RT-2, RT-6, RT-7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 23:57:07 +04:00

169 lines
16 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` и для воспроизведения непригодно. 6 записей — не блокировки, а `cat` исходников хуков.
Разбивка сырья (запись может попасть в несколько классов): 65 — `N>/dev/null` или `N>&M`; 54 — путь в `~/.claude`;
48 — `cd` внутри команды; 14 — heredoc; 11 — cwd в `.wt/`; 10 — `git -C … worktree`; 10 — Edit/Write вне
репозитория; 8 — `git commit/push`; 3 — `mkdir -p .wt/`; 1 — `sed -i` (единственный перехват по делу).
## Goals / Non-Goals
**Goals:**
- Одна модель решения для обоих хуков: цель записи → репозиторий → ветка.
- Разбор команды проверяется без файловой системы; вердикт — на корпусе реальных команд в песочнице.
- Существующие установки обновляются повторным `install.sh` без перерегистрации хуков.
**Non-Goals:**
- Полный разбор shell: подстановки, переменные, функции, алиасы не вычисляются.
- require-plan для записей через Bash (matcher хука остаётся Edit|Write|MultiEdit).
- Учёт `.gitignore`: запись в игнорируемый путь репозитория на main блокируется так же, как в отслеживаемый.
- allow-правило классификатора для удаления веток (B3), workspace-хук awp-ca (B10).
## Decisions
### 1. Ядро на Python, хуки — обёртки
```
settings.hooks.json (без изменений)
▼ ▼
hooks/guard-main.sh hooks/require-plan.sh
нет python3 → exit 0 то же
exec python3 harness_target.py guard-main | require-plan
▼
hooks/harness_target.py (stdlib)
parse(cmd, cwd) → [Target(kind=file|git, path|None, op)] чистая функция
resolve(path) → (root, branch) | None git от ближайшего существующего предка
decide(hook, input) → allow | block(сообщение)
CLI: guard-main | require-plan — тело хука (stdin JSON → exit 0/2, stderr)
targets — отладка: цели в JSON
```
Отдельный `.py`, а не встроенный heredoc: разбор `shlex` с `cd`, heredoc и рекурсией `sh -c` — 150–200 строк,
внутри bash он нечитаем и не тестируется по частям. Имена `.sh` остаются точками входа, поэтому `settings.hooks.json`,
`doctor.sh` и слияние настроек в `install.sh` не меняются. `install.sh` копирует `harness_target.py` рядом с хуками,
`--check` и `doctor.sh` проверяют его наличие.
Альтернативы: (B) точечно чинить регулярные выражения в bash — дифф мал, но решение остаётся текстовым: `grep "npm i"`,
`cd` внутри команды и cwd оболочки продолжают давать ложные блоки; (C) проверять в Bash только git-мутации — теряется
единственный реальный перехват (`sed -i` в main) и молча проходит распознанная запись редиректом. Выбран A
(владелец, 15.10.2026).
### 2. Разбор команды (`parse`)
- Токенизация — `shlex` в режиме posix с `punctuation_chars`; префикс дескриптора (`2>`, `2>&1`) распознаётся до
токенизации, чтобы `echo 2 > f` и `cmd 2>f` не путались.
- Простые команды разделяются по `;`, `&&`, `||`, `|`, `&` и переводам строк вне кавычек. `( … )` открывает вложенный
контекст каталога; `cd` из него наружу не выходит, через `|` — тоже.
- Голова команды ищется после присваиваний `VAR=x` и префиксов `sudo`, `env`, `time`, `nohup`, `command`, `xargs`.
У `xargs` цели неизвестны (пути приходят из stdin) — файловые цели неразрешимы, git-репозиторий — текущий каталог.
- Тело heredoc пропускается; редирект перед `<<EOF` — цель.
- `sh -c`, `bash -c`, `eval` в голове — аргумент разбирается рекурсивно с текущим каталогом.
- Пути: `~`, `$HOME`, `${HOME}` раскрываются по переменной окружения `HOME`; прочие `$`, `` ` ``, `$(` дают `path=None`.
У glob берётся каталог до первого метасимвола.
- Таблица глаголов и 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 размеченный корпус; первая строка — {"layout": [...]}
scripts/hook-sandbox.sh <dir> строит раскладку в каталоге
scripts/hook-corpus.sh <hooks-dir> прогон: строка на расхождение, итог «корпус хуков: N/M», exit 0/1
docs/retro/fixtures/hook-blocks.jsonl сырая выгрузка, не меняется
```
- Корпус лежит в skill, потому что `doctor.sh` проверяет установленные копии, а `install.sh` уже копирует
`skills/harness-doctor` целиком.
- Запись: поля выгрузки плюс `id` (`c001`…; живые — `l001`…), `expected`, `why` (класс: `devnull`, `read-only`,
`outside-repo`, `worktree`, `git-nonmut`, `push-feature`, `main-write`, `main-git`, `not-a-block`, …). Спорные —
`why` с суффиксом `?`; владелец смотрит список `block` и спорные.
- `layout`: список `{"path": …, "git": "main" | "<ветка>" | null, "worktree_of": …}` — репозитории awp-ca
(`arm-core`, `docs`, `tabular-parser`, `scan-gateway`, `arm-web`) на main, их worktree `.wt/<имя>/<репо>` на ветках,
`cc-skills/claude-harness` на main и его `.wt/*`, каталоги памяти вне git.
- Прогон: префикс `/Users/andrey.epifancev` в `cwd`, `command`, `file_path` заменяется на `$SANDBOX/home`, `HOME`
подменяется туда же. Edit/Write прогоняются через guard-main (и через require-plan, если у записи `hook` —
require-plan); Bash — через guard-main.
- `tests/test-hook-parse.sh` — 30–40 ручных случаев на правила разбора, проверка вывода `targets`.
- `tests/test-hook-corpus.sh` — весь корпус через `hooks/*.sh`, assert на запись с `id` и `why` в имени.
- Случаи require-plan — в `tests/test-hook-require-plan.sh`.
- `doctor.sh`: прежние 9 случаев плюс «openspec-only коммит на main → exit 2» и строка корпуса.
- Разовая метрика для PR: корпус через старый и новый хук — блокировок было N, стало M.
### 7. Экстрактор
`hook_blocks_export.py` выгружает результат, только если он помечен ошибкой (`is_error`) и начинается с
`PreToolUse:` / содержит `hook error:`; классификация хука — по префиксу `[guard-main]`/`[require-plan]`, затем по
старым фразам.
## Risks / Trade-offs
- [Разбор не видит запись внутри скрипта, `python3 -c`, `make`] → файловые записи и так fail-open по решению владельца;
git-мутации из скриптов не ловятся, как и сейчас. Видно в `git status` и на ревью.
- [Время старта python на каждый Bash-вызов, 30–50 мс] → быстрый путь без вызовов git; старый хук вызывал git на
каждом вызове.
- [Чужой `cd` в main-checkout при относительных командах implementer-а] → блок остаётся и он верный: коммит ушёл бы в
main. Правило «пути worktree абсолютные» уже в `roles/dev.md`; уточнение про относительные команды — там же.
- [Разметка корпуса делается агентом] → владелец проверяет список `block` и спорные; корпус — данные, правятся одной
строкой.
- [Корпус содержит рабочие пути и команды] → секреты заменены при выгрузке (`<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).