claude-harness/tests/test-retro-metrics.sh
andrey.epifantsev 3d2ce3f41d fix(retro): финальное ревью — сабагенты, период по записям, разбор Bash, redact до обрезки, --from (#29)
- 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>
2026-10-24 01:59:41 +04:00

177 lines
15 KiB
Bash
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.

# 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")