#!/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, clip, 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"(перечитай|прочитай|сверься|вспомни|освежи)[^.?!\n]{0,40}?(памят|контекст|claude\.md|\.md\b)", re.I) MARKERS = re.compile(r"(?/<сессия>/subagents/*.jsonl.""" return sorted(glob.glob(os.path.join(path[:-len(".jsonl")], "subagents", "*.jsonl"))) def in_period(ts, since): return not since or (ts or "")[:10] >= since def project_sessions(project, root, since): """Транскрипты проекта: каталоги по префиксу slug, сессии по первому cwd. Период режется по записям (since).""" pre = slug(project) best = {} for d in sorted(glob.glob(os.path.join(root, "*"))): if not (os.path.isdir(d) and os.path.basename(d).startswith(pre)): continue for f in glob.glob(os.path.join(d, "*.jsonl")): sid = os.path.basename(f) if sid not in best or os.path.getsize(f) > os.path.getsize(best[sid]): best[sid] = f files = [] for f in sorted(best.values()): cwd, _ = first_meta(f) if cwd and (inside(cwd, project) or inside(cwd, project + ".wt")): files.append(f) return 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("isCompactSummary") or rec.get("isVisibleInTranscriptOnly"): 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 simple_commands(cmd): """Простые команды Bash списками слов: кавычки учитываются, делят &&, ||, ;, |, &, скобки; цель редиректа пропускается. Незакрытая кавычка — разбор по пробелам.""" try: lx = shlex.shlex(cmd or "", posix=True, punctuation_chars=True) lx.whitespace_split = True toks = list(lx) except ValueError: toks = (cmd or "").split() segs, cur, skip = [], [], False for t in toks: if t and set(t) <= set(";&|()"): if cur: segs.append(cur) cur = [] elif t and set(t) <= set("<>"): skip = True elif skip: skip = False else: cur.append(t) if cur: segs.append(cur) return segs def bash_reads_code(cmd, cwd, cache): for words in simple_commands(cmd): if 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"] = redact(str(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, since=None): """Счётчики сессии по записям периода (запись без метки — по предыдущей). Сабагенты сессии добавляют MERGED.""" 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, cur, first_turn = set(), {}, "", True for rec in read_session(path, bad): ts = rec.get("timestamp") if isinstance(ts, str) and ts: cur = ts live = in_period(cur, since) if live and cur > s["last_ts"]: s["last_ts"] = cur t = rec.get("type") if t == "system" and rec.get("subtype") == "compact_boundary": s["compactions"] += live 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) u = msg.get("usage") or {} n = lambda k: u.get(k, 0) or 0 if u and first_turn: first_turn = False # стартовый контекст — первый ход сессии, если он в периоде if live: s["start"] = n("input_tokens") + n("cache_creation_input_tokens") + n("cache_read_input_tokens") if live: s["turns"] += 1 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 "?" if live: count_tool(s, b, rec.get("cwd"), cache) elif t == "user" and live: 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 for sf in subagent_files(path): sub = session_stats(sf, cache, bad, since) for k in MERGED: s[k] = s[k] + sub[k] s["last_ts"] = max(s["last_ts"], sub["last_ts"]) 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, since=None): """Кандидаты в поправки владельца с последним действием и хвостом ответа агента перед ними (записи периода).""" out, act, reply, sid, cur = [], "", "", os.path.basename(path)[:8], "" for rec in read_session(path, {}): if isinstance(rec.get("timestamp"), str) and rec["timestamp"]: cur = rec["timestamp"] 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" or not in_period(cur, since): 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": clip(txt, 300), "action": clip(act, 300), "reply_tail": clip(" ".join(reply.split()), 200, tail=True)}) 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("--from", dest="from_file") p.add_argument("--corrections", action="store_true") return p.parse_args(argv[1:]) def main(argv): a = parse_args(argv) prev = json.load(open(a.prev, encoding="utf-8")) if a.prev else None if a.from_file: # таблица из сохранённого среза, без пересчёта print(to_markdown(json.load(open(a.from_file, encoding="utf-8")), prev)) return 0 project = os.path.abspath(a.project) since = a.since if not since and a.out and os.path.isfile(a.out): # повторный срез того же ретро — тот же период try: since = json.load(open(a.out, encoding="utf-8"))["period"]["since"] except (ValueError, KeyError, TypeError): since = None since = since or default_since(a.harness) bad, cache, kept = {"n": 0}, {}, [] stats = [] for f in project_sessions(project, a.projects_root, since): s = session_stats(f, cache, bad, since) if s["prompts"] or s["turns"]: stats.append(s) kept.append(f) if not stats: sys.stderr.write("retro_metrics: нет сессий проекта %s (slug %s) в %s с %s\n" % (project, slug(project), a.projects_root, since or "начала")) return 1 files, dirs = kept, sorted(set(os.path.basename(os.path.dirname(f)) for f in kept)) cands = [c for f in files for c in corrections(f, since)] 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, prev)) elif not a.out: print(json.dumps(doc, ensure_ascii=False, indent=2)) return 0 if __name__ == "__main__": sys.exit(main(sys.argv))