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>
16 KiB
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
- PR в main, merge владельцем.
bash install.shна каждой машине — подменяет тела хуков и кладётharness_target.py;settings.jsonне меняется.doctor.sh— самотест и корпус по установленным копиям.- Откат:
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).