claude-harness/docs/retro/scripts/hook_cases_build.py
andrey.epifantsev 3d2ce3f41d fix(retro): финальное ревью — сабагенты, период по записям, разбор Bash, redact до обрезки, --from (#29)
- C-1: транскрипты сабагентов (<сессия>/subagents/*.jsonl) вливаются в
  сессию-родителя — ходы, токены, инструменты, codegraph, блокировки,
  отказы; промпты, роль, стартовый контекст — только из основного.
  hook_blocks_export.py выгружает их блокировки с сессией-родителем.
- I-7: период режется по меткам времени записей; сессия на стыке
  делится между ретро, стартовый контекст — если первый ход в периоде.
- I-1: простые команды Bash — shlex с punctuation_chars, «|» в кавычках
  команду не делит. I-2: правило src/ ловит и сам каталог src.
- I-3: clip() — redact до обрезки (кандидаты, выгрузка), роль — redact.
- I-4: без --since период берётся из существующего --out; --from рисует
  «Цифры» из сохранённого среза. SKILL.md передаёт период явно.
- I-5: выгрузка ретро без разметки — сборка корпуса падает.
- I-6: сводка компакции — не промпт; «перечитай» — побуждение перечитать.
- 2026-10-11.metrics.json: несравнимое помечено (без сабагентов, широкий
  «перечитай»). Спека и design.md (Decisions) обновлены.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:59:41 +04:00

131 lines
7.8 KiB
Python
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.

#!/usr/bin/env python3
"""Сборка регрессионного корпуса хуков из сырой выгрузки блокировок (B2, #6).
Запуск из корня репозитория:
python3 docs/retro/scripts/hook_cases_build.py [--fixtures-dir <каталог>] > skills/harness-doctor/fixtures/hook-cases.jsonl
Вход: <fixtures>/hook-blocks.jsonl (hook_blocks_export.py, 12.10.2026, 126 записей) и пары ретро
hook-blocks-<дата>.jsonl + hook-labels-<дата>.json (B12, #29).
Выход — сегменты: строка {"home": …, "layout": […]} (раскладка песочницы), дальше по записи на случай:
поля выгрузки + id, expected (allow|block), why (класс)[, known]. Первый сегмент — пилот 12.10 и живые случаи,
дальше по сегменту на пару ретро; записи со skip не входят. Вердикт размечен вручную по спеке
openspec/specs/hook-guards/spec.md, а не прогоном хука.
"""
import glob
import json
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
FIX = os.path.join(HERE, "..", "fixtures")
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 label_segments(fix):
"""Сегменты ретро: пары hook-blocks-<дата>.jsonl + hook-labels-<дата>.json. Разметка должна совпадать с выгрузкой."""
for bp in sorted(glob.glob(os.path.join(fix, "hook-blocks-*.jsonl"))):
date = os.path.basename(bp)[len("hook-blocks-"):-len(".jsonl")]
if not os.path.isfile(os.path.join(fix, "hook-labels-%s.json" % date)):
raise SystemExit("%s: нет разметки hook-labels-%s.json — ретро не уходит с неразмеченными блокировками"
% (os.path.basename(bp), date))
for lp in sorted(glob.glob(os.path.join(fix, "hook-labels-*.json"))):
date = os.path.basename(lp)[len("hook-labels-"):-len(".json")]
lab = json.load(open(lp, encoding="utf-8"))
cases = lab.get("cases", {})
recs = [json.loads(l) for l in open(os.path.join(fix, "hook-blocks-%s.jsonl" % date), encoding="utf-8")
if l.strip()]
ids = [r.get("id") for r in recs]
missing, extra = [i for i in ids if i not in cases], sorted(set(cases) - set(ids))
if missing or extra:
raise SystemExit("hook-labels-%s.json: нет разметки для %s; лишняя разметка %s"
% (date, ", ".join(missing) or "—", ", ".join(extra) or "—"))
yield {"home": lab["home"], "layout": lab["layout"]}
for r in recs:
c = cases[r["id"]]
if "skip" in c:
continue
r.update({"expected": c["expected"], "why": c["why"]})
if c.get("known"):
r["known"] = c["known"]
yield r
def main(argv):
fix = argv[argv.index("--fixtures-dir") + 1] if "--fixtures-dir" in argv else FIX
print(json.dumps({"home": HOME, "layout": LAYOUT}, ensure_ascii=False))
raw = os.path.join(fix, "hook-blocks.jsonl")
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))
for obj in label_segments(fix):
print(json.dumps(obj, ensure_ascii=False))
if __name__ == "__main__":
main(sys.argv)