openspec archive hooks-write-target: change перенесён в openspec/changes/archive/2026-10-16-hooks-write-target/, дельта применена — openspec/specs/hook-guards/spec.md (20 требований), validate --strict проходит. Ссылки на путь спеки в harness_target.py, hook_cases_build.py и CHANGELOG переведены на openspec/specs/hook-guards/spec.md; в CLAUDE.md «Текущая работа» B1+B2 переведены в «Сделано» (PR #12, follow-ups — #13). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
24 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и для воспроизведения непригодно. - 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
- 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). - 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_pathEdit/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 Codecwdпередаёт всегда; пропуск такого входа — deferred (M-1).