Сценарий спеки: запись через симлинк, указывающий в main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1604 lines
91 KiB
Markdown
1604 lines
91 KiB
Markdown
# Хуки решают по цели записи — план реализации
|
||
|
||
> **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 — владелец.
|