feat(retro): корпус хуков по сегментам и known, выгрузка блокировок за период (#29)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
andrey.epifantsev 2026-10-24 01:17:22 +04:00
parent 60d31c25db
commit 4d52ef773d
6 changed files with 232 additions and 74 deletions

View File

@ -1,14 +1,19 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Выгрузка блокировок хуков из транскриптов Claude Code в JSONL — сырьё для регрессионных тестов хуков (B2). """Выгрузка блокировок хуков из транскриптов Claude Code в JSONL — сырьё для регрессионных тестов хуков (B2).
Запуск: python3 hook_blocks_export.py <каталог-транскриптов> [<каталог> ...] > blocks.jsonl Запуск: python3 hook_blocks_export.py [--since ГГГГ-ММ-ДД] [--id-prefix <дата>] <каталог-транскриптов> [...]
Одна строка — одна блокировка: сессия, время, cwd и ветка на момент вызова, инструмент, команда или путь файла, Одна строка — одна блокировка: сессия, время, cwd и ветка на момент вызова, инструмент, команда или путь файла,
первая строка ответа хука. Поле expected не заполняется: вердикт (block/allow) проставляется при разметке. первая строка ответа хука. Поле expected не заполняется: вердикт (block/allow) проставляется при разметке.
Выгружаются только ошибки хуков (результат начинается с `PreToolUse:<инструмент> hook error`); вывод команд, Выгружаются только ошибки хуков (результат начинается с `PreToolUse:<инструмент> hook error`); вывод команд,
в котором встретилось слово BLOCKED (файлы памяти, исходники хуков), не попадает. в котором встретилось слово BLOCKED (файлы памяти, исходники хуков), не попадает.
Похожие на секреты подстроки заменяются на <redacted>. --since — только сессии с первой меткой времени не раньше даты; --id-prefix (по умолчанию — дата --since) —
поле id вида <префикс>-NNN (срез ретро, B12). Похожие на секреты подстроки заменяются на <redacted>.
""" """
import json, sys, glob, os, re import glob
import json
import os
import re
import sys
HOOK_ERROR = re.compile(r"\s*PreToolUse:\S+ hook error") HOOK_ERROR = re.compile(r"\s*PreToolUse:\S+ hook error")
@ -42,38 +47,80 @@ def hook_of(txt, tool):
return "other" return "other"
for d in sys.argv[1:]: def _records(path):
for f in sorted(glob.glob(os.path.join(os.path.expanduser(d), "*.jsonl"))): for line in open(path, encoding="utf-8", errors="replace"):
sid = os.path.basename(f)[:8] try:
pending = {} # tool_use_id -> (name, input, cwd, branch, ts) j = json.loads(line)
for line in open(f, encoding="utf-8", errors="replace"): except ValueError:
try: continue
j = json.loads(line) if isinstance(j, dict):
except Exception: yield j
def first_ts(path):
for j in _records(path):
if isinstance(j.get("timestamp"), str) and j["timestamp"]:
return j["timestamp"]
return ""
def export(dirs, since=None, prefix=None):
"""Записи выгрузки по каталогам транскриптов, по порядку файлов и строк."""
n = 0
for d in dirs:
for f in sorted(glob.glob(os.path.join(os.path.expanduser(d), "*.jsonl"))):
if since and first_ts(f)[:10] < since:
continue continue
m = j.get("message") or {} sid = os.path.basename(f)[:8]
cont = m.get("content") pending = {} # tool_use_id -> (name, input, cwd, branch, ts)
if j.get("type") == "assistant": for j in _records(f):
for b in cont or []: m = j.get("message") or {}
if isinstance(b, dict) and b.get("type") == "tool_use": cont = m.get("content")
pending[b.get("id")] = (b.get("name"), b.get("input") or {}, j.get("cwd", ""), if j.get("type") == "assistant":
j.get("gitBranch", ""), j.get("timestamp", "")) for b in cont or []:
elif j.get("type") == "user" and isinstance(cont, list): if isinstance(b, dict) and b.get("type") == "tool_use":
for b in cont: pending[b.get("id")] = (b.get("name"), b.get("input") or {}, j.get("cwd", ""),
if not (isinstance(b, dict) and b.get("type") == "tool_result"): j.get("gitBranch", ""), j.get("timestamp", ""))
continue elif j.get("type") == "user" and isinstance(cont, list):
txt = b.get("content", "") for b in cont:
if isinstance(txt, list): if not (isinstance(b, dict) and b.get("type") == "tool_result"):
txt = "\n".join(x.get("text", "") for x in txt if isinstance(x, dict)) continue
if not HOOK_ERROR.match(txt): txt = b.get("content", "")
continue # обычный вывод команды, даже если в нём есть слово BLOCKED if isinstance(txt, list):
name, inp, cwd, branch, ts = pending.get(b.get("tool_use_id"), ("?", {}, "", "", "")) txt = "\n".join(x.get("text", "") for x in txt if isinstance(x, dict))
if name not in ("Bash", "Edit", "Write", "MultiEdit"): if not isinstance(txt, str) or not HOOK_ERROR.match(txt):
continue continue # обычный вывод команды, даже если в нём есть слово BLOCKED
msg = next((l for l in txt.splitlines() if "BLOCKED" in l), "") name, inp, cwd, branch, ts = pending.get(b.get("tool_use_id"), ("?", {}, "", "", ""))
print(json.dumps({ if name not in ("Bash", "Edit", "Write", "MultiEdit"):
"session": sid, "ts": ts, "cwd": cwd, "branch": branch, "tool": name, continue
"command": redact(inp.get("command", "")) if name == "Bash" else None, msg = next((l for l in txt.splitlines() if "BLOCKED" in l), "")
"file_path": inp.get("file_path") if name != "Bash" else None, rec = {
"hook": hook_of(txt, name), "message": redact(msg[:300]), "expected": None, "session": sid, "ts": ts, "cwd": cwd, "branch": branch, "tool": name,
}, ensure_ascii=False)) "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, name), "message": redact(msg[:300]), "expected": None,
}
if prefix:
n += 1
rec = dict({"id": "%s-%03d" % (prefix, n)}, **rec)
yield rec
def main(argv):
args, dirs, since, prefix = argv[1:], [], None, None
i = 0
while i < len(args):
if args[i] == "--since" and i + 1 < len(args):
since, i = args[i + 1], i + 2
elif args[i] == "--id-prefix" and i + 1 < len(args):
prefix, i = args[i + 1], i + 2
else:
dirs.append(args[i])
i += 1
for rec in export(dirs, since, prefix or since):
print(json.dumps(rec, ensure_ascii=False))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))

View File

@ -2,19 +2,22 @@
"""Сборка регрессионного корпуса хуков из сырой выгрузки блокировок (B2, #6). """Сборка регрессионного корпуса хуков из сырой выгрузки блокировок (B2, #6).
Запуск из корня репозитория: Запуск из корня репозитория:
python3 docs/retro/scripts/hook_cases_build.py > skills/harness-doctor/fixtures/hook-cases.jsonl python3 docs/retro/scripts/hook_cases_build.py [--fixtures-dir <каталог>] > skills/harness-doctor/fixtures/hook-cases.jsonl
Вход: docs/retro/fixtures/hook-blocks.jsonl (hook_blocks_export.py, 12.10.2026, 126 записей). Вход: <fixtures>/hook-blocks.jsonl (hook_blocks_export.py, 12.10.2026, 126 записей) и пары ретро
Выход: первая строка — {"home": …, "layout": […]} (раскладка песочницы), дальше по записи на случай: hook-blocks-<дата>.jsonl + hook-labels-<дата>.json (B12, #29).
поля выгрузки + id, expected (allow|block), why (класс). Вердикт размечен вручную по спеке Выход — сегменты: строка {"home": …, "layout": […]} (раскладка песочницы), дальше по записи на случай:
поля выгрузки + id, expected (allow|block), why (класс)[, known]. Первый сегмент — пилот 12.10 и живые случаи,
дальше по сегменту на пару ретро; записи со skip не входят. Вердикт размечен вручную по спеке
openspec/specs/hook-guards/spec.md, а не прогоном хука. openspec/specs/hook-guards/spec.md, а не прогоном хука.
""" """
import glob
import json import json
import os import os
import re import re
import sys import sys
HERE = os.path.dirname(os.path.abspath(__file__)) HERE = os.path.dirname(os.path.abspath(__file__))
RAW = os.path.join(HERE, "..", "fixtures", "hook-blocks.jsonl") FIX = os.path.join(HERE, "..", "fixtures")
HOME = "/Users/andrey.epifancev" HOME = "/Users/andrey.epifancev"
AW, CH = "projects/awp-ca", "projects/cc-skills/claude-harness" AW, CH = "projects/awp-ca", "projects/cc-skills/claude-harness"
@ -77,9 +80,35 @@ def why_of(cid, r):
return "devnull" if DEVNULL.search(r.get("command") or "") else "read-only" return "devnull" if DEVNULL.search(r.get("command") or "") else "read-only"
def main(): def label_segments(fix):
"""Сегменты ретро: пары hook-blocks-<дата>.jsonl + hook-labels-<дата>.json. Разметка должна совпадать с выгрузкой."""
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)) 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()): 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) r = json.loads(line)
cid = "c%03d" % (n + 1) cid = "c%03d" % (n + 1)
r.update({"id": cid, "expected": "block" if cid in BLOCK else "allow", "why": why_of(cid, r)}) r.update({"id": cid, "expected": "block" if cid in BLOCK else "allow", "why": why_of(cid, r)})
@ -88,7 +117,9 @@ def main():
print(json.dumps({"id": cid, "session": "live-1015", "cwd": HOME + "/" + CH, "branch": "main", print(json.dumps({"id": cid, "session": "live-1015", "cwd": HOME + "/" + CH, "branch": "main",
"tool": "Bash", "command": cmd, "file_path": None, "hook": "guard-main(Bash)", "tool": "Bash", "command": cmd, "file_path": None, "hook": "guard-main(Bash)",
"message": "", "expected": "allow", "why": why}, ensure_ascii=False)) "message": "", "expected": "allow", "why": why}, ensure_ascii=False))
for obj in label_segments(fix):
print(json.dumps(obj, ensure_ascii=False))
if __name__ == "__main__": if __name__ == "__main__":
main() main(sys.argv)

