# retro_metrics.py: срез метрик по синтетическим транскриптам (спека harness-retro, #29). M="$ROOT/docs/retro/scripts/retro_metrics.py" P="$TMP/proj"; CR="$TMP/cc/projects"; mkdir -p "$P/.git" "$P/.claude" "$P/hooks" "$CR" echo 'hooks/' > "$P/.claude/code-paths" cat > "$TMP/gen.py" <<'PY' import json, os, re, sys P, CR = sys.argv[1], sys.argv[2] slug = lambda p: re.sub(r"[^A-Za-z0-9]", "-", p) def w(d, name, recs): os.makedirs(os.path.join(CR, d), exist_ok=True) with open(os.path.join(CR, d, name), "w", encoding="utf-8") as fh: for r in recs: fh.write((r if isinstance(r, str) else json.dumps(r, ensure_ascii=False)) + "\n") def u(text, ts, cwd=P, **kw): return dict({"type": "user", "timestamp": ts, "cwd": cwd, "message": {"role": "user", "content": text}}, **kw) def tr(tid, text, ts): return {"type": "user", "timestamp": ts, "cwd": P, "message": {"content": [{"type": "tool_result", "tool_use_id": tid, "content": text}]}} def a(mid, content, ts, cwd=P, usage=None): m = {"id": mid, "role": "assistant", "content": content} if usage: m["usage"] = usage return {"type": "assistant", "timestamp": ts, "cwd": cwd, "message": m} def tu(tid, tool, **inp): return {"type": "tool_use", "id": tid, "name": tool, "input": inp} A = slug(P) T = "2026-11-02T10:%02d:00Z" U1 = {"input_tokens": 100, "cache_creation_input_tokens": 1000, "cache_read_input_tokens": 50000, "output_tokens": 10} w(A, "s1.jsonl", [ u("сделай X", T % 0), a("m1", [{"type": "text", "text": "Начинаю."}], T % 1, usage=U1), a("m1", [tu("t0", "Skill", skill="role", args="dev")], T % 1, usage=U1), a("m2", [tu("t1", "Read", file_path=P + "/hooks/a.py"), tu("t2", "Read", file_path=P + "/docs/x.md")], T % 2), a("m3", [tu("t3", "mcp__codegraph__codegraph_explore", query="x"), tu("t4", "mcp__codegraph__codegraph_explore", query="y"), tu("t5", "Bash", command="grep -rn x hooks/"), tu("t6", "Bash", command="codegraph init"), tu("t7", "Agent", model="sonnet", prompt="p"), tu("t8", "Agent", prompt="q"), tu("t9", "Bash", command="echo x > f"), tu("t10", "Bash", command="cat hooks/require-plan.sh")], T % 3), tr("t9", 'PreToolUse:Bash hook error: ["$HOME/.claude/hooks/guard-main.sh"]: BLOCKED [guard-main]: запись в f', T % 3), tr("t10", 'echo "BLOCKED: editing without a plan" >&2', T % 3), tr("t6", "Permission for this action was denied by the Claude Code auto mode classifier. Reason: [Self-Modification]. If", T % 3), tr("t5", "The user doesn't want to proceed with this tool use. The tool use was rejected", T % 3), u("/clear", T % 4), u("служебное", T % 4, isMeta=True), u("[Request interrupted by user]", T % 4), a("m4", [{"type": "text", "text": "Вхожу в worktree."}, tu("t11", "EnterWorktree", name="x")], T % 5), u("почему мне нужно это каждый раз подтверждать?", T % 6), u("перечитай память", T % 7), a("m5", [tu("t12", "Bash", command="git branch -m feat/x")], T % 8), u("нет, tea login add --token 0123456789abcdef0123456789abcdef01234567 не так", T % 9), {"type": "system", "subtype": "compact_boundary", "timestamp": T % 9}, "not json", "[]", # I-3: секрет на границе обрезки хвоста ответа (200) и текста кандидата (300) a("m6", [{"type": "text", "text": "Выполни: tea login add --token fedcba9876543210fedcba9876543210fedcba98 " + "y" * 170}], T % 10), u("стоп, " + "x" * 270 + " 0123abcd0123abcd0123abcd0123abcd0123abcd", T % 11), # I-6: упоминание контекста — не «перечитай»; сводка компакции — не промпт владельца u("я сейчас смотрю на размер контекста в чистой сессии", T % 12), {"type": "user", "timestamp": T % 13, "cwd": P, "isCompactSummary": True, "isVisibleInTranscriptOnly": True, "message": {"role": "user", "content": "This session is being continued from a previous conversation. Нет, перечитай память."}}, # I-1: «|» внутри кавычек не режет команду a("m7", [tu("t13", "Bash", command='grep -n "a\\|b" hooks/x.py'), tu("t14", "Bash", command='cat docs/a.md | grep "x|y" docs/')], T % 14), ]) # C-1: транскрипт сабагента — в сессию-родителя (инструменты, ходы, токены, codegraph, блокировки, отказы), без промптов os.makedirs(os.path.join(CR, A, "s1", "subagents"), exist_ok=True) w(os.path.join(A, "s1", "subagents"), "agent-a1.jsonl", [ {"type": "user", "isSidechain": True, "timestamp": "2026-11-02T10:03:30Z", "cwd": P, "message": {"role": "user", "content": "Задача: реализуй, почему бы и нет"}}, a("sa1", [tu("sx1", "mcp__codegraph__codegraph_explore", query="z"), tu("sx2", "Read", file_path=P + "/hooks/b.py"), tu("sx3", "Bash", command="echo y > g"), tu("sx4", "Bash", command="git push --force")], "2026-11-02T10:03:40Z", usage={"input_tokens": 10, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 5}), tr("sx3", 'PreToolUse:Bash hook error: BLOCKED [guard-main]: запись в g', "2026-11-02T10:03:41Z"), tr("sx4", "denied by the Claude Code auto mode classifier. Reason: [Git Destructive]", "2026-11-02T10:03:42Z"), ]) # I-7: длинная сессия на стыке периодов — считаются только записи периода, стартовый контекст — вне периода w(A, "s6.jsonl", [ u("старт до периода", "2026-10-30T09:00:00Z"), a("p1", [], "2026-10-30T09:01:00Z", usage={"input_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 40000, "output_tokens": 0}), u("продолжаю", "2026-11-02T08:00:00Z"), a("p2", [tu("z1", "Bash", command="ls")], "2026-11-02T08:01:00Z", usage={"input_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 70000, "output_tokens": 0}), ]) W = P + "/.claude/worktrees/x" w(A + "--claude-worktrees-x", "s2.jsonl", [ u("ок", "2026-11-03T09:00:00Z", cwd=W), {"type": "assistant", "timestamp": "2026-11-03T09:00:30Z", "cwd": W, "isApiErrorMessage": True, # не ход модели "message": {"id": "syn-1", "model": "", "role": "assistant", "content": [{"type": "text", "text": "API Error"}], "usage": {"input_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 0}}}, a("n1", [tu("x1", "Bash", command='grep "unbalanced hooks/a.py')], "2026-11-03T09:01:00Z", cwd=W, usage={"input_tokens": 0, "cache_creation_input_tokens": 6000, "cache_read_input_tokens": 50000, "output_tokens": 0}), ]) s5 = [u("да", "2026-11-04T09:00:00Z", cwd=P + ".wt/y"), a("k1", [tu("y1", "Grep", pattern="x")], "2026-11-04T09:01:00Z", cwd=P + ".wt/y", usage={"input_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 61000, "output_tokens": 0})] w(slug(P + ".wt/y"), "s5.jsonl", s5) w(A, "s5.jsonl", s5[:1]) # усечённая копия той же сессии — берётся больший файл w(A, "s0.jsonl", [u("старое", "2026-10-01T09:00:00Z")]) w(A + "-tools", "s3.jsonl", [u("чужое", "2026-11-02T09:00:00Z", cwd=P + "-tools")]) os.makedirs(os.path.join(CR, A, "memory"), exist_ok=True) open(os.path.join(CR, A, "memory", "MEMORY.md"), "w").write("x" * 100) open(os.path.join(CR, A, "memory", "note.md"), "w").write("y" * 50) PY python3 "$TMP/gen.py" "$P" "$CR" J="$TMP/m.json" python3 "$M" "$P/" --since 2026-11-01 --projects-root "$CR" --out "$J"; rc=$? assert_eq "срез: exit 0" "$rc" "0" q(){ python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); m=d["metrics"]; print(eval(sys.argv[2]))' "$J" "$1"; } assert_eq "схема 1" "$(q 'd["schema"]')" "1" assert_eq "сессии: s1, s2 (worktree), s5 (.wt), s6 (на стыке) — без старой и чужой" "$(q 'm["sessions"]')" "4" assert_eq "каталоги транскриптов среза" "$(q 'len(d["sources"]["dirs"])')" "3" assert_eq "битые строки" "$(q 'd["sources"]["bad_lines"]')" "1" assert_eq "промпты владельца без служебных, прерываний, сводки компакции, сабагента и записей до периода" "$(q 'm["prompts"]')" "9" assert_eq "прерывания" "$(q 'm["interrupts"]')" "1" assert_eq "ходы — по message.id, с сабагентом, только в периоде" "$(q 'm["turns"]')" "11" assert_eq "вызовы инструментов с сабагентом" "$(q 'm["tool_calls"]')" "22" assert_eq "токены вывода один раз на сообщение, с сабагентом" "$(q 'm["tokens"]["output"]')" "15" assert_eq "стартовый контекст — без сабагента и без сессии, начатой до периода" "$(q 'sorted(m["start_context"].items())')" "[('max', 61000), ('median', 56000), ('min', 51100)]" assert_eq "блокировки только по ошибке хука, с сабагентом" "$(q 'm["hook_blocks"], m["hook_blocks_by_hook"]')" "(2, {'guard-main(Bash)': 2})" assert_eq "отказы классификатора по причине, с сабагентом" "$(q 'sorted(m["classifier_by_reason"].items())')" "[('Git Destructive', 1), ('Self-Modification', 1)]" assert_eq "отказы владельца" "$(q 'm["owner_denials"]')" "1" assert_eq "codegraph против чтения кода: с сабагентом и grep с «|» в кавычках" "$(q 'sorted(m["codegraph"].items())')" "[('code_reads', 7), ('explore', 3), ('explore_share', 0.3), ('init', 1)]" assert_eq "роли" "$(q 'sorted(m["roles"].items())')" "[('dev', 1), ('не выбрана', 3)]" assert_eq "сабагенты по моделям" "$(q 'sorted(m["subagent_models"].items())')" "[('sonnet', 1), ('не указана', 1)]" assert_eq "skills" "$(q 'm["skills"]')" "{'role': 1}" assert_eq "компакции; «перечитай» — только побуждение перечитать" "$(q 'm["compactions"], m["recall"]')" "(1, 1)" assert_eq "кандидаты в поправки" "$(q 'm["corrections"]')" "5" assert_eq "память" "$(q 'sorted(m["memory"].items())')" "[('bytes', 150), ('files', 2), ('index_bytes', 100)]" assert_eq "памяти нет — нули" "$(python3 -c "import sys; sys.path.insert(0, '$ROOT/docs/retro/scripts'); import retro_metrics; print(sorted(retro_metrics.memory_stats('$TMP/none', '$P').items()))")" "[('bytes', 0), ('files', 0), ('index_bytes', 0)]" # I-2: правило src/ без code-paths — каталог src как аргумент тоже код NC="$TMP/nocp"; mkdir -p "$NC/.git" "$NC/src" "$NC/svc/src" assert_eq "src/ без code-paths: каталог как аргумент" "$(python3 -c "import sys; sys.path.insert(0, '$ROOT/docs/retro/scripts'); import retro_metrics as r; print([r.bash_reads_code(c, '$NC', {}) for c in ('grep -rn foo src/', 'grep -rn foo src', 'find svc/src -name x', 'grep -rn foo docs/')])")" "[True, True, True, False]" # кандидаты в поправки: контекст и redact C="$TMP/corr.jsonl" python3 "$M" "$P" --since 2026-11-01 --projects-root "$CR" --corrections > "$C" assert_eq "кандидатов 5" "$(grep -c . "$C")" "5" assert_eq "«почему» после EnterWorktree" "$(python3 -c 'import json,sys; print([c["action"] for c in map(json.loads, open(sys.argv[1])) if "почему" in c["text"]])' "$C")" "['EnterWorktree']" assert_eq "последнее действие Bash" "$(python3 -c 'import json,sys; print([c["action"] for c in map(json.loads, open(sys.argv[1])) if c["text"].startswith("нет")])' "$C")" "['Bash: git branch -m feat/x']" assert_eq "токен замазан" "$(grep -c '0123456789abcdef' "$C")" "0" assert_grep "токен заменён на " '' "$C" assert_eq "секрет на границе обрезки не утекает частично (I-3)" "$(python3 -c ' import sys out = open(sys.argv[1], encoding="utf-8").read() toks = ["fedcba9876543210fedcba9876543210fedcba98", "0123abcd0123abcd0123abcd0123abcd0123abcd"] print(sum(t[i:i + 8] in out for t in toks for i in range(len(t) - 7)))' "$C")" "0" assert_eq "виды кандидатов" "$(python3 -c 'import json,sys; print(sorted(set(c["kind"] for c in map(json.loads, open(sys.argv[1])))))' "$C")" "['denial', 'interrupt', 'prompt']" # сравнение с прошлым ретро cat > "$TMP/prev.json" <<'EOF' {"schema": 1, "metrics": {"sessions": 44, "turns": {"value": 12121, "note": "по строкам"}, "cd_main": {"retired": "хуки 0.4.0"}}} EOF md=$(python3 "$M" "$P" --since 2026-11-01 --projects-root "$CR" --markdown --prev "$TMP/prev.json") assert_grep "строка с разницей" '^\| Сессий \| 4 \| 44 \| -40 \|$' <(printf '%s' "$md") assert_grep "новая метрика — прочерки" '^\| Кандидатов в поправки владельца \| 5 \| — \| — \|$' <(printf '%s' "$md") assert_grep "снятая метрика" '^\| cd в основной checkout на main \| — \| — \| не считается: хуки 0.4.0 \|$' <(printf '%s' "$md") assert_grep "метрика с другим способом подсчёта" '^\| Ходов модели \| 11 \| 12 121 \(по строкам\) \| — \|$' <(printf '%s' "$md") # ложные блокировки по разметке cat > "$TMP/labels.json" <<'EOF' {"home": "/h", "layout": [], "cases": {"x-001": {"expected": "allow", "why": "a"}, "x-002": {"expected": "allow", "why": "b"}, "x-003": {"expected": "allow", "why": "c", "known": "RT-21"}, "x-004": {"expected": "block", "why": "d"}, "x-005": {"skip": "s"}}} EOF python3 "$M" "$P" --since 2026-11-01 --projects-root "$CR" --labels "$TMP/labels.json" --out "$J" assert_eq "ложные блокировки" "$(q 'sorted(m["false_blocks"].items())')" "[('false', 3), ('fixed', 2), ('known', 1), ('labelled', 4), ('share', 0.75), ('skipped', 1)]" # период по умолчанию — от даты последнего ретро H="$TMP/harness"; mkdir -p "$H/docs/retro"; touch "$H/docs/retro/2026-11-03.md" "$H/docs/retro/2026-10-11.md" J="$TMP/d.json" python3 "$M" "$P" --harness "$H" --projects-root "$CR" --out "$J" assert_eq "период с последнего ретро" "$(q 'd["period"]["since"], m["sessions"]')" "('2026-11-03', 2)" # I-4: черновик текущего ретро не сдвигает период — при существующем --out берётся его period.since touch "$H/docs/retro/2026-11-05.md" python3 "$M" "$P" --harness "$H" --projects-root "$CR" --out "$J"; rc=$? assert_eq "повторный срез в тот же --out: тот же период" "$rc $(q 'd["period"]["since"], m["sessions"]')" "0 ('2026-11-03', 2)" md=$(python3 "$M" "$P" --from "$J" --markdown --projects-root "$TMP/нет-такого") assert_grep "--from: таблица из сохранённого среза без пересчёта" '^\| Сессий \| 2 \| — \| — \|$' <(printf '%s' "$md") # нет сессий err=$(python3 "$M" "$P" --since 2030-01-01 --projects-root "$CR" 2>&1 >/dev/null); rc=$? assert_eq "нет сессий: exit 1" "$rc" "1" assert_grep "сообщение со slug" 'proj' <(printf '%s' "$err") # H-1 (владелец, PR #30): «сделай сам» — агент переложил работу на владельца — признак поправки assert_eq "«сделай сам» — признак поправки, «сделай X» — нет" "$(python3 -c "import sys; sys.path.insert(0, '$ROOT/docs/retro/scripts'); import retro_metrics as r; print([bool(r.MARKERS.search(t)) for t in ('сделай сам', 'Сделай сам, пожалуйста', 'сделай X', 'сделайсам')])")" "[True, True, False, False]"