- C-1: транскрипты сабагентов (<сессия>/subagents/*.jsonl) вливаются в сессию-родителя — ходы, токены, инструменты, codegraph, блокировки, отказы; промпты, роль, стартовый контекст — только из основного. hook_blocks_export.py выгружает их блокировки с сессией-родителем. - I-7: период режется по меткам времени записей; сессия на стыке делится между ретро, стартовый контекст — если первый ход в периоде. - I-1: простые команды Bash — shlex с punctuation_chars, «|» в кавычках команду не делит. I-2: правило src/ ловит и сам каталог src. - I-3: clip() — redact до обрезки (кандидаты, выгрузка), роль — redact. - I-4: без --since период берётся из существующего --out; --from рисует «Цифры» из сохранённого среза. SKILL.md передаёт период явно. - I-5: выгрузка ретро без разметки — сборка корпуса падает. - I-6: сводка компакции — не промпт; «перечитай» — побуждение перечитать. - 2026-10-11.metrics.json: несравнимое помечено (без сабагентов, широкий «перечитай»). Спека и design.md (Decisions) обновлены. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
177 lines
15 KiB
Bash
177 lines
15 KiB
Bash
# 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("<command-name>/clear</command-name>", 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": "<synthetic>", "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 "токен заменён на <redacted>" '<redacted>' "$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")
|