View File

@ -115,7 +115,7 @@ rm -rf "$T"
HC="$CC/skills/harness-doctor/scripts/hook_corpus.py" HC="$CC/skills/harness-doctor/scripts/hook_corpus.py"
if [ -f "$HC" ] && [ -f "$CC/hooks/harness_target.py" ]; then if [ -f "$HC" ] && [ -f "$CC/hooks/harness_target.py" ]; then
out=$(python3 "$HC" "$CC/hooks" --summary 2>&1); rc=$? out=$(python3 "$HC" "$CC/hooks" --summary 2>&1); rc=$?
printf '%s\n' "$out" | grep '^MISMATCH' | sed 's/^MISMATCH / /' printf '%s\n' "$out" | grep -E '^(MISMATCH|KNOWN-FIXED) ' | sed 's/^/ /'
[ "$rc" = 0 ] && ok "$(printf '%s\n' "$out" | tail -1)" || warn "$(printf '%s\n' "$out" | tail -1) — расхождения выше" [ "$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 else miss "корпус хуков (skills/harness-doctor/scripts/hook_corpus.py, hooks/harness_target.py)"; fi

View File

@ -2,11 +2,12 @@
"""Регрессионный корпус хуков (B2): прогон fixtures/hook-cases.jsonl через guard-main и require-plan в песочнице. """Регрессионный корпус хуков (B2): прогон fixtures/hook-cases.jsonl через guard-main и require-plan в песочнице.
Запуск: python3 hook_corpus.py <каталог хуков> [--summary] [--fixtures <jsonl>] Запуск: python3 hook_corpus.py <каталог хуков> [--summary] [--fixtures <jsonl>]
Песочница строится во временном каталоге по раскладке из первой строки корпуса; префикс домашнего каталога записей Корпус — сегменты: строка с ключом layout ({home, layout}) открывает сегмент, следующие строки — его записи.
заменяется на её home, HOME хуков подменяется туда же. Bash идёт через guard-main, Edit/Write — через guard-main и Для каждого сегмента строится своя песочница; префикс home записей заменяется на её home, HOME хуков — туда же.
require-plan (блок, если блокирует любой). Bash идёт через guard-main, Edit/Write — через guard-main и require-plan (блок, если блокирует любой).
Вывод: строка на запись «id<TAB>why<TAB>ожидалось<TAB>получено<TAB>команда» и итог «total N/M»; Запись с known — известная ошибка хуков: расхождение — KNOWN (не ошибка прогона), совпадение — KNOWN-FIXED (ошибка:
с --summary — только расхождения и «корпус хуков: N/M». Код выхода 0 — всё совпало, 1 — нет. снять known). Вывод: строка «id<TAB>why<TAB>ожидалось<TAB>получено<TAB>статус<TAB>команда» и итог «total N/M»;
с --summary — только MISMATCH и KNOWN-FIXED и «корпус хуков: N/M[, известных ошибок K]». Код 0 — нет ошибок.
""" """
import json import json
import os import os
@ -52,6 +53,23 @@ def verdict(hooks, case, prefix, home, env):
return "allow" return "allow"
def segments(lines):
"""[(шапка, [записи])]: строка с ключом layout открывает сегмент."""
segs = []
for obj in lines:
if "layout" in obj:
segs.append((obj, []))
elif segs:
segs[-1][1].append(obj)
return segs
def status(case, got):
if got == case["expected"]:
return "KNOWN-FIXED" if case.get("known") else "ok"
return "KNOWN" if case.get("known") else "MISMATCH"
def main(argv): def main(argv):
args = argv[1:] args = argv[1:]
summary = "--summary" in args summary = "--summary" in args
@ -64,29 +82,33 @@ def main(argv):
return 1 return 1
hooks = os.path.abspath(pos[0]) hooks = os.path.abspath(pos[0])
lines = [json.loads(l) for l in open(fx, encoding="utf-8") if l.strip()] lines = [json.loads(l) for l in open(fx, encoding="utf-8") if l.strip()]
head, cases = lines[0], lines[1:] env0 = {k: v for k, v in os.environ.items() if k not in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE")}
tmp = tempfile.mkdtemp(prefix="hook-corpus-") tmp = tempfile.mkdtemp(prefix="hook-corpus-")
try: try:
home = os.path.join(tmp, "home") total = good = known = 0
try: for n, (head, cases) in enumerate(segments(lines)):
build(home, head["layout"]) home = os.path.join(tmp, "seg-%d" % n, "home")
except Exception as e: try:
print("корпус хуков: песочница не собрана (%s)" % e) build(home, head["layout"])
return 1 except Exception as e:
env = {k: v for k, v in os.environ.items() if k not in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE")} print("корпус хуков: песочница сегмента %d не собрана (%s)" % (n + 1, e))
env["HOME"] = home return 1
ok = 0 env = dict(env0, HOME=home)
for c in cases: for c in cases:
got = verdict(hooks, c, head["home"], home, env) got = verdict(hooks, c, head["home"], home, env)
ok += got == c["expected"] st = status(c, got)
body = (c.get("command") or c.get("file_path") or "").replace(head["home"], "~") total += 1
row = "\t".join([c["id"], c["why"], c["expected"], got, " ".join(body.split())[:70]]) good += st in ("ok", "KNOWN")
if not summary: known += st == "KNOWN"
print(row) body = (c.get("command") or c.get("file_path") or "").replace(head["home"], "~")
elif got != c["expected"]: row = "\t".join([c["id"], c["why"], c["expected"], got, st, " ".join(body.split())[:70]])
print("MISMATCH " + row) if not summary:
print(("корпус хуков: %d/%d" if summary else "total %d/%d") % (ok, len(cases))) print(row)
return 0 if ok == len(cases) else 1 elif st in ("MISMATCH", "KNOWN-FIXED"):
print(st + " " + row)
tail = ", известных ошибок %d" % known if known else ""
print(("корпус хуков: %d/%d%s" if summary else "total %d/%d%s") % (good, total, tail))
return 0 if good == total else 1
finally: finally:
shutil.rmtree(tmp, ignore_errors=True) shutil.rmtree(tmp, ignore_errors=True)

View File

@ -4,7 +4,55 @@ F="$ROOT/skills/harness-doctor/fixtures/hook-cases.jsonl"
assert_file "hook_corpus.py существует" "$R" assert_file "hook_corpus.py существует" "$R"
assert_file "корпус hook-cases.jsonl" "$F" assert_file "корпус hook-cases.jsonl" "$F"
assert_eq "корпус совпадает со сборкой из выгрузки" "$(python3 "$ROOT/docs/retro/scripts/hook_cases_build.py" | cmp -s - "$F" && echo same)" "same" 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 while IFS=$'\t' read -r id why exp got st cmd; do
case "$id" in total*) continue ;; esac case "$id" in total*) continue ;; esac
assert_eq "корпус $id ($why): $cmd" "$got" "$exp" case "$st" in
ok|KNOWN) ok "корпус $id ($why)" ;;
*) fail "корпус $id ($why): $cmd" "ожидалось $exp, получено $got ($st)" ;;
esac
done < <(python3 "$R" "$ROOT/hooks") done < <(python3 "$R" "$ROOT/hooks")
# сегменты и known (#29): синтетический корпус из двух сегментов с разными home
S="$TMP/seg"; mkdir -p "$S"
cat > "$S/c.jsonl" <<'EOF'
{"home": "/h1", "layout": [{"path": "m", "branch": "main"}]}
{"id": "a1", "why": "main", "tool": "Write", "file_path": "/h1/m/a.md", "cwd": "/h1/m", "expected": "block"}
{"id": "a2", "why": "known-x", "tool": "Write", "file_path": "/h1/m/b.md", "cwd": "/h1/m", "expected": "allow", "known": "RT-21"}
{"home": "/h2", "layout": [{"path": "r", "branch": "feat/x"}]}
{"id": "b1", "why": "branch", "tool": "Write", "file_path": "/h2/r/a.md", "cwd": "/h2/r", "expected": "allow"}
EOF
out=$(python3 "$R" "$ROOT/hooks" --summary --fixtures "$S/c.jsonl"); rc=$?
assert_eq "два сегмента и известная ошибка: exit 0" "$rc" "0"
assert_eq "итог с известными ошибками" "$out" "корпус хуков: 3/3, известных ошибок 1"
sed 's/"expected": "allow", "known"/"expected": "block", "known"/' "$S/c.jsonl" > "$S/f.jsonl"
out=$(python3 "$R" "$ROOT/hooks" --summary --fixtures "$S/f.jsonl"); rc=$?
assert_eq "known совпал с вердиктом: exit 1" "$rc" "1"
assert_grep "KNOWN-FIXED с id записи" '^KNOWN-FIXED a2' <(printf '%s' "$out")
# разметка ретро (#29): hook-labels-<дата>.json + hook-blocks-<дата>.jsonl → новый сегмент корпуса
B="$ROOT/docs/retro/scripts/hook_cases_build.py"
FX="$TMP/fx"; mkdir -p "$FX"; cp "$ROOT/docs/retro/fixtures/hook-blocks.jsonl" "$FX/"
cat > "$FX/hook-blocks-2026-11-01.jsonl" <<'EOF'
{"id": "2026-11-01-001", "session": "s", "ts": "2026-11-02T10:00:00Z", "cwd": "/h3/r", "branch": "main", "tool": "Bash", "command": "ls x 2>/dev/null", "file_path": null, "hook": "guard-main(Bash)", "message": "BLOCKED", "expected": null}
{"id": "2026-11-01-002", "session": "s", "ts": "2026-11-02T10:01:00Z", "cwd": "/h3/r", "branch": "main", "tool": "Bash", "command": "echo x > f", "file_path": null, "hook": "guard-main(Bash)", "message": "BLOCKED", "expected": null}
{"id": "2026-11-01-003", "session": "s", "ts": "2026-11-02T10:02:00Z", "cwd": "/h3/r", "branch": "main", "tool": "Bash", "command": "cat $X", "file_path": null, "hook": "guard-main(Bash)", "message": "BLOCKED", "expected": null}
EOF
cat > "$FX/hook-labels-2026-11-01.json" <<'EOF'
{"home": "/h3", "layout": [{"path": "r", "branch": "main"}],
"cases": {"2026-11-01-001": {"expected": "allow", "why": "devnull"},
"2026-11-01-002": {"expected": "block", "why": "main-redirect"},
"2026-11-01-003": {"skip": "цель в переменной, состояние неизвестно"}}}
EOF
out=$(python3 "$B" --fixtures-dir "$FX"); rc=$?
assert_eq "сборка с разметкой ретро: exit 0" "$rc" "0"
assert_eq "в корпусе два сегмента" "$(printf '%s\n' "$out" | grep -c '"layout"')" "2"
assert_eq "сегмент ретро: две записи" "$(printf '%s\n' "$out" | grep -c '"2026-11-01-00[12]"')" "2"
assert_eq "запись со skip не в корпусе" "$(printf '%s\n' "$out" | grep -c '2026-11-01-003')" "0"
printf '%s\n' "$out" > "$FX/cases.jsonl"
assert_eq "корпус с сегментом ретро проходит хуки" "$(python3 "$R" "$ROOT/hooks" --summary --fixtures "$FX/cases.jsonl" | tail -1)" "корпус хуков: 131/131"
python3 - "$FX/hook-labels-2026-11-01.json" <<'PY'
import json, sys
p = sys.argv[1]; d = json.load(open(p)); del d["cases"]["2026-11-01-002"]; d["cases"]["2026-11-01-009"] = {"skip": "x"}
json.dump(d, open(p, "w"))
PY
out=$(python3 "$B" --fixtures-dir "$FX" 2>&1 >/dev/null); rc=$?
assert_eq "разметка не совпадает с выгрузкой: exit 1" "$rc" "1"
assert_grep "сообщение называет недостающий и лишний id" '2026-11-01-002.*2026-11-01-009' <(printf '%s' "$out")

