#!/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()