claude-harness/docs/superpowers/plans/2026-10-16-hooks-write-target.md
andrey.epifantsev a2170232d9 docs(plan): план hooks-write-target — 5 задач, код из проверенного прототипа (#6)
Сценарий спеки: запись через симлинк, указывающий в main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 01:52:36 +04:00

1604 lines
91 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Хуки решают по цели записи — план реализации
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** guard-main и require-plan решают по цели записи (файл, цели команды Bash, git-мутации) и ветке её репозитория, а не по cwd; исключение openspec-only удалено; у хуков есть регрессионный корпус реальных команд, который гоняют тесты пакета и `doctor.sh`.
**Architecture:** Новое ядро `hooks/harness_target.py` (stdlib): `parse` разбирает команду Bash на цели без файловой системы, `resolve` находит корень и ветку репозитория пути, `decide_guard` / `decide_plan` выносят вердикт. `guard-main.sh` и `require-plan.sh` становятся обёртками, регистрация в `settings.json` не меняется. Корпус — `skills/harness-doctor/fixtures/hook-cases.jsonl` (собирается `docs/retro/scripts/hook_cases_build.py` из сырой выгрузки), прогон в песочнице — `skills/harness-doctor/scripts/hook_corpus.py`.
**Tech Stack:** bash, python3 stdlib (3.8+), git; тесты — `tests/run.sh` (bash, без зависимостей).
**Spec:** `openspec/changes/hooks-write-target/` — `proposal.md`, `specs/hook-guards/spec.md`, `design.md`. Issue #6, ветка `feat/6-guard-main-target`.
**Worktree:** `/Users/andrejepifancev/Projects/cc-harness.wt/6-guard-main-target` — все пути ниже от его корня, все команды — из него.
## Global Constraints
- Хук блокирует только через `exit 2` с сообщением в stderr; любой сбой (нет `python3`, нет `harness_target.py`, невалидный JSON, исключение разбора, сбой git) — `exit 0`.
- Только stdlib python3, совместимость с Python 3.8 (на машине 3.9.6): без `match`, без `X | Y` в аннотациях, без `str.removeprefix`.
- Защищены ветки `main`, `master` и detached HEAD.
- Сообщения блокировки начинаются с `BLOCKED [guard-main]:` или `BLOCKED [require-plan]:` и называют цель, корень репозитория и выход.
- Точки входа — `hooks/guard-main.sh`, `hooks/require-plan.sh`; `hooks/settings.hooks.json` не меняется.
- Исключений для `openspec/` и `openspec archive` нет (решение владельца 1, 11.10.2026).
- Зелёным считается только весь набор `bash tests/run.sh`.
- Коммиты — в ветке `feat/6-guard-main-target`, trailer `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`. Push и PR — после задачи 5.
- Тексты документации и сообщений — по-русски.
## Предусловия окружения
- Сессия в worktree `/Users/andrejepifancev/Projects/cc-harness.wt/6-guard-main-target` на ветке `feat/6-guard-main-target`, дерево чистое (`git status --short` пуст).
- `python3 --version` ≥ 3.8, `git --version` ≥ 2.22 (`branch --show-current`).
- `bash tests/run.sh` на старте зелёный (157 passed).
- Стенд, порты, ключи API не нужны.
## Review Focus
1. Путь с пробелом в кавычках (`touch "a b.md"`) — одна цель с пробелом, а не две. Тест: задача 1 (разбор), задача 2 (блок в main).
2. Запись через симлинк, в том числе висячий, указывающий в main — должна блокироваться по цели ссылки, а не по её каталогу. Тест: задача 2.
3. Пустой `cwd` в JSON хука и относительный `file_path` — пропуск без исключения. Тест: задача 2.
4. `cd ~/…` и относительный `git -C sub` после `cd` — путь считается от нового каталога. Тест: задача 1.
5. Запись внутри `.git/` (`.git/info/exclude`) — это не рабочее дерево ветки, пропуск. Тест: задача 2.
## Файлы
| Файл | Задача | Что |
|---|---|---|
| `hooks/harness_target.py` | 1 | создать: лексер, `parse`, `resolve`, `decide_guard`, `decide_plan`, CLI |
| `tests/test-hook-parse.sh` | 1 | создать |
| `hooks/guard-main.sh` | 2 | заменить телом-обёрткой |
| `tests/test-hook-guard-main.sh` | 2 | создать |
| `hooks/require-plan.sh` | 3 | заменить телом-обёрткой |
| `tests/test-hook-require-plan.sh` | 3 | создать |
| `docs/retro/scripts/hook_cases_build.py` | 4 | создать |
| `skills/harness-doctor/fixtures/hook-cases.jsonl` | 4 | сгенерировать |
| `skills/harness-doctor/scripts/hook_corpus.py` | 4 | создать |
| `tests/test-hook-corpus.sh`, `tests/test-hook-export.sh` | 4 | создать |
| `docs/retro/scripts/hook_blocks_export.py`, `docs/retro/scripts/retro_blocks.py` | 4 | фильтр ошибок хука |
| `install.sh`, `skills/harness-doctor/scripts/doctor.sh`, `skills/harness-doctor/SKILL.md` | 5 | ядро и корпус |
| `tests/test-install.sh`, `tests/test-doctor.sh` | 5 | новые проверки |
| `README.md`, `docs/HARNESS.md`, `CLAUDE.md`, `roles/dev.md`, `CHANGELOG.md` | 5 | документация |
---
### Task 1: Ядро `harness_target.py` и разбор команды
**Files:**
- Create: `hooks/harness_target.py`
- Test: `tests/test-hook-parse.sh`
**Interfaces:**
- Produces:
- `Target(kind, path, op)` — namedtuple; `kind` ∈ `file|git|switch`; `path` — абсолютный путь или `None` (не разрешился); для `switch` `path` — каталог репозитория, `op` — новая ветка или `None`.
- `parse(cmd: str, cwd: str | None) -> list[Target]` — цели в порядке выполнения; бросает `ValueError` на незакрытой кавычке/подстановке.
- `resolve(path: str) -> (root: str, branch: str, real: str) | None`; `branch == ""` — detached HEAD.
- `decide_guard(tool: str, tool_input: dict, cwd: str) -> str | None` — сообщение блокировки guard-main.
- `decide_plan(file_path: str, cwd: str) -> str | None` — сообщение блокировки require-plan.
- CLI: `python3 hooks/harness_target.py targets '<cmd>' [cwd]` → строки `kind path op` (`-` для `None`), при ошибке разбора — `error <текст>` и exit 1; `guard-main` / `require-plan` — JSON вызова из stdin → exit 0/2.
- [ ] **Step 1: Написать тест разбора**
Создать `tests/test-hook-parse.sh`:
```bash
# harness_target.py targets: разбор команды Bash на цели записи, без файловой системы (спека hook-guards).
P="$ROOT/hooks/harness_target.py"
assert_file "harness_target.py существует" "$P"
t(){ python3 "$P" targets "$1" /r 2>&1; }
NL=$'\n'
# чтение и дескрипторы
assert_eq "grep с текстом «npm i» — целей нет" "$(t 'grep -rn "npm i\|npm install" docs | head')" ""
assert_eq "2>/dev/null и 2>&1 — не цели" "$(t 'ls x 2>/dev/null; git fetch origin --prune 2>&1 | tail -3')" ""
assert_eq ">&2 и 1>&- — не цели" "$(t 'echo x >&2; echo y 1>&-')" ""
assert_eq "регулярка с > в кавычках" "$(t "python3 -c 're.search(r\"\\d>{1,2}\", s); mkdir'")" ""
assert_eq "комментарий" "$(t 'echo x # > f')" ""
# редиректы
assert_eq "редирект" "$(t 'echo x > notes.md')" "file /r/notes.md >"
assert_eq "дозапись по абсолютному пути" "$(t 'echo x >> /tmp/a.log')" "file /tmp/a.log >>"
assert_eq "stderr в файл" "$(t 'make 2>build.log')" "file /r/build.log 2>"
assert_eq "echo 2 > f — 2 это аргумент" "$(t 'echo 2 > f')" "file /r/f >"
assert_eq "&> в файл" "$(t 'cmd &> out.log')" "file /r/out.log &>"
assert_eq "heredoc: цель — файл, тело не разбирается" "$(t "cat > CLAUDE.md <<'EOF'${NL}a > b${NL}rm -rf x${NL}EOF${NL}echo ok")" "file /r/CLAUDE.md >"
# команды записи
assert_eq "tee -a" "$(t 'echo x | tee -a a.log b.log')" "file /r/a.log tee${NL}file /r/b.log tee"
assert_eq "cp — назначение" "$(t 'cp a b dst/')" "file /r/dst cp"
assert_eq "cp -t" "$(t 'cp -t out a b')" "file /r/out cp"
assert_eq "mv — источник и назначение" "$(t 'mv a b')" "file /r/a mv${NL}file /r/b mv"
assert_eq "rm -rf" "$(t 'rm -rf build')" "file /r/build rm"
assert_eq "chmod — без режима" "$(t 'chmod +x run.sh')" "file /r/run.sh chmod"
assert_eq "mkdir -p .wt/" "$(t 'mkdir -p .wt/6-x')" "file /r/.wt/6-x mkdir"
assert_eq "glob — каталог до метасимвола" "$(t 'rm docs/*.md')" "file /r/docs rm"
assert_eq "sed -i" "$(t "sed -i 's/a/b/' x.md")" "file /r/x.md sed -i"
assert_eq "sed -i '' (BSD)" "$(t "sed -i '' 's/a/b/' x.md")" "file /r/x.md sed -i"
assert_eq "sed -i -e, два файла" "$(t 'sed -i -e s/a/b/ x.md y.md')" "file /r/x.md sed -i${NL}file /r/y.md sed -i"
assert_eq "sed -n — чтение" "$(t 'sed -n 1,5p x.md')" ""
assert_eq "perl -pi -e" "$(t "perl -pi -e 's/a/b/' x")" "file /r/x perl -i"
assert_eq "dd of=" "$(t 'dd if=a of=/r/b.img')" "file /r/b.img dd"
assert_eq "tar -x -C" "$(t 'tar -xzf a.tgz -C out')" "file /r/out tar -x"
assert_eq "tar c — не запись" "$(t 'tar czf a.tgz src')" ""
assert_eq "unzip -d" "$(t 'unzip a.zip -d out')" "file /r/out unzip"
assert_eq "unzip -l — чтение" "$(t 'unzip -l a.zip')" ""
assert_eq "npm install — текущий каталог" "$(t 'npm install')" "file /r npm install"
assert_eq "npm -g install — не репозиторий" "$(t 'npm -g install x')" ""
assert_eq "pip install — не репозиторий" "$(t 'pip install requests')" ""
assert_eq "openspec archive" "$(t 'openspec archive x')" "file /r openspec archive"
assert_eq "openspec validate — чтение" "$(t 'openspec validate x --strict')" ""
assert_eq "docker … sh -c — не локальная запись" "$(t "docker compose exec -T db sh -c 'psql > /tmp/x'")" ""
assert_eq "префиксы sudo/env/присваивания" "$(t 'sudo rm /etc/x; env A=1 touch f; A=1 B=2 touch g')" "file /etc/x rm${NL}file /r/f touch${NL}file /r/g touch"
assert_eq "xargs rm — цели неизвестны" "$(t 'xargs rm < list')" "file - rm"
assert_eq "if/then" "$(t 'if [ -f x ]; then rm x; fi')" "file /r/x rm"
# каталог
assert_eq "cd && запись" "$(t 'cd /w && echo x > f')" "file /w/f >"
assert_eq "cd относительный ;" "$(t 'cd sub; touch f')" "file /r/sub/f touch"
assert_eq "( cd ) не протекает" "$(t '(cd /w; touch a); touch b')" "file /w/a touch${NL}file /r/b touch"
assert_eq "cd в конвейере не протекает" "$(t 'cd /w | cat; touch b')" "file /r/b touch"
assert_eq "pushd/popd" "$(t 'pushd /w && touch a && popd && touch b')" "file /w/a touch${NL}file /r/b touch"
assert_eq "cd - — каталог неизвестен" "$(t 'cd - && touch a')" "file - touch"
assert_eq "bash -c разбирается" "$(t "bash -c 'echo x > f'")" "file /r/f >"
assert_eq "eval разбирается" "$(t 'eval "rm x"')" "file /r/x rm"
# переменные и HOME
assert_eq "цель в переменной" "$(t 'echo x > "$OUT"')" "file - >"
assert_eq "\$HOME раскрывается" "$(t 'echo x > $HOME/f')" "file $HOME/f >"
assert_eq "~ раскрывается" "$(t 'echo x > ~/f')" "file $HOME/f >"
assert_eq "ln в переменную" "$(t 'ln -sfn ../../fixtures "$WT/fixtures"')" "file - ln"
# git
assert_eq "git add && commit" "$(t 'git add -A && git commit -m x')" "git /r add${NL}git /r commit"
assert_eq "git -C" "$(t 'git -C /w commit -m x')" "git /w commit"
assert_eq "git -C в переменной" "$(t 'for r in a b; do git -C "$r" commit -m x; done')" "git - commit"
assert_eq "git --git-dir" "$(t 'git --git-dir=/x/.git commit -m x')" "git - commit"
assert_eq "worktree, pull, fetch, log — не мутации" "$(t 'git worktree add ../w -b f; git pull --ff-only; git fetch; git log')" ""
assert_eq "stash list/show — чтение" "$(t 'git stash list; git stash show')" ""
assert_eq "stash — мутация" "$(t 'git stash')" "git /r stash"
assert_eq "checkout -b — смена ветки" "$(t 'git checkout -b f')" "switch /r f"
assert_eq "checkout -- пути" "$(t 'git checkout -- a.txt')" "git /r checkout --"
assert_eq "checkout ветки — не мутация" "$(t 'git checkout main')" ""
assert_eq "switch -c && commit" "$(t 'git switch -q -c feat/x && git commit -m y')" "switch /r feat/x${NL}git /r commit"
assert_eq "apply --check — чтение" "$(t 'git apply --check p.diff')" ""
assert_eq "apply — мутация" "$(t 'git apply p.diff')" "git /r apply"
assert_eq "commit с heredoc в подстановке" "$(t "git commit -m \"\$(cat <<'EOF'${NL}fix: it's (done)${NL}EOF${NL})\"")" "git /r commit"
# push
assert_eq "push без refspec" "$(t 'git push')" "git /r push"
assert_eq "push origin HEAD" "$(t 'git push -u origin HEAD')" "git /r push"
assert_eq "push ветки фичи" "$(t 'git push -u origin feat/x')" ""
assert_eq "push --delete ветки фичи" "$(t 'git push origin --delete feat/x')" ""
assert_eq "push HEAD:main" "$(t 'git push origin HEAD:main')" "git /r push>main"
assert_eq "push --delete main" "$(t 'git push origin --delete main')" "git /r push>main"
assert_eq "push --all" "$(t 'git push --all')" "git /r push --all"
assert_eq "push в переменную" "$(t 'git push origin "$BR"')" "git /r push?"
assert_eq "push feat/\$ID — префикс фичи" "$(t 'git push origin feat/$ID-x')" ""
# граничные случаи (Review Focus)
assert_eq "путь с пробелом в кавычках" "$(t 'touch "a b.md"')" "file /r/a b.md touch"
assert_eq "арифметика \$((…)) не ломает разбор" "$(t 'echo $((1+2)) > f')" "file /r/f >"
assert_eq "cd ~/x" "$(t 'cd ~/x && touch f')" "file $HOME/x/f touch"
assert_eq "git -C относительный после cd" "$(t 'cd /w && git -C sub commit -m x')" "git /w/sub commit"
assert_eq "перенос строки обратным слешем" "$(t "git add a \\${NL} b && git commit -m x")" "git /r add${NL}git /r commit"
# сбой разбора
assert_grep "незакрытая кавычка — error" '^error' <(t 'echo "x')
```
- [ ] **Step 2: Убедиться, что тест падает**
Run: `bash tests/run.sh hook-parse`
Expected: `FAIL harness_target.py существует` и остальные FAIL (файла нет).
- [ ] **Step 3: Создать ядро**
Создать `hooks/harness_target.py`:
```python
#!/usr/bin/env python3
"""claude-harness: общее ядро хуков guard-main и require-plan — решение по цели записи, а не по cwd.
Обёртки hooks/guard-main.sh и hooks/require-plan.sh передают сюда JSON вызова из stdin:
python3 harness_target.py guard-main|require-plan < hook.json → exit 0 (пропустить) | 2 (блок, причина в stderr)
Отладка разбора без файловой системы:
python3 harness_target.py targets '<команда>' [cwd] → по строке на цель: «kind path op»
Любой сбой разбора или вызова git — пропуск (exit 0). Только stdlib, Python 3.8+.
Спека: openspec/changes/hooks-write-target/specs/hook-guards/spec.md.
"""
import glob
import json
import os
import re
import subprocess
import sys
from collections import namedtuple
# kind: file | git | switch; path: абсолютный путь или None (не разрешился); op: что делает команда.
# Для switch path — каталог репозитория, op — новая ветка (None — не определена).
Target = namedtuple("Target", "kind path op")
PROTECTED = ("main", "master", "") # "" — detached HEAD
SEPS = (";", "&&", "||", "|", "|&", "&", ";;", "\n", "(", ")")
OPS = sorted(["&&", "||", ";;", ";", "|&", "|", "&>>", "&>", "&", "(", ")", ">>", ">|", ">&", ">",
"<<<", "<<-", "<<", "<&", "<>", "<"], key=len, reverse=True)
OP_CHARS = set(";&|()<>")
WRITE_REDIRS = (">", ">>", ">|", "&>", "&>>")
KEYWORDS = ("if", "then", "else", "elif", "fi", "do", "done", "while", "until", "!", "{", "}")
SKIP_HEADS = ("for", "case", "select", "function")
PREFIXES = ("sudo", "env", "nohup", "command", "exec", "nice", "time", "builtin", "xargs")
ALL_PATHS = ("tee", "touch", "mkdir", "rm", "rmdir", "truncate", "unlink", "mv", "shred")
DEST = ("cp", "install", "ln", "rsync")
GIT_MUT = ("commit", "merge", "rebase", "cherry-pick", "revert", "reset", "am", "add", "rm", "mv", "restore")
ASSIGN = re.compile(r"[A-Za-z_][A-Za-z0-9_]*(\[[^]]*\])?\+?=")
FD = re.compile(r"\d+(?=[<>])")
VAR = re.compile(r"[A-Za-z_][A-Za-z0-9_]*|[0-9?$!#*@-]")
def _home():
return os.environ.get("HOME", "")
# ---------- лексер ----------
class Lexer:
"""Токены команды: ("w", текст, динамическое?) и ("op", оператор). Тело heredoc пропускается.
Динамическое слово содержит $VAR, $(…) или `…` — в тексте остаётся сырой вид подстановки.
~, $HOME и ${HOME} раскрываются по переменной окружения HOME.
"""
def __init__(self, s, i=0):
self.s, self.i, self.heredocs = s, i, []
def tokens(self, until_paren=False):
s, out, depth = self.s, [], 0
while self.i < len(s):
c = s[self.i]
if c in " \t":
self.i += 1
elif s.startswith("\\\n", self.i):
self.i += 2
elif c == "\n":
self.i += 1
out.append(("op", "\n"))
self._skip_heredocs()
elif c == "#":
j = s.find("\n", self.i)
self.i = len(s) if j < 0 else j
elif FD.match(s, self.i):
m = FD.match(s, self.i)
self.i = m.end()
op = self._op()
out.append(("op", m.group() + op))
elif c in OP_CHARS:
op = self._op()
if until_paren and op == "(":
depth += 1
elif until_paren and op == ")":
if depth == 0:
return out
depth -= 1
out.append(("op", op))
if op in ("<<", "<<-"):
w = self._word()
if w is not None:
self.heredocs.append((w[0], op == "<<-"))
out.append(("w",) + w)
else:
out.append(("w",) + self._word())
if until_paren:
raise ValueError("unclosed $(")
return out
def _op(self):
op = next(o for o in OPS if self.s.startswith(o, self.i))
self.i += len(op)
return op
def _skip_heredocs(self):
s = self.s
for delim, strip_tabs in self.heredocs:
while self.i < len(s):
j = s.find("\n", self.i)
line = s[self.i:len(s) if j < 0 else j]
self.i = len(s) if j < 0 else j + 1
if (line.lstrip("\t") if strip_tabs else line) == delim:
break
self.heredocs = []
def _word(self):
s, n = self.s, len(self.s)
while self.i < n and s[self.i] in " \t":
self.i += 1
start, buf, dyn = self.i, [], False
while self.i < n:
c = s[self.i]
if c in " \t\n" or c in OP_CHARS:
break
if c == "\\":
if self.i + 1 < n and s[self.i + 1] != "\n":
buf.append(s[self.i + 1])
self.i += 2
elif c == "'":
j = s.find("'", self.i + 1)
if j < 0:
raise ValueError("unclosed '")
buf.append(s[self.i + 1:j])
self.i = j + 1
elif c == '"':
self.i += 1
while True:
if self.i >= n:
raise ValueError('unclosed "')
c = s[self.i]
if c == '"':
self.i += 1
break
if c == "\\" and self.i + 1 < n and s[self.i + 1] in '"\\$`\n':
if s[self.i + 1] != "\n":
buf.append(s[self.i + 1])
self.i += 2
elif c in "$`":
t, d = self._dollar()
buf.append(t)
dyn = dyn or d
else:
buf.append(c)
self.i += 1
elif c in "$`":
t, d = self._dollar()
buf.append(t)
dyn = dyn or d
elif c == "~" and self.i == start and (self.i + 1 >= n or s[self.i + 1] in "/ \t\n;&|()<>"):
buf.append(_home())
self.i += 1
else:
buf.append(c)
self.i += 1
if self.i == start:
return None
return "".join(buf), dyn
def _dollar(self):
"""$…/`…` с позиции i → (текст, динамическое?)."""
s, i = self.s, self.i
if s[i] == "`":
j = i + 1
while j < len(s) and s[j] != "`":
j += 2 if s[j] == "\\" else 1
if j >= len(s):
raise ValueError("unclosed `")
self.i = j + 1
return s[i:j + 1], True
if s.startswith("$(", i): # подстановка и арифметика $((…)): пропуск до парной скобки
sub = Lexer(s, i + 2)
sub.tokens(until_paren=True)
self.i = sub.i
return s[i:self.i], True
if s.startswith("${", i):
j = s.find("}", i)
if j < 0:
raise ValueError("unclosed ${")
self.i = j + 1
return (_home(), False) if s[i + 2:j] == "HOME" else (s[i:j + 1], True)
m = VAR.match(s, i + 1)
if not m:
self.i = i + 1
return "$", False
self.i = m.end()
return (_home(), False) if m.group() == "HOME" else (s[i:m.end()], True)
# ---------- разбор ----------
def _join(cur, p):
"""Абсолютный нормализованный путь; у glob — каталог до первого метасимвола; None, если не определить."""
if p is None:
return None
m = re.search(r"[*?\[]", p)
if m:
p = os.path.dirname(p[:m.start()]) or "."
if os.path.isabs(p):
return os.path.normpath(p)
if not cur:
return None
return os.path.normpath(os.path.join(cur, p))
def _path(w, cur):
text, dyn = w
return None if dyn else _join(cur, text)
def _operands(args, valued=()):
"""Аргументы-операнды без опций; опции из valued забирают следующее слово как значение."""
out, k, opts_done = [], 0, False
while k < len(args):
t = args[k][0]
if not opts_done and t == "--":
opts_done = True
elif not opts_done and t.startswith("-") and t != "-":
if t in valued:
k += 1
else:
out.append(args[k])
k += 1
return out
def _branch(w):
"""Имя ветки из слова. Для динамического — '<статический префикс>*', если префикс не может стать
защищённой веткой, иначе None (не определена)."""
text, dyn = w
if not dyn:
return text
cut = min([i for i in (text.find("$"), text.find("`")) if i >= 0] or [len(text)])
prefix = text[:cut]
if prefix and not any(p.startswith(prefix) for p in ("main", "master", "HEAD", "refs/heads/main",
"refs/heads/master")):
return prefix + "*"
return None
class _State:
def __init__(self, cwd, out, depth):
self.cur, self.out, self.depth, self.dirs = cwd, out, depth, []
def parse(cmd, cwd, _out=None, _depth=0):
"""Цели записи команды Bash в порядке выполнения. Чистая функция: файловую систему не читает.
cwd — каталог, от которого считаются относительные пути (None — неизвестен)."""
out = [] if _out is None else _out
if _depth > 5:
return out
st = _State(cwd or None, out, _depth)
toks = Lexer(cmd).tokens()
stack, words, redirs, prev = [], [], [], None
i = 0
while i <= len(toks):
t = toks[i] if i < len(toks) else ("op", "\n")
if t[0] == "w":
words.append((t[1], t[2]))
i += 1
continue
op = t[1]
if op in SEPS:
if words or redirs:
new = _simple(words, redirs, st)
if new is not _SAME and op not in ("|", "|&") and prev not in ("|", "|&"):
st.cur = new
words, redirs = [], []
if op == "(":
stack.append(st.cur)
elif op == ")" and stack:
st.cur = stack.pop()
prev = op
i += 1
continue
nxt = toks[i + 1] if i + 1 < len(toks) and toks[i + 1][0] == "w" else None
redirs.append((op, (nxt[1], nxt[2]) if nxt else None))
i += 2 if nxt else 1
return out
_SAME = object()
def _simple(words, redirs, st):
"""Одна простая команда: цели в st.out; возвращает новый каталог для cd/pushd/popd или _SAME."""
cur, out = st.cur, st.out
for op, w in redirs:
o = op.lstrip("0123456789")
if w is None:
continue
if o in WRITE_REDIRS or (o == ">&" and not re.fullmatch(r"\d+-?|-", w[0])):
if not w[1] and w[0].startswith("/dev/"):
continue
out.append(Target("file", _path(w, cur), op))
ws = list(words)
while ws and ws[0][0] in KEYWORDS and not ws[0][1]:
ws.pop(0)
if not ws or ws[0][0] in SKIP_HEADS:
return _SAME
xargs = False
while ws:
h = ws[0][0]
if ASSIGN.match(h):
ws.pop(0)
elif h in PREFIXES and not ws[0][1]:
xargs = xargs or h == "xargs"
ws.pop(0)
while ws and (ws[0][0].startswith("-") or (h == "env" and ASSIGN.match(ws[0][0]))):
ws.pop(0)
else:
break
if not ws or ws[0][1]:
return _SAME # пусто или голова — переменная: неизвестная команда
head, args = os.path.basename(ws[0][0]), ws[1:]
def file_(w, op):
out.append(Target("file", None if xargs else _path(w, cur), op))
if head in ("cd", "pushd"):
a = _operands(args)
if not a:
new = _home() or None
else:
new = None if a[0][1] or a[0][0] == "-" else _join(cur, a[0][0])
if head == "pushd":
st.dirs.append(cur)
return new
if head == "popd":
return st.dirs.pop() if st.dirs else None
if head in ("sh", "bash", "zsh", "dash"):
for k, (t, d) in enumerate(args):
if t.startswith("-") and not t.startswith("--") and "c" in t[1:]:
if k + 1 < len(args) and not args[k + 1][1]:
parse(args[k + 1][0], cur, out, st.depth + 1)
break
return _SAME
if head == "eval":
parse(" ".join(t for t, d in args), cur, out, st.depth + 1)
return _SAME
if xargs and head in ALL_PATHS + DEST + ("chmod", "chown", "chgrp", "sed", "perl"):
out.append(Target("file", None, head)) # пути придут из stdin
return _SAME
if head in ALL_PATHS:
for w in _operands(args, ("-s", "--size") if head == "truncate" else ()):
file_(w, head)
elif head in ("chmod", "chown", "chgrp"):
for w in _operands(args)[1:]:
file_(w, head)
elif head in DEST:
tdir = next((args[k + 1] for k, (t, d) in enumerate(args[:-1]) if t == "-t"), None)
tdir = tdir or next(((t.split("=", 1)[1], d) for t, d in args if t.startswith("--target-directory=")), None)
ops = _operands(args, ("-t", "-S", "--suffix"))
if tdir:
dst = tdir
elif len(ops) >= 2:
dst = ops[-1]
elif head == "ln" and ops:
dst = (".", False) # ln -s цель — ссылка в текущем каталоге
else:
dst = None
if dst is not None and not (head == "rsync" and re.match(r"[^/]*:", dst[0])):
file_(dst, head)
elif head == "sed":
inplace = any(t == "-i" or t.startswith("-i") or t.startswith("--in-place")
or re.fullmatch(r"-[A-Za-z]*i[A-Za-z]*", t) for t, d in args)
if inplace:
rest = [w for k, w in enumerate(args)
if not (w[0] == "" and k > 0 and args[k - 1][0] == "-i")] # BSD: sed -i '' …
has_script = any(t in ("-e", "-f", "--expression", "--file") or t.startswith("--expression=")
for t, d in rest)
ops = _operands(rest, ("-e", "-f", "-l", "--expression", "--file"))
for w in (ops if has_script else ops[1:]):
file_(w, "sed -i")
elif head == "perl":
if any(re.fullmatch(r"-[A-Za-z]*i\S*", t) for t, d in args):
has_script = any(re.fullmatch(r"-[A-Za-z]*[eE]", t) for t, d in args)
ops = _operands(args, ("-e", "-E", "-pe", "-ne", "-pie", "-npe"))
for w in (ops if has_script else ops[1:]):
file_(w, "perl -i")
elif head == "dd":
for t, d in args:
if t.startswith("of="):
file_((t[3:], d), "dd")
elif head == "tar":
first = args[0][0] if args else ""
if any(t in ("--extract", "--get") or (re.fullmatch(r"-[A-Za-z]+", t) and "x" in t) for t, d in args) \
or (re.fullmatch(r"[A-Za-z]+", first) and "x" in first):
dst = next((args[k + 1] for k, (t, d) in enumerate(args[:-1]) if t == "-C"), None)
dst = dst or next(((t.split("=", 1)[1], d) for t, d in args if t.startswith("--directory=")), None)
file_(dst or (".", False), "tar -x")
elif head == "unzip":
if not any(t in ("-l", "-t", "-v", "-Z", "-p") for t, d in args):
dst = next((args[k + 1] for k, (t, d) in enumerate(args[:-1]) if t == "-d"), None)
file_(dst or (".", False), "unzip")
elif head == "patch":
if "--dry-run" not in [t for t, d in args]:
dst = next((args[k + 1] for k, (t, d) in enumerate(args[:-1]) if t == "-d"), None)
dst = dst or next(((t.split("=", 1)[1], d) for t, d in args if t.startswith("--directory=")), None)
file_(dst or (".", False), "patch")
elif head in ("npm", "yarn", "pnpm"):
if not any(t in ("-g", "--global") for t, d in args):
ops = _operands(args)
sub = ops[0][0] if ops else ("install" if head == "yarn" else "")
if sub in ("install", "i", "ci", "add", "init"):
out.append(Target("file", cur, head + " " + sub))
elif head == "openspec":
ops = _operands(args)
if ops and ops[0][0] in ("archive", "new", "init", "update"):
out.append(Target("file", cur, "openspec " + ops[0][0]))
elif head == "git":
_git(args, st)
return _SAME
def _git(args, st):
repo, k, unknown = st.cur, 0, False
while k < len(args):
t, d = args[k]
if t == "-C" and k + 1 < len(args):
v = args[k + 1]
if v[1]:
unknown = True
else:
repo = _join(repo, v[0])
k += 2
elif t in ("-c", "--namespace", "--exec-path", "--config-env") and k + 1 < len(args):
k += 2
elif t.startswith("--git-dir") or t.startswith("--work-tree"):
unknown = True
k += 1 if "=" in t else 2
elif t.startswith("-"):
k += 1
else:
break
if k >= len(args):
return
sub, rest = args[k][0], args[k + 1:]
path = None if unknown or repo is None else repo
words = [t for t, d in rest]
add = st.out.append
if sub in GIT_MUT:
add(Target("git", path, sub))
elif sub == "apply":
if not any(w in ("--check", "--stat", "--numstat", "--summary") for w in words):
add(Target("git", path, "apply"))
elif sub == "stash":
s2 = next((w for w in words if not w.startswith("-")), "push")
if s2 not in ("list", "show"):
add(Target("git", path, "stash"))
elif sub == "checkout":
flag = next((j for j, w in enumerate(words) if w in ("-b", "-B", "--orphan")), None)
if flag is not None and flag + 1 < len(rest):
add(Target("switch", path, _branch(rest[flag + 1])))
elif "--" in words and words.index("--") < len(words) - 1:
add(Target("git", path, "checkout --"))
elif sub == "switch":
name, found = None, False
for j, w in enumerate(words):
if w in ("-c", "-C", "--create", "--force-create", "--orphan") and j + 1 < len(rest):
name, found = _branch(rest[j + 1]), True
break
if w in ("-d", "--detach"):
name, found = "", True
break
if not found:
ops = _operands(rest)
if ops:
name, found = _branch(ops[0]), True
if found:
add(Target("switch", path, name))
elif sub == "push":
_push(rest, path, add)
def _push(rest, path, add):
delete, everything, pos, k = False, False, [], 0
while k < len(rest):
t = rest[k][0]
if t in ("--delete", "-d"):
delete = True
elif t in ("--all", "--mirror", "--branches"):
everything = True
elif t in ("--repo", "-o", "--push-option", "--receive-pack", "--exec"):
k += 1
elif not t.startswith("-"):
pos.append(rest[k])
k += 1
if everything:
add(Target("git", path, "push --all"))
return
refs = pos[1:]
if not refs:
if not delete:
add(Target("git", path, "push"))
return
for text, dyn in refs:
src, colon, dst = text.lstrip("+").partition(":")
if delete or not colon:
dst = src
if dst.startswith("refs/heads/"):
dst = dst[len("refs/heads/"):]
if dst == "HEAD" and not delete and not colon:
add(Target("git", path, "push"))
continue
name = _branch((dst, dyn))
if name is None:
add(Target("git", path, "push?"))
elif name in ("main", "master"):
add(Target("git", path, "push>" + name))
# ---------- файловая система и вердикт ----------
_cache = {}
def _git_out(d, *args):
env = {k: v for k, v in os.environ.items() if k not in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE")}
r = subprocess.run(["git", "-C", d] + list(args), stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
universal_newlines=True, timeout=5, env=env)
return r.stdout.strip() if r.returncode == 0 else None
def resolve(path):
"""(корень, ветка, реальный путь) git-репозитория, в котором лежит path, или None — вне репозитория.
Ветка '' — detached HEAD. Симлинки раскрываются до поиска (в том числе висячие: запись создаст файл у цели
ссылки), затем берётся ближайший существующий предок."""
real = os.path.realpath(path)
p = real
while not os.path.exists(p):
parent = os.path.dirname(p)
if parent == p:
return None
p = parent
d = p if os.path.isdir(p) else os.path.dirname(p)
if d not in _cache:
root = _git_out(d, "rev-parse", "--show-toplevel")
branch = _git_out(root, "branch", "--show-current") if root else None
_cache[d] = (root, branch or "") if root else None
hit = _cache[d]
if not hit:
return None
return hit[0], hit[1], real
def _label(branch):
return "detached HEAD" if branch == "" else branch
WT_HINT = "Работай в worktree на ветке (superpowers:using-git-worktrees)."
def decide_guard(tool, ti, cwd):
"""Сообщение блокировки guard-main или None."""
cwd = cwd or None
if tool == "Bash":
targets = parse(ti.get("command") or "", cwd)
elif tool in ("Edit", "Write", "MultiEdit"):
fp = ti.get("file_path") or ""
targets = [Target("file", _join(cwd, fp), tool)] if fp else []
else:
return None
over = {} # корень → ветка после git switch/checkout -b в этой же команде (None — не определена)
for t in targets:
if t.kind == "switch":
r = resolve(t.path) if t.path else None
if r:
over[r[0]] = t.op
continue
if t.path is None:
if t.kind == "git":
if t.op == "push?":
return "BLOCKED [guard-main]: git push — целевая ветка не определена. Укажи ветку явно."
return ("BLOCKED [guard-main]: git %s — репозиторий не определён (переменная в -C, --git-dir или "
"--work-tree). Укажи git -C <абсолютный путь>." % t.op)
continue
r = resolve(t.path)
if not r:
continue
root, branch, real = r
if root in over:
branch = over[root]
if t.kind == "git":
if t.op.startswith("push>") or t.op == "push --all":
return "BLOCKED [guard-main]: git %s — репозиторий %s. В main только через PR." % (
t.op.replace(">", " в "), root)
if t.op == "push?":
return "BLOCKED [guard-main]: git push — целевая ветка не определена. Укажи ветку явно."
if branch is None or branch in PROTECTED:
return "BLOCKED [guard-main]: git %s — репозиторий %s на %s. В main только через PR. %s" % (
t.op, root, "неизвестной ветке" if branch is None else _label(branch), WT_HINT)
elif branch in PROTECTED:
return "BLOCKED [guard-main]: запись в %s — репозиторий %s на %s. %s" % (
os.path.relpath(real, root), root, _label(branch), WT_HINT)
return None
def decide_plan(fp, cwd):
"""Сообщение блокировки require-plan или None."""
path = _join(cwd or None, fp) if fp else None
r = resolve(path) if path else None
if not r:
return None
root, branch, real = r
rel = os.path.relpath(real, root)
cp = os.path.join(root, ".claude", "code-paths")
if os.path.isfile(cp):
prefixes = [l.split("#", 1)[0].strip().rstrip("/") for l in open(cp, encoding="utf-8", errors="replace")]
protected = any(p and rel.startswith(p + "/") for p in prefixes)
else:
protected = "/src/" in "/" + rel
if not protected or os.path.exists(os.path.join(root, ".bounded")):
return None
plans = sorted(glob.glob(os.path.join(root, "docs", "superpowers", "plans", "*.md")))
if plans:
if branch == "" or len(plans) == 1:
return None
for p in plans:
if branch in open(p, encoding="utf-8", errors="replace").read():
return None
return ("BLOCKED [require-plan]: правка %s без плана для ветки %s. Запусти superpowers:brainstorming; "
"для bounded-задачи создай %s/.bounded." % (rel, _label(branch), root))
def main(argv):
mode = argv[1] if len(argv) > 1 else ""
if mode == "targets":
try:
ts = parse(argv[2] if len(argv) > 2 else "", argv[3] if len(argv) > 3 else os.getcwd())
except Exception as e:
print("error %s" % e)
return 1
for t in ts:
print(t.kind, "-" if t.path is None else t.path, "-" if t.op is None else t.op)
return 0
try:
d = json.loads(sys.stdin.read())
ti = d.get("tool_input") or {}
if mode == "guard-main":
msg = decide_guard(d.get("tool_name") or "", ti, d.get("cwd") or "")
elif mode == "require-plan":
msg = decide_plan(ti.get("file_path") or ti.get("path") or "", d.get("cwd") or "")
else:
msg = None
except Exception:
return 0
if msg:
sys.stderr.write(msg + "\n")
return 2
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
```
- [ ] **Step 4: Прогнать тест**
Run: `bash tests/run.sh hook-parse`
Expected: `passed 80, failed 0`.
- [ ] **Step 5: Коммит**
```bash
git add hooks/harness_target.py tests/test-hook-parse.sh
git commit -m "feat(hooks): harness_target.py — разбор команды на цели записи (#6)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 2: guard-main на ядре
**Files:**
- Modify: `hooks/guard-main.sh` (заменить целиком)
- Test: `tests/test-hook-guard-main.sh`
**Interfaces:**
- Consumes: `python3 hooks/harness_target.py guard-main` (задача 1).
- Produces: `hooks/guard-main.sh` — PreToolUse-хук, stdin JSON → exit 0/2.
- [ ] **Step 1: Написать тест сценариев спеки**
Создать `tests/test-hook-guard-main.sh`:
```bash
# guard-main.sh: решение по цели записи (спека hook-guards) в песочнице из настоящих git-репозиториев.
G="$ROOT/hooks/guard-main.sh"
S="$TMP/sb"; mkdir -p "$S/ws" "$HOME/.claude/projects/p/memory"
mk(){ git init -q "$1" && git -C "$1" symbolic-ref HEAD "refs/heads/${2:-main}" && git -C "$1" commit -q --allow-empty -m init; }
mk "$S/m" # репозиторий на main
mk "$S/f"; git -C "$S/f" worktree add -q "$S/wt" -b feat/6-x # основной checkout на main + worktree на ветке
mk "$S/ms" master # на master
mk "$S/d"; git -C "$S/d" checkout -q --detach # detached HEAD
j(){ python3 -c 'import json,sys; k="command" if sys.argv[2]=="Bash" else "file_path"; print(json.dumps({"cwd":sys.argv[1],"tool_name":sys.argv[2],"tool_input":{k:sys.argv[3]}}))' "$@"; }
rc(){ j "$@" | bash "$G" 2>/dev/null; echo $?; }
err(){ j "$@" | bash "$G" 2>&1 >/dev/null; }
# решение по цели, а не по cwd
assert_eq "чтение с 2>/dev/null на main" "$(rc "$S/m" Bash 'grep -rn "npm i\|npm install" . 2>/dev/null | head')" 0
assert_eq "fetch 2>&1 | tail на main" "$(rc "$S/m" Bash 'git fetch origin --prune 2>&1 | tail -3')" 0
assert_eq "Write вне репозитория при cwd на main" "$(rc "$S/m" Write "$HOME/.claude/projects/p/memory/x.md")" 0
assert_eq "Edit в worktree при cwd на main" "$(rc "$S/f" Edit "$S/wt/a.md")" 0
assert_eq "Edit в репозитории на main при cwd в worktree" "$(rc "$S/wt" Edit "$S/m/a.md")" 2
assert_eq "Write на master" "$(rc "$S/ws" Write "$S/ms/a.md")" 2
assert_eq "Write при detached HEAD" "$(rc "$S/ws" Write "$S/d/a.md")" 2
# файловые цели Bash
assert_eq "echo > в main" "$(rc "$S/m" Bash 'echo x > notes.md')" 2
assert_eq "make 2>build.log в main" "$(rc "$S/m" Bash 'make 2>build.log')" 2
assert_eq "sed -i по файлу main" "$(rc "$S/ws" Bash "sed -i 's/a/b/' $S/m/docs/HARNESS.md")" 2
assert_eq "sed -i '' по файлу памяти" "$(rc "$S/m" Bash "sed -i '' 's/a/b/' $HOME/.claude/projects/p/memory/x.md")" 0
assert_eq "docker … sh -c > /tmp" "$(rc "$S/m" Bash "docker compose exec -T db sh -c 'psql -c \"select 1\" > /tmp/x'")" 0
assert_eq "pip install на main" "$(rc "$S/m" Bash 'pip install requests')" 0
assert_eq "npm install на main" "$(rc "$S/m" Bash 'npm install')" 2
assert_eq "цель в переменной" "$(rc "$S/m" Bash 'echo x > "$OUT"')" 0
assert_eq "цель через \$HOME в main" "$(HOME="$S" rc "$S/ws" Bash 'echo x > $HOME/m/f')" 2
# каталог внутри команды
assert_eq "cd в worktree и запись" "$(rc "$S/f" Bash "cd $S/wt && echo x > f")" 0
assert_eq "cd в main и touch" "$(rc "$S/wt" Bash "cd $S/m && touch f")" 2
assert_eq "mkdir -p .wt/ в не-git workspace" "$(rc "$S/ws" Bash 'mkdir -p .wt/6-x')" 0
assert_eq "bash -c с записью в main" "$(rc "$S/m" Bash "bash -c 'echo x > f'")" 2
# git
assert_eq "add && commit в main" "$(rc "$S/m" Bash 'git add -A && git commit -m x')" 2
assert_eq "commit -C worktree при cwd на main" "$(rc "$S/f" Bash "git -C $S/wt commit -m x")" 0
assert_eq "worktree add -C из workspace" "$(rc "$S/ws" Bash "git -C $S/m worktree add ../.wt/x -b feat/x")" 0
assert_eq "pull --ff-only на main" "$(rc "$S/m" Bash 'git pull --ff-only')" 0
assert_eq "stash list на main" "$(rc "$S/m" Bash 'git stash list')" 0
assert_eq "-C в переменной — блок" "$(rc "$S/ws" Bash 'git -C "$REPO" commit -m x')" 2
assert_grep "-C в переменной — подсказка git -C" 'git -C <абсолютный путь>' <(err "$S/ws" Bash 'git -C "$REPO" commit -m x')
assert_eq "switch -c && commit на main" "$(rc "$S/m" Bash 'git switch -q -c chore/archive-x && git add openspec && git commit -m a')" 0
assert_eq "switch -c && touch на main" "$(rc "$S/m" Bash 'git switch -q -c feat/16 && touch .bounded')" 0
assert_eq "switch в переменную && commit — блок" "$(rc "$S/m" Bash 'git switch "$B" && git commit -m x')" 2
assert_eq "switch в переменную && touch — пропуск" "$(rc "$S/m" Bash 'git switch "$B" && touch f')" 0
assert_eq "commit вне репозитория" "$(rc "$S/ws" Bash 'git commit -m x')" 0
# push
assert_eq "push с main" "$(rc "$S/m" Bash 'git push')" 2
assert_eq "push --delete фичи с main" "$(rc "$S/m" Bash 'git push origin --delete feat/6-x')" 0
assert_eq "push фичи с main" "$(rc "$S/m" Bash 'git push origin feat/6-x')" 0
assert_eq "push HEAD:main из worktree" "$(rc "$S/wt" Bash 'git push origin HEAD:main')" 2
assert_eq "push из worktree" "$(rc "$S/wt" Bash 'git push -u origin HEAD')" 0
# без исключения для openspec (решение владельца 1)
mkdir -p "$S/m/openspec"; echo a > "$S/m/openspec/x.md"; git -C "$S/m" add openspec
assert_eq "commit только openspec/ в main" "$(rc "$S/m" Bash 'git commit -m archive')" 2
git -C "$S/m" reset -q
assert_eq "openspec archive в main" "$(rc "$S/m" Bash 'openspec archive x')" 2
# граничные случаи (Review Focus)
ln -s "$S/wt/CLAUDE.md" "$S/ws/CLAUDE.md"; ln -s "$S/m/CLAUDE.md" "$S/ws/MAIN.md"
assert_eq "запись через симлинк в worktree" "$(rc "$S/ws" Write "$S/ws/CLAUDE.md")" 0
assert_eq "запись через симлинк в main" "$(rc "$S/ws" Write "$S/ws/MAIN.md")" 2
assert_eq "пустой cwd и относительный путь — пропуск" "$(rc "" Write "a.md")" 0
assert_eq "запись внутри .git/ — не рабочее дерево" "$(rc "$S/m" Bash 'echo x >> .git/info/exclude')" 0
assert_eq "путь с пробелом в main" "$(rc "$S/m" Bash 'touch "a b.md"')" 2
# сбой разбора и сообщение
assert_eq "не JSON — пропуск" "$(printf 'not json' | bash "$G" 2>/dev/null; echo $?)" 0
assert_eq "незакрытая кавычка — пропуск" "$(rc "$S/m" Bash 'echo "x > f')" 0
mkdir -p "$TMP/nopy"
assert_eq "нет python3 — пропуск" "$(env PATH="$TMP/nopy" /bin/bash "$G" <<< "$(j "$S/m" Write "$S/m/a.md")" 2>/dev/null; echo $?)" 0
m=$(err "$S/ws" Write "$S/m/docs/HARNESS.md")
assert_grep "сообщение: префикс" '^BLOCKED \[guard-main\]:' <(printf '%s' "$m")
assert_grep "сообщение: путь отн. корня" 'docs/HARNESS.md' <(printf '%s' "$m")
assert_grep "сообщение: корень и worktree" "$(basename "$S")/m на main.*worktree" <(printf '%s' "$m")
assert_grep "settings.hooks.json регистрирует guard-main" 'guard-main\.sh' "$ROOT/hooks/settings.hooks.json"
```
- [ ] **Step 2: Убедиться, что тест падает на старом хуке**
Run: `bash tests/run.sh hook-guard`
Expected: FAIL на «чтение с 2>/dev/null на main», «Write вне репозитория при cwd на main», «switch -c && commit на main», «сообщение: префикс» и других.
- [ ] **Step 3: Заменить `hooks/guard-main.sh` обёрткой**
```bash
#!/usr/bin/env bash
# PreToolUse hook (Edit/Write/MultiEdit/Bash): запрет записи в репозиторий на main/master/detached HEAD.
# Решение — по цели записи, а не по cwd; логика в harness_target.py рядом. Любой сбой — пропуск (exit 0).
py="${0%/*}/harness_target.py"
command -v python3 >/dev/null 2>&1 && [ -f "$py" ] || exit 0
python3 "$py" guard-main
[ $? -eq 2 ] && exit 2
exit 0
```
- [ ] **Step 4: Прогнать тесты**
Run: `bash tests/run.sh hook-`
Expected: `passed 131, failed 0` (parse 80 + guard-main 51).
- [ ] **Step 5: Коммит**
```bash
git add hooks/guard-main.sh tests/test-hook-guard-main.sh
git commit -m "feat(hooks): guard-main решает по цели записи, без исключения openspec-only (#6)" -m "B1; RT-1, RT-2, RT-7; решение владельца 1." -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 3: require-plan на ядре
**Files:**
- Modify: `hooks/require-plan.sh` (заменить целиком)
- Test: `tests/test-hook-require-plan.sh`
**Interfaces:**
- Consumes: `python3 hooks/harness_target.py require-plan` (задача 1).
- Produces: `hooks/require-plan.sh` — PreToolUse-хук, stdin JSON → exit 0/2.
- [ ] **Step 1: Написать тест**
Создать `tests/test-hook-require-plan.sh`:
```bash
# require-plan.sh: корень, ветка, code-paths, .bounded и планы — из репозитория правимого файла (спека hook-guards).
H="$ROOT/hooks/require-plan.sh"
S="$TMP/rp"; mkdir -p "$S/out"
mk(){ git init -q "$1" && git -C "$1" symbolic-ref HEAD "refs/heads/${2:-main}" && git -C "$1" commit -q --allow-empty -m init; }
mk "$S/a"; touch "$S/a/.bounded" # A: есть .bounded
mk "$S/b" # B: ни плана, ни .bounded
mk "$S/f"; git -C "$S/f" worktree add -q "$S/wt" -b feat/6-x; touch "$S/wt/.bounded"
mk "$S/c"; mkdir -p "$S/c/.claude"; printf 'hooks/ # код\n\nskills/\n' > "$S/c/.claude/code-paths"
mk "$S/p2" feat/6-x; mkdir -p "$S/p2/docs/superpowers/plans"
echo "Ветка: feat/6-x" > "$S/p2/docs/superpowers/plans/2026-10-16-a.md"; echo "другое" > "$S/p2/docs/superpowers/plans/2026-10-11-b.md"
mk "$S/p0" feat/7-y; mkdir -p "$S/p0/docs/superpowers/plans"
echo "Ветка: feat/6-x" > "$S/p0/docs/superpowers/plans/a.md"; echo "другое" > "$S/p0/docs/superpowers/plans/b.md"
mk "$S/p1" feat/7-y; mkdir -p "$S/p1/docs/superpowers/plans"; echo "x" > "$S/p1/docs/superpowers/plans/a.md"
mk "$S/pd"; mkdir -p "$S/pd/docs/superpowers/plans"; echo a > "$S/pd/docs/superpowers/plans/a.md"; echo b > "$S/pd/docs/superpowers/plans/b.md"
git -C "$S/pd" checkout -q --detach
j(){ python3 -c 'import json,sys; print(json.dumps({"cwd":sys.argv[1],"tool_name":"Edit","tool_input":{"file_path":sys.argv[2]}}))' "$@"; }
rc(){ j "$@" | bash "$H" 2>/dev/null; echo $?; }
assert_eq "файл в B без плана при cwd в A с .bounded" "$(rc "$S/a" "$S/b/src/a.py")" 2
assert_eq "файл в worktree с .bounded при cwd на main" "$(rc "$S/f" "$S/wt/src/a.py")" 0
assert_eq "src на глубине без плана" "$(rc "$S/b" "$S/b/api/src/a.py")" 2
assert_eq "не код — пропуск" "$(rc "$S/b" "$S/b/docs/a.md")" 0
assert_eq "вне репозитория — пропуск" "$(rc "$S/b" "$S/out/src/a.py")" 0
assert_eq "code-paths: hooks/ без плана" "$(rc "$S/a" "$S/c/hooks/x.sh")" 2
assert_eq "code-paths: src/ не защищён" "$(rc "$S/c" "$S/c/src/a.py")" 0
assert_eq "два плана, один с веткой" "$(rc "$S/p2" "$S/p2/src/a.py")" 0
assert_eq "два плана без ветки" "$(rc "$S/p0" "$S/p0/src/a.py")" 2
assert_eq "один план — пропуск" "$(rc "$S/p1" "$S/p1/src/a.py")" 0
assert_eq "detached HEAD при планах — пропуск" "$(rc "$S/pd" "$S/pd/src/a.py")" 0
assert_eq "не JSON — пропуск" "$(printf 'not json' | bash "$H" 2>/dev/null; echo $?)" 0
assert_eq "без file_path — пропуск" "$(printf '{"cwd":"%s","tool_input":{}}' "$S/b" | bash "$H" 2>/dev/null; echo $?)" 0
m=$(j "$S/a" "$S/b/src/a.py" | bash "$H" 2>&1 >/dev/null)
assert_grep "сообщение: префикс и путь" '^BLOCKED \[require-plan\]: правка src/a.py' <(printf '%s' "$m")
assert_grep "сообщение: .bounded репозитория файла" "$(basename "$S")/b/\.bounded" <(printf '%s' "$m")
assert_grep "settings.hooks.json регистрирует require-plan" 'require-plan\.sh' "$ROOT/hooks/settings.hooks.json"
```
- [ ] **Step 2: Убедиться, что тест падает на старом хуке**
Run: `bash tests/run.sh hook-require`
Expected: FAIL на «файл в B без плана при cwd в A с .bounded» (старый хук видит `.bounded` в A) и «сообщение: префикс и путь».
- [ ] **Step 3: Заменить `hooks/require-plan.sh` обёрткой**
```bash
#!/usr/bin/env bash
# PreToolUse hook (Edit/Write/MultiEdit): правки в каталогах кода (любой src/ или .claude/code-paths) — только при
# плане для ветки или файле .bounded. Репозиторий — тот, где лежит файл; логика в harness_target.py рядом.
py="${0%/*}/harness_target.py"
command -v python3 >/dev/null 2>&1 && [ -f "$py" ] || exit 0
python3 "$py" require-plan
[ $? -eq 2 ] && exit 2
exit 0
```
- [ ] **Step 4: Прогнать тесты**
Run: `bash tests/run.sh hook-`
Expected: `passed 147, failed 0` (+ require-plan 16).
- [ ] **Step 5: Коммит**
```bash
git add hooks/require-plan.sh tests/test-hook-require-plan.sh
git commit -m "feat(hooks): require-plan по репозиторию правимого файла (#6)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 4: Регрессионный корпус и экстрактор (B2)
**Files:**
- Create: `docs/retro/scripts/hook_cases_build.py`, `skills/harness-doctor/scripts/hook_corpus.py`
- Generate: `skills/harness-doctor/fixtures/hook-cases.jsonl`
- Modify: `docs/retro/scripts/hook_blocks_export.py`, `docs/retro/scripts/retro_blocks.py`
- Test: `tests/test-hook-corpus.sh`, `tests/test-hook-export.sh`
**Interfaces:**
- Consumes: `hooks/guard-main.sh`, `hooks/require-plan.sh` (задачи 2–3); сырьё `docs/retro/fixtures/hook-blocks.jsonl` (не меняется).
- Produces:
- `hook-cases.jsonl`: строка 1 — `{"home": "/Users/andrey.epifancev", "layout": [{"path", "branch"?, "worktree_of"?}]}`; далее записи выгрузки + `id` (`c001`…`c126`, `l001`…`l003`), `expected` (`allow|block`), `why`.
- `python3 skills/harness-doctor/scripts/hook_corpus.py <каталог хуков> [--summary] [--fixtures F]`: без `--summary` — строка `id\twhy\texpected\tgot\tcmd` на запись и `total N/M`; с `--summary` — `MISMATCH …` на расхождение и `корпус хуков: N/M`; exit 0, если всё совпало.
- [ ] **Step 1: Написать тесты корпуса и экстрактора**
Создать `tests/test-hook-corpus.sh`:
```bash
# hook_corpus.py: регрессионный корпус хуков (B2) через источники hooks/ — assert на запись.
R="$ROOT/skills/harness-doctor/scripts/hook_corpus.py"
F="$ROOT/skills/harness-doctor/fixtures/hook-cases.jsonl"
assert_file "hook_corpus.py существует" "$R"
assert_file "корпус hook-cases.jsonl" "$F"
assert_eq "корпус совпадает со сборкой из выгрузки" "$(python3 "$ROOT/docs/retro/scripts/hook_cases_build.py" | cmp -s - "$F" && echo same)" "same"
while IFS=$'\t' read -r id why exp got cmd; do
case "$id" in total*) continue ;; esac
assert_eq "корпус $id ($why): $cmd" "$got" "$exp"
done < <(python3 "$R" "$ROOT/hooks")
```
Создать `tests/test-hook-export.sh`:
```bash
# hook_blocks_export.py: выгружаются только ошибки хуков, а не вывод команд со словом BLOCKED.
X="$ROOT/docs/retro/scripts/hook_blocks_export.py"
T="$TMP/transcripts"; mkdir -p "$T"
cat > "$T/s1.jsonl" <<'EOF'
{"type":"assistant","cwd":"/r","gitBranch":"main","timestamp":"t1","message":{"content":[{"type":"tool_use","id":"u1","name":"Bash","input":{"command":"echo x > f"}}]}}
{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"u1","is_error":true,"content":"PreToolUse:Bash hook error: [\"$HOME/.claude/hooks/guard-main.sh\"]: BLOCKED [guard-main]: запись в f — репозиторий /r на main."}]}}
{"type":"assistant","cwd":"/r","gitBranch":"main","timestamp":"t2","message":{"content":[{"type":"tool_use","id":"u2","name":"Bash","input":{"command":"cat hooks/require-plan.sh"}}]}}
{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"u2","content":"echo \"BLOCKED: editing $fpath without a plan. Run superpowers:brainstorming\" >&2"}]}}
{"type":"assistant","cwd":"/r","gitBranch":"main","timestamp":"t3","message":{"content":[{"type":"tool_use","id":"u3","name":"Edit","input":{"file_path":"/r/a.md"}}]}}
{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"u3","is_error":true,"content":[{"type":"text","text":"PreToolUse:Edit hook error: [\"$HOME/.claude/hooks/guard-main.sh\"]: BLOCKED: you are on main. Create an isolated worktree first"}]}]}}
EOF
out=$(python3 "$X" "$T")
assert_eq "две записи: вывод cat с BLOCKED не выгружен" "$(printf '%s\n' "$out" | grep -c .)" "2"
assert_eq "новый префикс → guard-main(Bash)" "$(printf '%s\n' "$out" | sed -n 1p | python3 -c 'import json,sys; r=json.load(sys.stdin); print(r["hook"], r["command"])')" "guard-main(Bash) echo x > f"
assert_eq "старая фраза → guard-main(Edit/Write)" "$(printf '%s\n' "$out" | sed -n 2p | python3 -c 'import json,sys; r=json.load(sys.stdin); print(r["hook"], r["file_path"])')" "guard-main(Edit/Write) /r/a.md"
```
- [ ] **Step 2: Убедиться, что тесты падают**
Run: `bash tests/run.sh hook-corpus; bash tests/run.sh hook-export`
Expected: FAIL «hook_corpus.py существует», «корпус hook-cases.jsonl»; в экстракторе — «две записи: вывод cat с BLOCKED не выгружен» (получено 3) и «новый префикс → guard-main(Bash)» (получено `other`).
- [ ] **Step 3: Создать сборщик корпуса**
Создать `docs/retro/scripts/hook_cases_build.py`:
```python
#!/usr/bin/env python3
"""Сборка регрессионного корпуса хуков из сырой выгрузки блокировок (B2, #6).
Запуск из корня репозитория:
python3 docs/retro/scripts/hook_cases_build.py > skills/harness-doctor/fixtures/hook-cases.jsonl
Вход: docs/retro/fixtures/hook-blocks.jsonl (hook_blocks_export.py, 12.10.2026, 126 записей).
Выход: первая строка — {"home": …, "layout": […]} (раскладка песочницы), дальше по записи на случай:
поля выгрузки + id, expected (allow|block), why (класс). Вердикт размечен вручную по спеке
openspec/changes/hooks-write-target/specs/hook-guards/spec.md, а не прогоном хука.
"""
import json
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
RAW = os.path.join(HERE, "..", "fixtures", "hook-blocks.jsonl")
HOME = "/Users/andrey.epifancev"
AW, CH = "projects/awp-ca", "projects/cc-skills/claude-harness"
LAYOUT = [{"path": AW}, {"path": AW + "/reference-old-arm"}]
LAYOUT += [{"path": "%s/%s" % (AW, r), "branch": "main"}
for r in ("arm-core", "docs", "tabular-parser", "scan-gateway", "arm-web", "fixtures")]
LAYOUT += [{"path": "%s/.wt/%s/%s" % (AW, n, r), "worktree_of": "%s/%s" % (AW, r), "branch": "feat/" + n}
for n, r in (("web-proto", "arm-core"), ("web-proto", "arm-web"), ("ocr-q1", "fixtures"),
("ocr-q1", "scan-gateway"), ("stmt-template", "arm-web"), ("qa-guide-web", "docs"),
("slice-0", "scan-gateway"), ("slice-3b", "arm-core"))]
LAYOUT += [{"path": CH, "branch": "main"}]
LAYOUT += [{"path": "%s/.wt/%s" % (CH, n), "worktree_of": CH, "branch": b}
for n, b in (("retro", "retro/2026-10-11"), ("roles", "feat/2-roles-and-memory"),
("guard", "feat/6-guard-main-target"))]
LAYOUT += [{"path": ".claude/projects/-Users-andrey-epifancev-projects-awp-ca/memory"},
{"path": ".claude/projects/-Users-andrey-epifancev-projects-cc-skills-claude-harness/memory"},
{"path": ".claude/hooks"}, {"path": ".claude/jobs"}]
# Блок по спеке: архивный коммит openspec прямо в main (решение владельца 1 — архив отдельным PR).
BLOCK = {"c031", "c069", "c070", "c103"}
# Класс для записей, где он не выводится общим правилом ниже.
WHY = {
"c002": "unparseable", "c008": "var-target", "c023": "outside-repo", "c026": "outside-repo",
"c029": "worktree", "c030": "outside-repo", "c040": "worktree-setup", "c044": "worktree", "c045": "worktree",
"c055": "outside-repo", "c064": "worktree-setup", "c072": "switch-then-commit", "c073": "worktree-setup",
"c078": "outside-repo", "c080": "heredoc", "c081": "heredoc", "c083": "outside-repo", "c091": "heredoc",
"c099": "worktree-setup", "c100": "worktree-setup", "c101": "worktree", "c102": "worktree",
"c104": "worktree-setup", "c106": "worktree-setup", "c119": "heredoc",
}
# Ложные блоки этой сессии (15.10.2026), до перехода в worktree: cwd — основной checkout на main.
LIVE_RE = ('git show origin/feat/6-guard-main-target:docs/retro/fixtures/hook-blocks.jsonl | python3 -c \'\n'
'import sys,json,re,collections\n'
'rs=[json.loads(l) for l in sys.stdin if l.strip()]\n'
'c=collections.Counter()\n'
'for r in rs:\n'
' cmd=r.get("command") or ""\n'
' if re.search(r"\\d>{1,2}\\s*/dev/null|\\d>&\\d",cmd): c["bash: N>/dev/null или N>&M"]+=1\n'
' if re.search(r"mkdir\\s+-p\\s+\\S*\\.wt/",cmd): c["bash: mkdir -p .wt/"]+=1\n'
' if re.search(r"\\bsed\\s+-i",cmd): c["bash: sed -i"]+=1\n'
'for k,v in c.most_common(): print(f"{v:4} {k}")\'')
LIVE = [
("l001", "grep-pattern", 'grep -rn "npm i\\|npm install\\|brew install\\|plugin install\\|go install" '
'--include="*.md" --include="*.sh" . | grep -v "^./docs/retro/2026" | head -20'),
("l002", "devnull", 'grep -rn "plugin add\\|marketplace\\|caveman@\\|superpowers@" --include="*.md" '
'--include="*.sh" README.md docs global install.sh 2>/dev/null | head -20'),
("l003", "regex-text", LIVE_RE),
]
DEVNULL = re.compile(r"\d>{1,2}\s*/dev/null|\d>&\d")
def why_of(cid, r):
if cid in BLOCK:
return "main-git"
if cid in WHY:
return WHY[cid]
if "hook error" not in (r.get("message") or ""):
return "not-a-block" # вывод успешной команды, где встретилось слово BLOCKED
if r["tool"] != "Bash":
return "outside-repo"
return "devnull" if DEVNULL.search(r.get("command") or "") else "read-only"
def main():
print(json.dumps({"home": HOME, "layout": LAYOUT}, ensure_ascii=False))
for n, line in enumerate(l for l in open(RAW, encoding="utf-8") if l.strip()):
r = json.loads(line)
cid = "c%03d" % (n + 1)
r.update({"id": cid, "expected": "block" if cid in BLOCK else "allow", "why": why_of(cid, r)})
print(json.dumps(r, ensure_ascii=False))
for cid, why, cmd in LIVE:
print(json.dumps({"id": cid, "session": "live-1015", "cwd": HOME + "/" + CH, "branch": "main",
"tool": "Bash", "command": cmd, "file_path": None, "hook": "guard-main(Bash)",
"message": "", "expected": "allow", "why": why}, ensure_ascii=False))
if __name__ == "__main__":
main()
```
- [ ] **Step 4: Создать раннер корпуса**
Создать `skills/harness-doctor/scripts/hook_corpus.py`:
```python
#!/usr/bin/env python3
"""Регрессионный корпус хуков (B2): прогон fixtures/hook-cases.jsonl через guard-main и require-plan в песочнице.
Запуск: python3 hook_corpus.py <каталог хуков> [--summary] [--fixtures <jsonl>]
Песочница строится во временном каталоге по раскладке из первой строки корпуса; префикс домашнего каталога записей
заменяется на её home, HOME хуков подменяется туда же. Bash идёт через guard-main, Edit/Write — через guard-main и
require-plan (блок, если блокирует любой).
Вывод: строка на запись «id<TAB>why<TAB>ожидалось<TAB>получено<TAB>команда» и итог «total N/M»;
с --summary — только расхождения и «корпус хуков: N/M». Код выхода 0 — всё совпало, 1 — нет.
"""
import json
import os
import shutil
import subprocess
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
def _git(*args):
subprocess.run(["git", "-c", "user.name=corpus", "-c", "user.email=corpus@example.invalid"] + list(args),
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def build(home, layout):
for e in layout:
p = os.path.join(home, e["path"])
if "worktree_of" in e:
_git("-C", os.path.join(home, e["worktree_of"]), "worktree", "add", "-q", p, "-b", e["branch"])
elif "branch" in e:
os.makedirs(p, exist_ok=True)
_git("init", "-q", p)
_git("-C", p, "symbolic-ref", "HEAD", "refs/heads/" + e["branch"])
_git("-C", p, "commit", "-q", "--allow-empty", "-m", "init")
else:
os.makedirs(p, exist_ok=True)
def verdict(hooks, case, prefix, home, env):
swap = lambda s: s.replace(prefix, home) if isinstance(s, str) else s
tool = case["tool"]
ti = {"command": swap(case.get("command"))} if tool == "Bash" else {"file_path": swap(case.get("file_path"))}
payload = json.dumps({"cwd": swap(case.get("cwd")), "tool_name": tool, "tool_input": ti})
for name in ["guard-main"] + ([] if tool == "Bash" else ["require-plan"]):
r = subprocess.run(["bash", os.path.join(hooks, name + ".sh")], input=payload, universal_newlines=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, timeout=30)
if r.returncode == 2:
return "block"
if r.returncode != 0:
return "error"
return "allow"
def main(argv):
args = argv[1:]
summary = "--summary" in args
fx = os.path.join(HERE, "..", "fixtures", "hook-cases.jsonl")
if "--fixtures" in args:
fx = args[args.index("--fixtures") + 1]
pos = [a for k, a in enumerate(args) if not a.startswith("--") and (k == 0 or args[k - 1] != "--fixtures")]
if not pos:
print(__doc__.strip().splitlines()[2])
return 1
hooks = os.path.abspath(pos[0])
lines = [json.loads(l) for l in open(fx, encoding="utf-8") if l.strip()]
head, cases = lines[0], lines[1:]
tmp = tempfile.mkdtemp(prefix="hook-corpus-")
try:
home = os.path.join(tmp, "home")
try:
build(home, head["layout"])
except Exception as e:
print("корпус хуков: песочница не собрана (%s)" % e)
return 1
env = {k: v for k, v in os.environ.items() if k not in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE")}
env["HOME"] = home
ok = 0
for c in cases:
got = verdict(hooks, c, head["home"], home, env)
ok += got == c["expected"]
body = (c.get("command") or c.get("file_path") or "").replace(head["home"], "~")
row = "\t".join([c["id"], c["why"], c["expected"], got, " ".join(body.split())[:70]])
if not summary:
print(row)
elif got != c["expected"]:
print("MISMATCH " + row)
print(("корпус хуков: %d/%d" if summary else "total %d/%d") % (ok, len(cases)))
return 0 if ok == len(cases) else 1
finally:
shutil.rmtree(tmp, ignore_errors=True)
if __name__ == "__main__":
sys.exit(main(sys.argv))
```
- [ ] **Step 5: Сгенерировать корпус**
Run: `mkdir -p skills/harness-doctor/fixtures && python3 docs/retro/scripts/hook_cases_build.py > skills/harness-doctor/fixtures/hook-cases.jsonl && wc -l skills/harness-doctor/fixtures/hook-cases.jsonl`
Expected: `130` (шапка + 126 + 3).
- [ ] **Step 6: Экстрактор — только ошибки хука**
В `docs/retro/scripts/hook_blocks_export.py`:
Докстринг — после строки `первая строка ответа хука. Поле expected …` вставить:
```
Выгружаются только ошибки хуков (результат начинается с `PreToolUse:<инструмент> hook error`); вывод команд,
в котором встретилось слово BLOCKED (файлы памяти, исходники хуков), не попадает.
```
После `import json, sys, glob, os, re` добавить:
```python
HOOK_ERROR = re.compile(r"\s*PreToolUse:\S+ hook error")
```
Заменить начало `hook_of`:
```python
def hook_of(txt, tool):
if "[guard-main]" in txt:
return "guard-main(Bash)" if tool == "Bash" else "guard-main(Edit/Write)"
if "[require-plan]" in txt:
return "require-plan"
if "shell write" in txt: # формулировки до 0.4.0
```
Заменить фильтр:
```python
if not HOOK_ERROR.match(txt):
continue # обычный вывод команды, даже если в нём есть слово BLOCKED
```
(было `if not ("BLOCKED" in txt and ("hook" in txt.lower() or "BLOCKED:" in txt[:400])):`), и вызов — `"hook": hook_of(txt, name),`.
- [ ] **Step 7: Тот же фильтр в `retro_blocks.py`**
В `docs/retro/scripts/retro_blocks.py` после `RE_HEREDOC_PY = …` добавить:
```python
RE_HOOK_ERROR = re.compile(r"\s*PreToolUse:\S+ hook error") # только ошибка хука, не вывод команды со словом BLOCKED
```
Заменить:
```python
if "BLOCKED" in txt and ("hook" in txt.lower() or "BLOCKED:" in txt[:400]):
if name not in ("Bash", "Edit", "Write", "MultiEdit"):
continue
if "shell write" in txt:
```
на:
```python
if RE_HOOK_ERROR.match(txt) and "BLOCKED" in txt:
if name not in ("Bash", "Edit", "Write", "MultiEdit"):
continue
if "[guard-main]" in txt:
hook = "guard-main(Bash)" if name == "Bash" else "guard-main(Edit/Write)"
elif "[require-plan]" in txt:
hook = "require-plan"
elif "shell write" in txt:
```
Run: `python3 -m py_compile docs/retro/scripts/retro_blocks.py docs/retro/scripts/hook_blocks_export.py`
Expected: без вывода.
- [ ] **Step 8: Прогнать тесты**
Run: `bash tests/run.sh hook-`
Expected: `passed 282, failed 0` (+ корпус 132, экстрактор 3); в выводе `ok корпус c031 (main-git): …`, `ok корпус c072 (switch-then-commit): …`.
- [ ] **Step 9: Метрика для PR (не тест)**
Старые хуки — из `main`, в отдельный каталог (каждая команда отдельно):
Run: `mkdir -p "$TMPDIR/old-hooks"`
Run: `git show main:hooks/guard-main.sh > "$TMPDIR/old-hooks/guard-main.sh"`
Run: `git show main:hooks/require-plan.sh > "$TMPDIR/old-hooks/require-plan.sh"`
Run: `python3 skills/harness-doctor/scripts/hook_corpus.py "$TMPDIR/old-hooks" | awk -F'\t' 'NF>=4 {n[$4]++; if ($4=="block" && $3=="allow") fp++} END {for (k in n) print k, n[k]; print "ложных:", fp+0}'`
Expected: `allow 88`, `block 41`, `ложных: 37` — записать в описание PR рядом с итогом новых хуков (`block 4`, ложных 0).
- [ ] **Step 10: Коммит**
```bash
git add docs/retro/scripts/hook_cases_build.py docs/retro/scripts/hook_blocks_export.py docs/retro/scripts/retro_blocks.py skills/harness-doctor/scripts/hook_corpus.py skills/harness-doctor/fixtures/hook-cases.jsonl tests/test-hook-corpus.sh tests/test-hook-export.sh
git commit -m "test(hooks): регрессионный корпус хуков из транскриптов, экстрактор только по ошибкам хука (#6)" -m "B2, RT-6, O-1." -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 5: Установка, doctor и документация
**Files:**
- Modify: `install.sh`, `skills/harness-doctor/scripts/doctor.sh`, `skills/harness-doctor/SKILL.md`, `tests/test-install.sh`, `tests/test-doctor.sh`, `README.md`, `docs/HARNESS.md`, `CLAUDE.md`, `roles/dev.md`, `CHANGELOG.md`
**Interfaces:**
- Consumes: `hooks/harness_target.py` (задача 1), `hook_corpus.py` и корпус (задача 4).
- Produces: строки отчёта `doctor.sh` — `ok hooks/harness_target.py (ядро guard-main и require-plan)`, `ok guard-main: git commit openspec-only на main → exit 2`, `ok корпус хуков: N/N`.
- [ ] **Step 1: Тесты install и doctor**
В `tests/test-install.sh` после строки `assert_eq "hooks/role-hint.sh исполняемый" …` добавить:
```bash
assert_file "hooks/harness_target.py установлен" "$CC/hooks/harness_target.py"
assert_file "корпус хуков установлен" "$CC/skills/harness-doctor/fixtures/hook-cases.jsonl"
assert_file "раннер корпуса установлен" "$CC/skills/harness-doctor/scripts/hook_corpus.py"
```
и после `assert_grep "--check: agent briefing" …`:
```bash
assert_grep "--check: harness_target.py" 'ok +hooks/harness_target\.py' <(printf '%s' "$out")
```
В `tests/test-doctor.sh` заменить строку `cp "$ROOT"/hooks/*.sh "$CC/hooks/"; chmod +x "$CC/hooks/"*.sh` на:
```bash
cp "$ROOT"/hooks/*.sh "$ROOT/hooks/harness_target.py" "$CC/hooks/"; chmod +x "$CC/hooks/"*.sh
mkdir -p "$CC/skills"; cp -R "$ROOT/skills/harness-doctor" "$CC/skills/"
```
и после `assert_grep "самотест: role-hint тишина" …` добавить:
```bash
assert_grep "машина: harness_target.py" 'ok +hooks/harness_target\.py' <(printf '%s' "$out")
assert_grep "самотест: openspec-only коммит на main — блок" 'ok +guard-main: git commit openspec-only на main → exit 2' <(printf '%s' "$out")
assert_grep "самотест: Write на main" 'ok +guard-main: Write на main → exit 2' <(printf '%s' "$out")
assert_grep "самотест: Write на ветке" 'ok +guard-main: Write на ветке → exit 0' <(printf '%s' "$out")
assert_grep "корпус хуков сходится" 'ok +корпус хуков: [0-9]+/[0-9]+' <(printf '%s' "$out")
```
- [ ] **Step 2: Убедиться, что тесты падают**
Run: `bash tests/run.sh install; bash tests/run.sh doctor`
Expected: FAIL «hooks/harness_target.py установлен», «--check: harness_target.py», «машина: harness_target.py», «самотест: openspec-only…», «корпус хуков сходится».
- [ ] **Step 3: `install.sh`**
Заменить строку копирования хуков:
```bash
cp "$HERE/hooks/guard-main.sh" "$HERE/hooks/require-plan.sh" "$HERE/hooks/harness-check.sh" "$HERE/hooks/role-hint.sh" \
"$HERE/hooks/harness_target.py" "$CC/hooks/"
```
В `check()` список файлов: после `hooks/require-plan.sh` вставить `hooks/harness_target.py`:
```bash
for f in CLAUDE.md hooks/guard-main.sh hooks/require-plan.sh hooks/harness_target.py hooks/harness-check.sh hooks/role-hint.sh \
```
В финальной строке `echo "done: …"` после `role-hint}.sh` дописать ` + harness_target.py`.
- [ ] **Step 4: `doctor.sh`**
После цикла `for h in guard-main require-plan harness-check role-hint; do … done` добавить:
```bash
[ -f "$CC/hooks/harness_target.py" ] && ok "hooks/harness_target.py (ядро guard-main и require-plan)" || miss "hooks/harness_target.py (bash install.sh)"
```
В самотесте после случая `"git worktree на main"` и перед `git -C "$T" switch -q -c feat/1-x` вставить:
```bash
mkdir -p "$T/openspec"; echo a > "$T/openspec/x.md"; git -C "$T" add openspec 2>/dev/null
hk guard-main "{\"cwd\":\"$T\",\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"git commit -m archive\"}}" 2 "git commit openspec-only на main"
git -C "$T" reset -q 2>/dev/null
```
После `rm -rf "$T"` (конец самотеста, перед `echo "## Проект: …"`) вставить:
```bash
HC="$CC/skills/harness-doctor/scripts/hook_corpus.py"
if [ -f "$HC" ] && [ -f "$CC/hooks/harness_target.py" ]; then
out=$(python3 "$HC" "$CC/hooks" --summary 2>&1); rc=$?
printf '%s\n' "$out" | grep '^MISMATCH' | sed 's/^MISMATCH / /'
[ "$rc" = 0 ] && ok "$(printf '%s\n' "$out" | tail -1)" || warn "$(printf '%s\n' "$out" | tail -1) — расхождения выше"
else miss "корпус хуков (skills/harness-doctor/scripts/hook_corpus.py, hooks/harness_target.py)"; fi
```
- [ ] **Step 5: Прогнать тесты install и doctor**
Run: `bash tests/run.sh install; bash tests/run.sh doctor`
Expected: оба `failed 0`.
- [ ] **Step 6: `SKILL.md` doctor**
В `skills/harness-doctor/SKILL.md` после абзаца **Самотест хуков** добавить абзац:
```markdown
**Корпус хуков** — `ok корпус хуков: N/N`: установленные хуки дают на реальных командах пилота те же вердикты, что размечены. `WARN корпус хуков: N/M` и строки над ним — расхождения: `id`, класс, ожидалось, получено, команда. После `install.sh` расхождение — регрессия пакета; разобрать одну команду: `python3 ~/.claude/hooks/harness_target.py targets '<команда>' <cwd>`. `MISS корпус хуков` — нет `harness_target.py` или раннера, чинится `install.sh`.
```
- [ ] **Step 7: Документация**
`README.md`, блок «Файлы» — строки `hooks/guard-main.sh` и `hooks/require-plan.sh` заменить на:
```
hooks/guard-main.sh запрет изменений на main/master/detached HEAD (Edit/Write/Bash) по цели записи, без исключений
hooks/require-plan.sh правки в src/ (или .claude/code-paths) только при плане для ветки или файле .bounded
hooks/harness_target.py ядро обоих хуков: цели записи команды → репозиторий и ветка каждой цели
```
`docs/HARNESS.md`, раздел «Хуки» — строки таблицы guard-main и require-plan заменить на:
```markdown
| Никаких изменений на main / master / detached HEAD | `guard-main.sh` → `harness_target.py`, PreToolUse на Edit/Write/MultiEdit/Bash | решает по цели записи, а не по cwd. Цели: файл Edit/Write; в Bash — редиректы (кроме `/dev/null` и `N>&M`), `tee`, `rm`, `mv`, `cp`, `sed -i`, `npm install`, `openspec archive`… с учётом `cd` и `git -C`; git-мутации (`commit`, `add`, `merge`, `reset`…; `push` — только если пушит в main; `switch -c` в той же команде меняет ветку). Цель вне репозитория или в worktree на ветке — пропуск; неразрешимый путь (`$VAR`) — пропуск; git-мутация с неопределённым репозиторием — блок. Исключений нет: архив OpenSpec — отдельным PR |
| Правки в коде только при плане или `.bounded` | `require-plan.sh` → `harness_target.py`, PreToolUse на Edit/Write/MultiEdit | по репозиторию правимого файла, а не по cwd: защищает любой `src/` на любой глубине (`api/src/`, `packages/*/src/`) или каталоги из `.claude/code-paths`; ищет план в `docs/superpowers/plans/` для ветки этого репозитория или файл `.bounded` в его корне |
```
и абзац после таблицы заменить на:
```markdown
Exit 2 блокирует вызов и показывает агенту stderr, поэтому агент сам делает то, что просит сообщение. Сообщение начинается с `BLOCKED [guard-main]:` или `BLOCKED [require-plan]:` и называет цель, репозиторий и выход. Оба обхода, замеченные на пилоте (запись через `echo >` вместо Write; push через detached HEAD), закрыты.
Регрессия хуков — корпус `skills/harness-doctor/fixtures/hook-cases.jsonl`: реальные команды из транскриптов пилота с ожидаемым вердиктом. По источникам — `bash tests/run.sh hook`, по установленным копиям — строка «корпус хуков» в `doctor.sh`. Корпус собирается из сырой выгрузки `docs/retro/scripts/hook_cases_build.py`; разметка — в этом скрипте. Разбор одной команды: `python3 ~/.claude/hooks/harness_target.py targets '<команда>' <cwd>`.
```
`CLAUDE.md` репозитория:
- «Стек и ограничения», первый пункт — начало до «Внешние CLI» заменить на: «Bash (`set -euo pipefail`), markdown и `python3` stdlib (3.8+). Логика guard-main и require-plan — `hooks/harness_target.py`, их `.sh` — обёртки; в остальных хуках и скриптах JSON разбирается встроенным `python3`. Других зависимостей времени выполнения нет.»
- «Архитектура», пункт `hooks/` — описания guard-main и require-plan заменить на: «`guard-main.sh` (PreToolUse на Edit/Write/MultiEdit/Bash): запрет записи в репозиторий на main/master/detached HEAD по цели записи (файл, цели команды Bash, git-мутации), без исключений. `require-plan.sh` (PreToolUse на Edit/Write/MultiEdit): правки в каталогах кода репозитория файла только при плане для ветки или файле `.bounded`. Обе — обёртки над `harness_target.py`: `parse` (команда → цели, без ФС) → `resolve` (путь → корень и ветка) → `decide`; режим `targets` — отладка разбора.»
- пункт `skills/harness-doctor/` — дописать: «`fixtures/hook-cases.jsonl` + `scripts/hook_corpus.py` — регрессионный корпус хуков (реальные команды с ожидаемым вердиктом, прогон в песочнице); лежит в skill, чтобы `doctor.sh` гонял его по установленным копиям.»
- «Команды», пункт ретро-скриптов — дописать «; `hook_cases_build.py > skills/harness-doctor/fixtures/hook-cases.jsonl` — сборка размеченного корпуса из выгрузки.» и добавить пункт: «`python3 skills/harness-doctor/scripts/hook_corpus.py hooks [--summary]` — корпус хуков по источникам. `python3 hooks/harness_target.py targets '<команда>' [cwd]` — какие цели записи видит хук.»
- «Тесты», третий пункт (про «пока не сделаны») заменить двумя: «Хуки: `test-hook-parse` — разбор команды на цели; `test-hook-guard-main`, `test-hook-require-plan` — сценарии спеки `hook-guards` в песочнице из настоящих репозиториев; `test-hook-corpus` — корпус реальных команд (assert на запись, корпус должен совпадать со сборкой `hook_cases_build.py`); `test-hook-export` — экстрактор. Плюс `role-hint` и скрипты `install.sh`, `doctor.sh`, `link-roles.sh`.» и «Правишь разбор или вердикт — сначала случай в `test-hook-parse`/`test-hook-guard-main`; меняется ожидаемый вердикт реальной команды — разметка в `hook_cases_build.py` и пересборка корпуса.»
`roles/dev.md`, раздел «Исполнение» — пункт про `cd` заменить на:
```markdown
- Пока работает фоновый SDD-контроллер, в главной сессии не делать `cd` в основной checkout: cwd оболочки общий, и команды implementer-ов с относительными путями пойдут в main — guard-main их заблокирует, и они остановятся. Команды с абсолютными путями worktree и `git -C <worktree>` от cwd не зависят.
```
`CHANGELOG.md` — над `## 0.3.0` вставить:
```markdown
## 0.4.0 — 2026-10-16
- guard-main решает по цели записи, а не по cwd и тексту команды (#6, B1; RT-1, RT-2, RT-7). Цели — файл Edit/Write, в Bash — редиректы (кроме `/dev/null` и `N>&M`), команды записи с учётом `cd`, `git -C`, `sh -c`, git-мутации. Цель вне репозитория или в worktree на ветке — пропуск; неразрешимый путь — пропуск, git-мутация с неопределённым репозиторием — блок. `git push` блокируется, только если пушит в main; `git switch -c` в той же команде переводит последующие коммиты на новую ветку. `pip install` больше не блокируется. Спека — `openspec/changes/hooks-write-target/`.
- **Несовместимо:** исключение для openspec-only коммитов и `openspec archive` на main удалено — архив всегда отдельным PR (решение владельца 1, 11.10.2026).
- require-plan берёт корень, ветку, `.claude/code-paths`, `.bounded` и планы из репозитория правимого файла.
- Общее ядро `hooks/harness_target.py` (python3 stdlib); `guard-main.sh` и `require-plan.sh` — обёртки, регистрация в `settings.json` прежняя. Режим `targets` показывает, какие цели видит хук.
- Сообщения блокировки: `BLOCKED [guard-main]:` / `BLOCKED [require-plan]:`, цель, корень репозитория, выход.
- Регрессионный корпус хуков (B2, RT-6): 126 записей из транскриптов пилота + 3 живых случая, размечены по спеке (`docs/retro/scripts/hook_cases_build.py`), прогон в песочнице — `tests/test-hook-corpus.sh` по источникам и строка «корпус хуков» в `doctor.sh` по установленным копиям. В песочнице старые хуки дают на корпусе 41 блок (37 ложных), новые — 4 (архивные коммиты в main).
- `hook_blocks_export.py` и `retro_blocks.py` считают только ошибки хуков: из 126 выгруженных записей 49 оказались выводом команд со словом BLOCKED (O-1), счёт RT-1 в ретро завышен.
- Тесты: `test-hook-parse`, `test-hook-guard-main`, `test-hook-require-plan`, `test-hook-corpus`, `test-hook-export`.
```
- [ ] **Step 8: Весь набор**
Run: `bash tests/run.sh`
Expected: `passed 448, failed 0`.
- [ ] **Step 9: Коммит**
```bash
git add install.sh skills/harness-doctor/scripts/doctor.sh skills/harness-doctor/SKILL.md tests/test-install.sh tests/test-doctor.sh README.md docs/HARNESS.md CLAUDE.md roles/dev.md CHANGELOG.md
git commit -m "feat: install/doctor ставят и проверяют ядро и корпус хуков; документация 0.4.0 (#6)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
## После задач
- [ ] `superpowers:verification-before-completion`: `bash tests/run.sh` целиком, `OPENSPEC_TELEMETRY=0 openspec validate hooks-write-target --strict`, `git status --short` пуст.
- [ ] Ревью ветки целиком (opus), находки — `C-n`/`I-n`/`M-n` со статусами.
- [ ] `superpowers:finishing-a-development-branch`: push ветки, PR в Gitea (`tea pulls create --login home`) со ссылкой на #6, метрикой из задачи 4 (шаг 9), рулингами и отложенными находками. Merge — владелец.