andrey.epifantsev 6b7a073f6f chore(openspec): архив hooks-write-target, спека hook-guards в openspec/specs (#6)
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>
2026-10-16 10:16:06 +04:00

24 KiB
Raw Blame History

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