From 4d52ef773daa9daa0bd52aecc7cb00e9269c0b54 Mon Sep 17 00:00:00 2001 From: "andrey.epifantsev" Date: Sat, 24 Oct 2026 01:17:22 +0400 Subject: [PATCH] =?UTF-8?q?feat(retro):=20=D0=BA=D0=BE=D1=80=D0=BF=D1=83?= =?UTF-8?q?=D1=81=20=D1=85=D1=83=D0=BA=D0=BE=D0=B2=20=D0=BF=D0=BE=20=D1=81?= =?UTF-8?q?=D0=B5=D0=B3=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D0=BC=20=D0=B8=20kno?= =?UTF-8?q?wn,=20=D0=B2=D1=8B=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B0=20=D0=B1?= =?UTF-8?q?=D0=BB=D0=BE=D0=BA=D0=B8=D1=80=D0=BE=D0=B2=D0=BE=D0=BA=20=D0=B7?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=B5=D1=80=D0=B8=D0=BE=D0=B4=20(#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/retro/scripts/hook_blocks_export.py | 121 +++++++++++++------ docs/retro/scripts/hook_cases_build.py | 47 +++++-- skills/harness-doctor/scripts/doctor.sh | 2 +- skills/harness-doctor/scripts/hook_corpus.py | 74 ++++++++---- tests/test-hook-corpus.sh | 52 +++++++- tests/test-hook-export.sh | 10 ++ 6 files changed, 232 insertions(+), 74 deletions(-) diff --git a/docs/retro/scripts/hook_blocks_export.py b/docs/retro/scripts/hook_blocks_export.py index 96b47ef..3183d54 100644 --- a/docs/retro/scripts/hook_blocks_export.py +++ b/docs/retro/scripts/hook_blocks_export.py @@ -1,14 +1,19 @@ #!/usr/bin/env python3 """Выгрузка блокировок хуков из транскриптов Claude Code в JSONL — сырьё для регрессионных тестов хуков (B2). -Запуск: python3 hook_blocks_export.py <каталог-транскриптов> [<каталог> ...] > blocks.jsonl +Запуск: python3 hook_blocks_export.py [--since ГГГГ-ММ-ДД] [--id-prefix <дата>] <каталог-транскриптов> [...] Одна строка — одна блокировка: сессия, время, cwd и ветка на момент вызова, инструмент, команда или путь файла, первая строка ответа хука. Поле expected не заполняется: вердикт (block/allow) проставляется при разметке. Выгружаются только ошибки хуков (результат начинается с `PreToolUse:<инструмент> hook error`); вывод команд, в котором встретилось слово BLOCKED (файлы памяти, исходники хуков), не попадает. -Похожие на секреты подстроки заменяются на . +--since — только сессии с первой меткой времени не раньше даты; --id-prefix (по умолчанию — дата --since) — +поле id вида <префикс>-NNN (срез ретро, B12). Похожие на секреты подстроки заменяются на . """ -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") @@ -42,38 +47,80 @@ def hook_of(txt, tool): 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: +def _records(path): + for line in open(path, encoding="utf-8", errors="replace"): + try: + j = json.loads(line) + except ValueError: + continue + if isinstance(j, dict): + 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 - 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 HOOK_ERROR.match(txt): - continue # обычный вывод команды, даже если в нём есть слово BLOCKED - 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, name), "message": redact(msg[:300]), "expected": None, - }, ensure_ascii=False)) + sid = os.path.basename(f)[:8] + pending = {} # tool_use_id -> (name, input, cwd, branch, ts) + for j in _records(f): + 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 isinstance(txt, str) or not HOOK_ERROR.match(txt): + continue # обычный вывод команды, даже если в нём есть слово BLOCKED + 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), "") + rec = { + "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, 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)) diff --git a/docs/retro/scripts/hook_cases_build.py b/docs/retro/scripts/hook_cases_build.py index 392ad2c..e63cf0a 100644 --- a/docs/retro/scripts/hook_cases_build.py +++ b/docs/retro/scripts/hook_cases_build.py @@ -2,19 +2,22 @@ """Сборка регрессионного корпуса хуков из сырой выгрузки блокировок (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 (класс). Вердикт размечен вручную по спеке + python3 docs/retro/scripts/hook_cases_build.py [--fixtures-dir <каталог>] > skills/harness-doctor/fixtures/hook-cases.jsonl +Вход: /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__)) -RAW = os.path.join(HERE, "..", "fixtures", "hook-blocks.jsonl") +FIX = os.path.join(HERE, "..", "fixtures") HOME = "/Users/andrey.epifancev" 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" -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)) - 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) cid = "c%03d" % (n + 1) 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", "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() + main(sys.argv) diff --git a/skills/harness-doctor/scripts/doctor.sh b/skills/harness-doctor/scripts/doctor.sh index a9d8da5..baedccb 100755 --- a/skills/harness-doctor/scripts/doctor.sh +++ b/skills/harness-doctor/scripts/doctor.sh @@ -115,7 +115,7 @@ rm -rf "$T" 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 / /' + 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) — расхождения выше" else miss "корпус хуков (skills/harness-doctor/scripts/hook_corpus.py, hooks/harness_target.py)"; fi diff --git a/skills/harness-doctor/scripts/hook_corpus.py b/skills/harness-doctor/scripts/hook_corpus.py index 5457ed5..35e728d 100644 --- a/skills/harness-doctor/scripts/hook_corpus.py +++ b/skills/harness-doctor/scripts/hook_corpus.py @@ -2,11 +2,12 @@ """Регрессионный корпус хуков (B2): прогон fixtures/hook-cases.jsonl через guard-main и require-plan в песочнице. Запуск: python3 hook_corpus.py <каталог хуков> [--summary] [--fixtures ] -Песочница строится во временном каталоге по раскладке из первой строки корпуса; префикс домашнего каталога записей -заменяется на её home, HOME хуков подменяется туда же. Bash идёт через guard-main, Edit/Write — через guard-main и -require-plan (блок, если блокирует любой). -Вывод: строка на запись «idwhyожидалосьполученокоманда» и итог «total N/M»; -с --summary — только расхождения и «корпус хуков: N/M». Код выхода 0 — всё совпало, 1 — нет. +Корпус — сегменты: строка с ключом layout ({home, layout}) открывает сегмент, следующие строки — его записи. +Для каждого сегмента строится своя песочница; префикс home записей заменяется на её home, HOME хуков — туда же. +Bash идёт через guard-main, Edit/Write — через guard-main и require-plan (блок, если блокирует любой). +Запись с known — известная ошибка хуков: расхождение — KNOWN (не ошибка прогона), совпадение — KNOWN-FIXED (ошибка: +снять known). Вывод: строка «idwhyожидалосьполученостатускоманда» и итог «total N/M»; +с --summary — только MISMATCH и KNOWN-FIXED и «корпус хуков: N/M[, известных ошибок K]». Код 0 — нет ошибок. """ import json import os @@ -52,6 +53,23 @@ def verdict(hooks, case, prefix, home, env): 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): args = argv[1:] summary = "--summary" in args @@ -64,29 +82,33 @@ def main(argv): 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:] + 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-") 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 + total = good = known = 0 + for n, (head, cases) in enumerate(segments(lines)): + home = os.path.join(tmp, "seg-%d" % n, "home") + try: + build(home, head["layout"]) + except Exception as e: + print("корпус хуков: песочница сегмента %d не собрана (%s)" % (n + 1, e)) + return 1 + env = dict(env0, HOME=home) + for c in cases: + got = verdict(hooks, c, head["home"], home, env) + st = status(c, got) + total += 1 + good += st in ("ok", "KNOWN") + known += st == "KNOWN" + body = (c.get("command") or c.get("file_path") or "").replace(head["home"], "~") + row = "\t".join([c["id"], c["why"], c["expected"], got, st, " ".join(body.split())[:70]]) + if not summary: + print(row) + 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: shutil.rmtree(tmp, ignore_errors=True) diff --git a/tests/test-hook-corpus.sh b/tests/test-hook-corpus.sh index b3d5d0b..aee9b52 100644 --- a/tests/test-hook-corpus.sh +++ b/tests/test-hook-corpus.sh @@ -4,7 +4,55 @@ 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 +while IFS=$'\t' read -r id why exp got st cmd; do 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") +# сегменты и 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") diff --git a/tests/test-hook-export.sh b/tests/test-hook-export.sh index 46a7caa..4de0460 100644 --- a/tests/test-hook-export.sh +++ b/tests/test-hook-export.sh @@ -13,3 +13,13 @@ 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" +# --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)" ""