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>
169 lines
16 KiB
Markdown
169 lines
16 KiB
Markdown
# 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).
|