View File

@ -13,3 +13,13 @@ out=$(python3 "$X" "$T")
assert_eq "две записи: вывод cat с BLOCKED не выгружен" "$(printf '%s\n' "$out" | grep -c .)" "2" 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(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" 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"
# --since и --id-prefix (B12, #29): только сессии не раньше даты, id <дата>-NNN; модуль импортируется без выгрузки
T2="$TMP/tr2"; mkdir -p "$T2"
blk='{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"u1","is_error":true,"content":"PreToolUse:Bash hook error: BLOCKED [guard-main]: запись в f"}]}}'
printf '%s\n%s\n' '{"type":"assistant","cwd":"/r","gitBranch":"main","timestamp":"2026-10-01T10:00:00Z","message":{"content":[{"type":"tool_use","id":"u1","name":"Bash","input":{"command":"echo a > f"}}]}}' "$blk" > "$T2/old.jsonl"
printf '%s\n%s\n' '{"type":"assistant","cwd":"/r","gitBranch":"main","timestamp":"2026-11-02T10:00:00Z","message":{"content":[{"type":"tool_use","id":"u1","name":"Bash","input":{"command":"echo b > f"}}]}}' "$blk" > "$T2/new.jsonl"
out=$(python3 "$X" --since 2026-11-01 --id-prefix 2026-11-05 "$T2")
assert_eq "--since: только сессия не раньше даты" "$(printf '%s\n' "$out" | grep -c .)" "1"
assert_eq "--id-prefix: id <дата>-NNN" "$(printf '%s\n' "$out" | python3 -c 'import json,sys; r=json.loads(sys.stdin.readline()); print(r["id"], r["command"])')" "2026-11-05-001 echo b > f"
assert_eq "без флагов поля id нет (выгрузка 12.10 не меняется)" "$(python3 "$X" "$T2" | python3 -c 'import json,sys; print(sum("id" in json.loads(l) for l in sys.stdin))')" "0"
assert_eq "импорт модуля не выгружает" "$(python3 -c "import sys; sys.argv=['x', '$T2']; sys.path.insert(0, '$ROOT/docs/retro/scripts'); import hook_blocks_export" 2>&1)" ""