claude-harness/docs/retro/scripts/retro_metrics.py

545 lines
24 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, 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"(?<!\w)(нет|не так|почему|зачем|опять|снова|я же|стоп|подожди|не надо|сначала|верни|откати"
r"|сделай сам)(?!\w)", re.I)
READERS = ("grep", "rg", "find", "cat", "head")
FILE_TOOLS = ("Edit", "Write", "Read", "MultiEdit", "NotebookEdit")
MERGED = ("turns", "tools", "subagents", "skills", "tokens", "blocks", "classifier", "owner_denials", "cg_init",
"cg_explore", "code_reads") # что сабагент добавляет сессии-родителю
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 subagent_files(path):
"""Транскрипты сабагентов сессии: <каталог>/<сессия>/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") == "<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)
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))