diff --git a/CLAUDE.md b/CLAUDE.md index b93eee1..cc298a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ - `bash skills/harness-doctor/scripts/doctor.sh [проект]` — отчёт и самотест хуков. Только чтение. - `bash adopt-project.sh <папка> [--no-merge] [--push]` — подключить существующий проект. Из терминала, не из Claude Code. - `bash new-project.sh <папка> [ssh-url]` — каркас нового проекта. -- `python3 docs/retro/scripts/retro_stats.py <каталог транскриптов>` и `retro_blocks.py` — статистика сессий и блокировок хуков для ретро. +- `python3 docs/retro/scripts/retro_stats.py <каталог транскриптов>` и `retro_blocks.py` — статистика сессий и блокировок хуков для ретро; `hook_blocks_export.py <каталог> [...]` — выгрузка блокировок в JSONL (сырьё для тестов хуков). ## Тесты - `tests/run.sh` — автоматические тесты источников (хуки через фиктивный JSON, скрипты во временных каталогах, размеры). Самотест хуков в `doctor.sh` проверяет установленные копии; перед ним `bash install.sh`. @@ -36,7 +36,10 @@ - `tests/` покрывает `role-hint` и скрипты `install.sh`, `doctor.sh`, `link-roles.sh`; регрессионные тесты `guard-main` и `require-plan` на реальных командах из транскриптов (бэклог ретро B2) пока не сделаны. ## Текущая работа -Бэклог обвязки — `docs/retro/2026-10-11.md`, раздел 4 (B1–B12), решения владельца — раздел 5. Порядок: B1+B2 (guard-main по цели записи, тесты хуков), затем B5+B6 (роли и память, спека `docs/superpowers/specs/2026-10-11-roles-and-memory-design.md`), затем B3/B4/B10. Находки ретро маркируются `RT-n`. +Бэклог обвязки — `docs/retro/2026-10-11.md`, раздел 4 (B1–B12), решения владельца — раздел 5. Находки ретро маркируются `RT-n`. +- Сделано: B5+B6 часть 1 — слой ролей (PR #4, 0.3.0). Часть 2 (awp-ca) — issue #2, отложенные находки — issue #5. +- В работе: B1+B2 и отмена исключения openspec-only для guard-main — issue #6, ветка `feat/6-guard-main-target`. +- Дальше: B3/B4/B10, B12. ## OpenSpec и superpowers - Вход в любую задачу — superpowers:brainstorming. Skills openspec-propose и openspec-explore сами по себе не запускать. diff --git a/docs/retro/scripts/hook_blocks_export.py b/docs/retro/scripts/hook_blocks_export.py new file mode 100644 index 0000000..f37f405 --- /dev/null +++ b/docs/retro/scripts/hook_blocks_export.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Выгрузка блокировок хуков из транскриптов Claude Code в JSONL — сырьё для регрессионных тестов хуков (B2). + +Запуск: python3 hook_blocks_export.py <каталог-транскриптов> [<каталог> ...] > blocks.jsonl +Одна строка — одна блокировка: сессия, время, cwd и ветка на момент вызова, инструмент, команда или путь файла, +первая строка ответа хука. Поле expected не заполняется: вердикт (block/allow) проставляется при разметке. +Похожие на секреты подстроки заменяются на . +""" +import json, sys, glob, os, re + +RE_SECRET = re.compile( + r'(?i)gh[pousr]_[A-Za-z0-9]{20,}|sk-[A-Za-z0-9_-]{20,}|\b[0-9a-f]{40}\b' + r'|(?P(?:token|password|passwd|secret|api[_-]?key)\s*[=:]\s*)\S+' + r'|(?P--(?:token|password)\s+)\S+|(?PAuthorization:\s*\S+\s+)\S+' +) + + +def redact(s): + def sub(m): + keep = m.group("kv") or m.group("flag") or m.group("auth") or "" + return keep + "" + return RE_SECRET.sub(sub, s) + + +def hook_of(txt): + if "shell write" in txt: + return "guard-main(Bash)" + if "you are on" in txt: + return "guard-main(Edit/Write)" + if "without a plan" in txt: + return "require-plan" + if "commit/push в main" in txt or "ветке main" in txt or "reference-old-arm" in txt or "docs/source" in txt: + return "workspace-guard" + return "other" + + +for d in sys.argv[1:]: + for f in sorted(glob.glob(os.path.join(os.path.expanduser(d), "*.jsonl"))): + sid = os.path.basename(f)[:8] + pending = {} # tool_use_id -> (name, input, cwd, branch, ts) + for line in open(f, encoding="utf-8", errors="replace"): + try: + j = json.loads(line) + except Exception: + continue + m = j.get("message") or {} + cont = m.get("content") + if j.get("type") == "assistant": + for b in cont or []: + if isinstance(b, dict) and b.get("type") == "tool_use": + pending[b.get("id")] = (b.get("name"), b.get("input") or {}, j.get("cwd", ""), + j.get("gitBranch", ""), j.get("timestamp", "")) + elif j.get("type") == "user" and isinstance(cont, list): + for b in cont: + if not (isinstance(b, dict) and b.get("type") == "tool_result"): + continue + txt = b.get("content", "") + if isinstance(txt, list): + txt = "\n".join(x.get("text", "") for x in txt if isinstance(x, dict)) + if not ("BLOCKED" in txt and ("hook" in txt.lower() or "BLOCKED:" in txt[:400])): + continue + name, inp, cwd, branch, ts = pending.get(b.get("tool_use_id"), ("?", {}, "", "", "")) + if name not in ("Bash", "Edit", "Write", "MultiEdit"): + continue + msg = next((l for l in txt.splitlines() if "BLOCKED" in l), "") + print(json.dumps({ + "session": sid, "ts": ts, "cwd": cwd, "branch": branch, "tool": name, + "command": redact(inp.get("command", "")) if name == "Bash" else None, + "file_path": inp.get("file_path") if name != "Bash" else None, + "hook": hook_of(txt), "message": redact(msg[:300]), "expected": None, + }, ensure_ascii=False))