B12: /harness-retro — срез метрик, поправки владельца, хвосты закрытия, корпус по сегментам (#29, 0.6.0) #30

Merged
aep merged 10 commits from feat/29-harness-retro into main 2026-10-24 09:08:01 +03:00
6 changed files with 232 additions and 74 deletions
Showing only changes of commit 4d52ef773d - Show all commits

View File

@ -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 (файлы памяти, исходники хуков), не попадает.
Похожие на секреты подстроки заменяются на <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")
@ -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))

View File

@ -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
Вход: <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__))
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)

View File

@ -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

View File

@ -2,11 +2,12 @@
"""Регрессионный корпус хуков (B2): прогон fixtures/hook-cases.jsonl через guard-main и require-plan в песочнице.
Запуск: python3 hook_corpus.py <каталог хуков> [--summary] [--fixtures <jsonl>]
Песочница строится во временном каталоге по раскладке из первой строки корпуса; префикс домашнего каталога записей
заменяется на её home, HOME хуков подменяется туда же. Bash идёт через guard-main, Edit/Write — через guard-main и
require-plan (блок, если блокирует любой).
Вывод: строка на запись «id<TAB>why<TAB>ожидалось<TAB>получено<TAB>команда» и итог «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). Вывод: строка «id<TAB>why<TAB>ожидалось<TAB>получено<TAB>статус<TAB>команда» и итог «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)

View File

@ -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")

View File

@ -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)" ""