claude-harness/docs/retro/scripts/retro_metrics.py
andrey.epifantsev dde575673d feat(retro): retro_metrics.py — срез метрик, кандидаты в поправки владельца, сравнение с прошлым ретро (#29)
Синтетические сообщения об ошибке API (model <synthetic>) не считаются ходом модели (O-18, живой прогон).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:21:53 +04:00

483 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Срез метрик обвязки по транскриптам и памяти одного проекта или workspace (B12, #29).
Запуск: python3 retro_metrics.py <папка проекта> [--since ГГГГ-ММ-ДД] [--harness <репозиторий обвязки>]
[--projects-root <каталог>] [--labels <hook-labels.json>] [--out <metrics.json>]
[--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"(?<!\w)(нет|не так|почему|зачем|опять|снова|я же|стоп|подожди|не надо|сначала|верни|откати)"
r"(?!\w)", re.I)
READERS = ("grep", "rg", "find", "cat", "head")
SEPARATORS = re.compile(r"&&|\|\||;|\|")
FILE_TOOLS = ("Edit", "Write", "Read", "MultiEdit", "NotebookEdit")
def slug(path):
"""Имя каталога транскриптов Claude Code: каждый символ кроме [A-Za-z0-9] заменён на «-»."""
return re.sub(r"[^A-Za-z0-9]", "-", path)
def inside(path, root):
path, root = os.path.normpath(path), os.path.normpath(root)
return path == root or path.startswith(root + os.sep)
def read_session(path, bad):
"""Записи-объекты транскрипта; битые строки считаются в bad["n"]."""
with open(path, encoding="utf-8", errors="replace") as fh:
for line in fh:
if not line.strip():
continue
try:
rec = json.loads(line)
except ValueError:
bad["n"] = bad.get("n", 0) + 1
continue
if isinstance(rec, dict):
yield rec
def first_meta(path):
"""(cwd, метка времени) первых записей, где они есть."""
cwd = ts = None
for rec in read_session(path, {}):
if not cwd and isinstance(rec.get("cwd"), str) and rec["cwd"]:
cwd = rec["cwd"]
if not ts and isinstance(rec.get("timestamp"), str) and rec["timestamp"]:
ts = rec["timestamp"]
if cwd and ts:
break
return cwd, ts
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, 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") == "<synthetic>":
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))