Синтетические сообщения об ошибке API (model <synthetic>) не считаются ходом модели (O-18, живой прогон). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
133 lines
10 KiB
Bash
133 lines
10 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",
|
||
"[]",
|
||
])
|
||
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) — без старой и чужой" "$(q 'm["sessions"]')" "3"
|
||
assert_eq "каталоги транскриптов среза" "$(q 'len(d["sources"]["dirs"])')" "3"
|
||
assert_eq "битые строки" "$(q 'd["sources"]["bad_lines"]')" "1"
|
||
assert_eq "промпты владельца без служебных и прерываний" "$(q 'm["prompts"]')" "6"
|
||
assert_eq "прерывания" "$(q 'm["interrupts"]')" "1"
|
||
assert_eq "ходы — по message.id" "$(q 'm["turns"]')" "7"
|
||
assert_eq "вызовы инструментов" "$(q 'm["tool_calls"]')" "15"
|
||
assert_eq "токены вывода один раз на сообщение" "$(q 'm["tokens"]["output"]')" "10"
|
||
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"]')" "(1, {'guard-main(Bash)': 1})"
|
||
assert_eq "отказы классификатора по причине" "$(q 'm["classifier_by_reason"]')" "{'Self-Modification': 1}"
|
||
assert_eq "отказы владельца" "$(q 'm["owner_denials"]')" "1"
|
||
assert_eq "codegraph против чтения кода" "$(q 'sorted(m["codegraph"].items())')" "[('code_reads', 5), ('explore', 2), ('explore_share', 0.29), ('init', 1)]"
|
||
assert_eq "роли" "$(q 'sorted(m["roles"].items())')" "[('dev', 1), ('не выбрана', 2)]"
|
||
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"]')" "4"
|
||
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)]"
|
||
# кандидаты в поправки: контекст и redact
|
||
C="$TMP/corr.jsonl"
|
||
python3 "$M" "$P" --since 2026-11-01 --projects-root "$CR" --corrections > "$C"
|
||
assert_eq "кандидатов 4" "$(grep -c . "$C")" "4"
|
||
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 "виды кандидатов" "$(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 "строка с разницей" '^\| Сессий \| 3 \| 44 \| -41 \|$' <(printf '%s' "$md")
|
||
assert_grep "новая метрика — прочерки" '^\| Кандидатов в поправки владельца \| 4 \| — \| — \|$' <(printf '%s' "$md")
|
||
assert_grep "снятая метрика" '^\| cd в основной checkout на main \| — \| — \| не считается: хуки 0.4.0 \|$' <(printf '%s' "$md")
|
||
assert_grep "метрика с другим способом подсчёта" '^\| Ходов модели \| 7 \| 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"
|
||
python3 "$M" "$P" --harness "$H" --projects-root "$CR" --out "$J"
|
||
assert_eq "период с последнего ретро" "$(q 'd["period"]["since"], m["sessions"]')" "('2026-11-03', 2)"
|
||
# нет сессий
|
||
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")
|