From 7e4b5cfec9d713c3d1dd8572c67d396b8188ebae Mon Sep 17 00:00:00 2001 From: "andrey.epifantsev" Date: Fri, 16 Oct 2026 01:47:37 +0400 Subject: [PATCH] =?UTF-8?q?docs(spec):=20hooks-write-target=20=E2=80=94=20?= =?UTF-8?q?=D1=81=D0=BC=D0=B5=D0=BD=D0=B0=20=D0=B2=D0=B5=D1=82=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=B2=D0=BD=D1=83=D1=82=D1=80=D0=B8=20=D0=BA=D0=BE=D0=BC?= =?UTF-8?q?=D0=B0=D0=BD=D0=B4=D1=8B,=20=D1=84=D0=B0=D0=BA=D1=82=D1=8B=20?= =?UTF-8?q?=D0=BA=D0=BE=D1=80=D0=BF=D1=83=D1=81=D0=B0=20(O-1)=20(#6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Требование «Смена ветки внутри команды» (c072, поток архива отдельным PR), сценарий BSD sed -i ''. design: 76 настоящих блокировок из 126, c083 — файл памяти, собственный лексер, один hook_corpus.py, фильтр retro_blocks.py. Co-Authored-By: Claude Opus 5.5 --- openspec/changes/hooks-write-target/design.md | 70 +++++++++++++------ .../specs/hook-guards/spec.md | 21 ++++++ 2 files changed, 71 insertions(+), 20 deletions(-) diff --git a/openspec/changes/hooks-write-target/design.md b/openspec/changes/hooks-write-target/design.md index 9bcd53d..ef70fb4 100644 --- a/openspec/changes/hooks-write-target/design.md +++ b/openspec/changes/hooks-write-target/design.md @@ -14,11 +14,17 @@ покрывает. - Сырьё: `docs/retro/fixtures/hook-blocks.jsonl` — 126 записей из 42 сессий (выгрузка 12.10.2026 на рабочей машине, коммит 2549e86). Пути — с другой машины (`/Users/andrey.epifancev/projects/awp-ca/...`), поле `branch` в 108 - записях равно `HEAD` и для воспроизведения непригодно. 6 записей — не блокировки, а `cat` исходников хуков. + записях равно `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` (единственный перехват по делу). +репозитория; 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 @@ -50,19 +56,18 @@ hooks/harness_target.py (stdlib) CLI: guard-main | require-plan — тело хука (stdin JSON → exit 0/2, stderr) targets — отладка: цели в JSON ``` -Отдельный `.py`, а не встроенный heredoc: разбор `shlex` с `cd`, heredoc и рекурсией `sh -c` — 150–200 строк, +Отдельный `.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). +`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` не путались. +- Префикс дескриптора (`2>`, `2>&1`) распознаётся лексером только в начале токена, чтобы `echo 2 > f` и `cmd 2>f` + не путались. - Простые команды разделяются по `;`, `&&`, `||`, `|`, `&` и переводам строк вне кавычек. `( … )` открывает вложенный контекст каталога; `cd` из него наружу не выходит, через `|` — тоже. - Голова команды ищется после присваиваний `VAR=x` и префиксов `sudo`, `env`, `time`, `nohup`, `command`, `xargs`. @@ -71,7 +76,13 @@ hooks/harness_target.py (stdlib) - `sh -c`, `bash -c`, `eval` в голове — аргумент разбирается рекурсивно с текущим каталогом. - Пути: `~`, `$HOME`, `${HOME}` раскрываются по переменной окружения `HOME`; прочие `$`, `` ` ``, `$(` дают `path=None`. У glob берётся каталог до первого метасимвола. -- Таблица глаголов и git-подкоманд — в спеке (требования «Файловые цели», «Git-мутации», «push»). +- Токенизация — собственный лексер, а не `shlex`: `shlex` режет `$(…)` на скобки и не знает heredoc внутри + подстановки (`git commit -m "$(cat <<'EOF' … EOF)"`, c069). Лексер рекурсивно пропускает `$(…)` до парной скобки с + учётом кавычек и heredoc; содержимое подстановки целями не считается. +- `git switch -c|-C `, `git switch `, `git checkout -b|-B ` дают событие смены ветки; `decide` применяет его + к последующим целям того же репозитория (поток «ветка архива → add → commit → push» в одной команде, c072). +- Таблица глаголов и git-подкоманд — в спеке (требования «Редирект», «Команды записи», «Git-мутации», «push», + «Смена ветки»). ### 3. `resolve` и вердикт - Относительный путь склеивается с текущим каталогом разбора; берётся ближайший существующий предок, у него @@ -101,11 +112,14 @@ hooks/harness_target.py (stdlib) ### 6. Корпус и прогон (B2) ``` skills/harness-doctor/ - fixtures/hook-cases.jsonl размеченный корпус; первая строка — {"layout": [...]} - scripts/hook-sandbox.sh строит раскладку в каталоге - scripts/hook-corpus.sh прогон: строка на расхождение, итог «корпус хуков: N/M», exit 0/1 -docs/retro/fixtures/hook-blocks.jsonl сырая выгрузка, не меняется + fixtures/hook-cases.jsonl размеченный корпус; первая строка — {"home": …, "layout": [...]} + scripts/hook_corpus.py [--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`, @@ -117,16 +131,20 @@ docs/retro/fixtures/hook-blocks.jsonl сырая выгрузка, не мен - Прогон: префикс `/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-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` в имени. -- Случаи require-plan — в `tests/test-hook-require-plan.sh`. -- `doctor.sh`: прежние 9 случаев плюс «openspec-only коммит на main → exit 2» и строка корпуса. -- Разовая метрика для PR: корпус через старый и новый хук — блокировок было N, стало M. +- `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` выгружает результат, только если он помечен ошибкой (`is_error`) и начинается с -`PreToolUse:` / содержит `hook error:`; классификация хука — по префиксу `[guard-main]`/`[require-plan]`, затем по -старым фразам. +`hook_blocks_export.py` и `retro_blocks.py` считают результат блокировкой, только если он начинается с +`PreToolUse:<инструмент> hook error` (O-1); классификация хука — по префиксу `[guard-main]`/`[require-plan]`, затем +по старым фразам. Признак `is_error` не используется: в выгрузке его нет, проверить на старых транскриптах нельзя. ## Risks / Trade-offs @@ -166,3 +184,15 @@ docs/retro/fixtures/hook-blocks.jsonl сырая выгрузка, не мен переименование до 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` и корпусом. diff --git a/openspec/changes/hooks-write-target/specs/hook-guards/spec.md b/openspec/changes/hooks-write-target/specs/hook-guards/spec.md index 3a92b6d..0328d9a 100644 --- a/openspec/changes/hooks-write-target/specs/hook-guards/spec.md +++ b/openspec/changes/hooks-write-target/specs/hook-guards/spec.md @@ -68,6 +68,10 @@ guard-main SHALL считать файловыми целями пути-арг - **WHEN** команда `sed -i 's/a/b/' <репозиторий на main>/docs/HARNESS.md` - **THEN** guard-main завершается с кодом 2 +#### Scenario: BSD sed -i '' по файлу памяти +- **WHEN** cwd — репозиторий на main, команда `sed -i '' 's/a/b/' ~/.claude/projects//memory/x.md` +- **THEN** guard-main завершается с кодом 0 + #### Scenario: Запись в контейнере не считается локальной - **WHEN** cwd — репозиторий на main, команда `docker compose exec -T db sh -c 'psql -c "select 1" > /tmp/x'` - **THEN** guard-main завершается с кодом 0 @@ -139,6 +143,23 @@ guard-main SHALL блокировать git-мутации — `commit`, `merge` - **WHEN** cwd — репозиторий на main, команда `git stash list` - **THEN** guard-main завершается с кодом 0 +### Requirement: Смена ветки внутри команды +`git switch -c|-C <ветка>`, `git switch <ветка>` и `git checkout -b|-B <ветка>` SHALL менять ветку репозитория для +последующих целей той же команды. Если имя новой ветки нельзя определить, последующие git-мутации в этом репозитории +MUST блокироваться, а файловые цели — пропускаться. + +#### Scenario: Архивная ветка и коммит в одной команде +- **WHEN** cwd — репозиторий на main, команда `git switch -q -c chore/archive-x && git add openspec && git commit -m a` +- **THEN** guard-main завершается с кодом 0 + +#### Scenario: Смена ветки и запись +- **WHEN** cwd — репозиторий на main, команда `git switch -q -c feat/16 && touch .bounded` +- **THEN** guard-main завершается с кодом 0 + +#### Scenario: Ветка в переменной и коммит +- **WHEN** cwd — репозиторий на main, команда `git switch "$B" && git commit -m x` +- **THEN** guard-main завершается с кодом 2 + ### Requirement: Git-мутация с неопределённым репозиторием блокируется Если для git-мутации нельзя определить репозиторий (`-C` с переменной, `--git-dir`, `--work-tree`, неразрешимый текущий каталог), guard-main SHALL заблокировать вызов и в сообщении предложить `git -C <абсолютный путь>`.