#!/usr/bin/env python3 """Срез метрик обвязки по транскриптам и памяти одного проекта или workspace (B12, #29). Запуск: python3 retro_metrics.py <папка проекта> [--since ГГГГ-ММ-ДД] [--harness <репозиторий обвязки>] [--projects-root <каталог>] [--labels ] [--out ] [--markdown [--prev <прошлый.metrics.json>]] [--corrections] Без флагов вывода печатает JSON метрик (схема 1); --out пишет его в файл; --markdown печатает таблицу «Цифры» со сравнением с --prev; --corrections — кандидаты в поправки владельца, по JSON-строке на кандидата. Только чтение транскриптов и памяти. Спека: openspec/specs/harness-retro/spec.md. """ import argparse import collections import glob import json import os import re import shlex import statistics import sys HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) from hook_blocks_export import HOOK_ERROR, hook_of, redact # noqa: E402 INTERRUPT = "[Request interrupted by user" OWNER_DENY = "The user doesn't want to proceed with this tool use" CLASSIFIER = re.compile(r"denied by the Claude Code auto mode classifier\. Reason: \[([^\]]+)\]") RECALL = re.compile(r"памят|перечитай|сверься|контекст|вспомни|прочитай .*md", re.I) MARKERS = re.compile(r"(? os.path.getsize(best[sid]): best[sid] = f files = [] for f in sorted(best.values()): cwd, ts = first_meta(f) if not cwd or not (inside(cwd, project) or inside(cwd, project + ".wt")): continue if since and (not ts or ts[:10] < since): continue files.append(f) return sorted(set(os.path.basename(os.path.dirname(f)) for f in files)), files def blocks(rec): c = (rec.get("message") or {}).get("content") if isinstance(c, str): return [{"type": "text", "text": c}] return [b for b in c if isinstance(b, dict)] if isinstance(c, list) else [] def result_text(b): c = b.get("content", "") if isinstance(c, list): return "\n".join(x.get("text", "") for x in c if isinstance(x, dict)) return c if isinstance(c, str) else "" def prompt_text(rec): """Текст промпта владельца или None: служебное, tool_result, прерывание, сабагент.""" if rec.get("type") != "user" or rec.get("isMeta") or rec.get("isSidechain"): return None if rec.get("userType", "external") != "external": return None bs = blocks(rec) if any(b.get("type") == "tool_result" for b in bs): return None txt = "\n".join(b.get("text") or "" for b in bs if b.get("type") == "text").strip() if not txt or txt.startswith("<") or txt.startswith(INTERRUPT): return None return txt def is_interrupt(rec): return rec.get("type") == "user" and any( b.get("type") == "text" and (b.get("text") or "").startswith(INTERRUPT) for b in blocks(rec)) def split_repo(path): """(основной репозиторий, путь от корня worktree или репозитория) или (None, None).""" p = os.path.normpath(path) if path else "" m = re.match(r"^(.+?)/\.claude/worktrees/[^/]+/(.*)$", p) if m: return m.group(1), m.group(2) m = re.match(r"^(.+)/\.wt/[^/]+/([^/]+)/(.*)$", p) if m: return m.group(1) + "/" + m.group(2), m.group(3) m = re.match(r"^(.*[^/])\.wt/[^/]+/(.*)$", p) if m: return m.group(1), m.group(2) d = os.path.dirname(p) while d and d != os.path.dirname(d): if os.path.exists(os.path.join(d, ".git")): return d, os.path.relpath(p, d) d = os.path.dirname(d) return None, None def code_prefixes(repo, cache): if repo not in cache: cp = os.path.join(repo, ".claude", "code-paths") if os.path.isfile(cp): pre = [l.split("#", 1)[0].strip().rstrip("/") for l in open(cp, encoding="utf-8", errors="replace")] cache[repo] = [x for x in pre if x] else: cache[repo] = None return cache[repo] def is_code(path, cache): repo, rel = split_repo(path) if not repo: return False pre = code_prefixes(repo, cache) if pre is None: return "/src/" in "/" + rel return any(rel == x or rel.startswith(x + "/") for x in pre) def bash_reads_code(cmd, cwd, cache): for part in SEPARATORS.split(cmd or ""): try: words = shlex.split(part) except ValueError: words = part.split() if not words or not (words[0] in READERS or (words[0] == "sed" and "-n" in words[1:2])): continue for w in words[1:]: if w.startswith("-") or not (os.path.isabs(w) or cwd): continue if is_code(w if os.path.isabs(w) else os.path.join(cwd, w), cache): return True return False def action_of(b): name, inp = b.get("name") or "?", b.get("input") or {} if name == "Bash": return "Bash: " + " ".join((inp.get("command") or "").split()) if name in FILE_TOOLS: return "%s: %s" % (name, inp.get("file_path") or inp.get("notebook_path") or "") return name def count_tool(s, b, cwd, cache): name, inp = b.get("name") or "?", b.get("input") or {} s["tools"][name] += 1 if name in ("Agent", "Task"): s["subagents"][inp.get("model") or "не указана"] += 1 elif name == "Skill": sk = inp.get("skill") or "?" s["skills"][sk] += 1 if s["role"] is None and (sk == "role" or sk.endswith(":role")): s["role"] = (inp.get("args") or "").strip() or "?" if "codegraph_explore" in name: s["cg_explore"] += 1 elif name == "Read": s["code_reads"] += is_code(inp.get("file_path") or "", cache) elif name in ("Grep", "Glob"): s["code_reads"] += 1 elif name == "Bash": cmd = inp.get("command") or "" s["cg_init"] += "codegraph init" in cmd s["code_reads"] += bash_reads_code(cmd, cwd, cache) def session_stats(path, cache, bad): s = {"prompts": 0, "interrupts": 0, "turns": 0, "tools": collections.Counter(), "subagents": collections.Counter(), "skills": collections.Counter(), "role": None, "tokens": collections.Counter(), "compactions": 0, "start": None, "blocks": collections.Counter(), "classifier": collections.Counter(), "owner_denials": 0, "cg_init": 0, "cg_explore": 0, "code_reads": 0, "recall": 0, "last_ts": ""} seen, pending = set(), {} for rec in read_session(path, bad): ts = rec.get("timestamp") if isinstance(ts, str) and ts > s["last_ts"]: s["last_ts"] = ts t = rec.get("type") if t == "system" and rec.get("subtype") == "compact_boundary": s["compactions"] += 1 elif t == "assistant": msg = rec.get("message") or {} if rec.get("isApiErrorMessage") or msg.get("model") == "": continue # сообщение Claude Code об ошибке API — не ход модели mid = msg.get("id") if mid is None or mid not in seen: if mid is not None: seen.add(mid) s["turns"] += 1 u = msg.get("usage") or {} if u: n = lambda k: u.get(k, 0) or 0 if s["start"] is None: s["start"] = n("input_tokens") + n("cache_creation_input_tokens") + n("cache_read_input_tokens") s["tokens"]["output"] += n("output_tokens") s["tokens"]["cache_read"] += n("cache_read_input_tokens") s["tokens"]["cache_write"] += n("cache_creation_input_tokens") for b in blocks(rec): if b.get("type") == "tool_use": pending[b.get("id")] = b.get("name") or "?" count_tool(s, b, rec.get("cwd"), cache) elif t == "user": txt = prompt_text(rec) if txt is not None: s["prompts"] += 1 s["recall"] += bool(RECALL.search(txt)) elif is_interrupt(rec): s["interrupts"] += 1 for b in blocks(rec): if b.get("type") != "tool_result": continue rt = result_text(b) if HOOK_ERROR.match(rt) and "BLOCKED" in rt: s["blocks"][hook_of(rt, pending.get(b.get("tool_use_id"), "?"))] += 1 m = CLASSIFIER.search(rt) if m: s["classifier"][m.group(1)] += 1 s["owner_denials"] += OWNER_DENY in rt return s def aggregate(stats): total = lambda k: sum(s[k] for s in stats) merged = lambda k: sum((s[k] for s in stats), collections.Counter()) tools, blk, cls, sub = merged("tools"), merged("blocks"), merged("classifier"), merged("subagents") tok = merged("tokens") starts = [s["start"] for s in stats if s["start"] is not None] explore, reads = total("cg_explore"), total("code_reads") return { "sessions": len(stats), "prompts": total("prompts"), "interrupts": total("interrupts"), "turns": total("turns"), "tool_calls": sum(tools.values()), "tools": dict(tools.most_common()), "roles": dict(collections.Counter(s["role"] or "не выбрана" for s in stats).most_common()), "subagents": sum(sub.values()), "subagent_models": dict(sub.most_common()), "skills": dict(merged("skills").most_common()), "tokens": {k: tok.get(k, 0) for k in ("output", "cache_read", "cache_write")}, "compactions": total("compactions"), "start_context": {"median": int(statistics.median(starts)), "min": min(starts), "max": max(starts)} if starts else None, "hook_blocks": sum(blk.values()), "hook_blocks_by_hook": dict(blk.most_common()), "classifier_denials": sum(cls.values()), "classifier_by_reason": dict(cls.most_common()), "owner_denials": total("owner_denials"), "codegraph": {"init": total("cg_init"), "explore": explore, "code_reads": reads, "explore_share": round(explore / (explore + reads), 2) if explore + reads else None}, "recall": total("recall"), } def memory_stats(root, project): d = os.path.join(root, slug(project), "memory") files = [f for f in glob.glob(os.path.join(d, "**", "*"), recursive=True) if os.path.isfile(f)] idx = os.path.join(d, "MEMORY.md") return {"index_bytes": os.path.getsize(idx) if os.path.isfile(idx) else 0, "files": len(files), "bytes": sum(os.path.getsize(f) for f in files)} def false_blocks(labels): cases = labels.get("cases", {}) lab = [c for c in cases.values() if "skip" not in c] allow = [c for c in lab if c.get("expected") == "allow"] known = [c for c in allow if c.get("known")] return {"labelled": len(lab), "skipped": len(cases) - len(lab), "false": len(allow), "share": round(len(allow) / len(lab), 2) if lab else None, "fixed": len(allow) - len(known), "known": len(known)} def corrections(path): """Кандидаты в поправки владельца с последним действием и хвостом ответа агента перед ними.""" out, act, reply, sid = [], "", "", os.path.basename(path)[:8] for rec in read_session(path, {}): t = rec.get("type") if t == "assistant": for b in blocks(rec): if b.get("type") == "tool_use": act = action_of(b) elif b.get("type") == "text" and (b.get("text") or "").strip(): reply = b["text"] continue if t != "user": continue txt, kind = prompt_text(rec), None if txt is not None: if len(txt) <= 2000 and MARKERS.search(txt[:200]): kind = "prompt" elif is_interrupt(rec): kind, txt = "interrupt", "[Request interrupted by user]" elif any(b.get("type") == "tool_result" and OWNER_DENY in result_text(b) for b in blocks(rec)): kind, txt = "denial", "отказ владельца в разрешении" if kind: out.append({"session": sid, "ts": rec.get("timestamp") or "", "kind": kind, "text": redact(txt[:300]), "action": redact(act[:300]), "reply_tail": redact(" ".join(reply.split())[-200:])}) return out ROWS = [ ("sessions", "Сессий"), ("prompts", "Промптов владельца"), ("interrupts", "Прерываний"), ("turns", "Ходов модели"), ("tool_calls", "Вызовов инструментов"), ("subagents", "Запусков сабагентов"), ("tokens.output", "Токенов вывода"), ("tokens.cache_read", "Чтение из кэша, токенов"), ("tokens.cache_write", "Запись в кэш, токенов"), ("compactions", "Компакций контекста"), ("start_context.median", "Стартовый контекст, медиана"), ("start_context.min", "Стартовый контекст, мин"), ("start_context.max", "Стартовый контекст, макс"), ("hook_blocks", "Блокировок хуков"), ("false_blocks.false", "Ложных блокировок"), ("false_blocks.share", "Доля ложных блокировок"), ("false_blocks.fixed", "Ложных, уже исправлено"), ("false_blocks.known", "Ложных, ещё не исправлено"), ("classifier_denials", "Отказов классификатора"), ("owner_denials", "Отказов владельца"), ("codegraph.init", "codegraph init"), ("codegraph.explore", "Вызовов codegraph_explore"), ("codegraph.code_reads", "Чтений кода (Read/Grep/Glob/Bash)"), ("codegraph.explore_share", "Доля codegraph_explore"), ("corrections", "Кандидатов в поправки владельца"), ("recall", "Промптов «перечитай память/контекст»"), ("memory.index_bytes", "MEMORY.md, байт"), ("memory.files", "Файлов памяти"), ("memory.bytes", "Память, байт"), ("cd_main", "cd в основной checkout на main"), ] BREAKDOWNS = [("tools", "Инструменты"), ("roles", "Роли"), ("subagent_models", "Сабагенты по моделям"), ("skills", "Skills"), ("hook_blocks_by_hook", "Блокировки по хукам"), ("classifier_by_reason", "Отказы классификатора")] def get(m, key): for k in key.split("."): if not isinstance(m, dict) or k not in m: return None m = m[k] return m def _num(v): return isinstance(v, (int, float)) and not isinstance(v, bool) def fmt(v): if v is None: return "—" if isinstance(v, float): return "%.2f" % v if _num(v): return "{:,}".format(v).replace(",", " ") return str(v) def cell(label, cur, old): if isinstance(cur, dict): cur = None if isinstance(old, dict) and "retired" in old: return "| %s | %s | — | не считается: %s |" % (label, fmt(cur), old["retired"]) if isinstance(old, dict) and "value" in old: return "| %s | %s | %s (%s) | — |" % (label, fmt(cur), fmt(old["value"]), old.get("note", "иначе считалось")) d = "—" if _num(cur) and _num(old): diff = cur - old d = ("%+.2f" % diff) if isinstance(diff, float) else "{:+,}".format(diff).replace(",", " ") return "| %s | %s | %s | %s |" % (label, fmt(cur), fmt(old), d) def to_markdown(doc, prev): m, pm = doc["metrics"], (prev or {}).get("metrics") or {} out = ["| Показатель | Сейчас | Прошлое ретро | Δ |", "|---|---|---|---|"] for key, label in ROWS: cur, old = get(m, key), get(pm, key) if cur is None and old is None: continue out.append(cell(label, cur, old)) out.append("") for key, label in BREAKDOWNS: d = m.get(key) or {} if d: out.append("- %s: %s" % (label, ", ".join("%s %s" % (k, fmt(v)) for k, v in list(d.items())[:10]))) src, per = doc.get("sources") or {}, doc.get("period") or {} out += ["", "Период %s — %s; сессий %s; каталоги транскриптов: %s; битых строк %s." % ( per.get("since") or "начало", per.get("until") or "—", src.get("sessions"), ", ".join(src.get("dirs") or []) or "—", src.get("bad_lines"))] return "\n".join(out) def default_since(harness): names = [os.path.basename(f)[:10] for f in glob.glob(os.path.join(harness, "docs", "retro", "*.md"))] names = [n for n in names if re.match(r"^\d{4}-\d{2}-\d{2}$", n)] return max(names) if names else None def parse_args(argv): cc = os.environ.get("CLAUDE_CONFIG_DIR") or os.path.expanduser("~/.claude") p = argparse.ArgumentParser(prog="retro_metrics.py", description="Срез метрик обвязки (B12)") p.add_argument("project") p.add_argument("--since") p.add_argument("--harness", default=os.path.normpath(os.path.join(HERE, "..", "..", ".."))) p.add_argument("--projects-root", default=os.path.join(cc, "projects")) p.add_argument("--labels") p.add_argument("--out") p.add_argument("--markdown", action="store_true") p.add_argument("--prev") p.add_argument("--corrections", action="store_true") return p.parse_args(argv[1:]) def main(argv): a = parse_args(argv) project = os.path.abspath(a.project) since = a.since or default_since(a.harness) dirs, files = project_sessions(project, a.projects_root, since) bad, cache = {"n": 0}, {} stats = [s for s in (session_stats(f, cache, bad) for f in files) if s["prompts"] or s["turns"]] if not stats: sys.stderr.write("retro_metrics: нет сессий проекта %s (slug %s) в %s с %s\n" % (project, slug(project), a.projects_root, since or "начала")) return 1 cands = [c for f in files for c in corrections(f)] if a.corrections: for c in cands: print(json.dumps(c, ensure_ascii=False)) return 0 m = aggregate(stats) m["corrections"] = len(cands) m["memory"] = memory_stats(a.projects_root, project) if a.labels: m["false_blocks"] = false_blocks(json.load(open(a.labels, encoding="utf-8"))) doc = {"schema": 1, "period": {"since": since, "until": max(s["last_ts"] for s in stats)[:10]}, "project": project, "sources": {"dirs": dirs, "sessions": len(stats), "bytes": sum(os.path.getsize(f) for f in files), "bad_lines": bad["n"]}, "metrics": m} if a.out: with open(a.out, "w", encoding="utf-8") as fh: json.dump(doc, fh, ensure_ascii=False, indent=2) fh.write("\n") if a.markdown: print(to_markdown(doc, json.load(open(a.prev, encoding="utf-8")) if a.prev else None)) elif not a.out: print(json.dumps(doc, ensure_ascii=False, indent=2)) return 0 if __name__ == "__main__": sys.exit(main(sys.argv))