From 60d31c25db0afe27b8cc501d5fb21277f8d87673 Mon Sep 17 00:00:00 2001 From: "andrey.epifantsev" Date: Sat, 24 Oct 2026 01:10:10 +0400 Subject: [PATCH] =?UTF-8?q?docs(plan):=20=D0=BF=D0=BB=D0=B0=D0=BD=20/harne?= =?UTF-8?q?ss-retro=20=E2=80=94=204=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8?= =?UTF-8?q?;=20design:=20=D0=BF=D0=BE=D0=B4=D1=81=D1=87=D1=91=D1=82=20?= =?UTF-8?q?=D0=BF=D0=BE=20message.id,=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7?= =?UTF-8?q?=D1=8B=20=D0=B2=D0=BB=D0=B0=D0=B4=D0=B5=D0=BB=D1=8C=D1=86=D0=B0?= =?UTF-8?q?,=20=D0=B4=D0=B5=D0=B4=D1=83=D0=BF=D0=BB=D0=B8=D0=BA=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20=D1=81=D0=B5=D1=81=D1=81=D0=B8=D0=B9=20(#2?= =?UTF-8?q?9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-24-harness-retro.md | 1764 +++++++++++++++++ openspec/changes/harness-retro/design.md | 6 +- 2 files changed, 1769 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-10-24-harness-retro.md diff --git a/docs/superpowers/plans/2026-10-24-harness-retro.md b/docs/superpowers/plans/2026-10-24-harness-retro.md new file mode 100644 index 0000000..c4c82b1 --- /dev/null +++ b/docs/superpowers/plans/2026-10-24-harness-retro.md @@ -0,0 +1,1764 @@ +# `/harness-retro` — план реализации + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Повторяемое ретро обвязки по транскриптам, памяти и состоянию репозиториев одного проекта. Его результат — черновик `docs/retro/<дата>.md` со сравнением с прошлым ретро, блокировки хуков, размеченные в регрессионный корпус, и список поправок владельца по процессу. + +**Architecture:** +- **Скрипты** в `docs/retro/scripts/` (stdlib python3): + - `retro_metrics.py` — срез метрик, кандидаты в поправки, таблица «Цифры» со сравнением; + - `retro_leftovers.py` — хвосты чеклиста закрытия; + - `hook_blocks_export.py` — выгрузка блокировок за период, становится импортируемым модулем; + - `hook_cases_build.py` — сборка корпуса из разметки ретро. +- **Корпус хуков** состоит из сегментов: у каждого своя шапка `{home, layout}`. Записи с `known` — известные ошибки хуков. +- **Проектный skill** `.claude/skills/harness-retro/` задаёт порядок ретро и шаблон черновика. + +**Tech Stack:** bash, python3 stdlib (3.8+), git; тесты — `tests/run.sh` (bash, без зависимостей). + +**Spec:** `openspec/changes/harness-retro/` — `proposal.md`, `specs/harness-retro/spec.md`, `specs/hook-guards/spec.md`, `design.md`. Issue #29, ветка `feat/29-harness-retro`. + +**Worktree:** `/Users/andrejepifancev/Projects/cc-harness/.claude/worktrees/b12-harness-retro` — все пути ниже от его корня, все команды — из него. + +## Global Constraints + +- Только stdlib python3, совместимость с Python 3.8: без `match`, без `str.removeprefix`, без `X | Y` в аннотациях. +- Скрипты ретро только читают транскрипты, память и репозитории. Пишут только файл `--out`; `retro_leftovers.py --fetch` делает только `git fetch --prune origin`. +- Всё, что выводится из транскриптов (промпты, команды, сообщения), проходит `redact()` из `hook_blocks_export.py`. +- «Код» — как в require-plan: префиксы `.claude/code-paths` репозитория, без файла — любой `src/`. +- Корпус `skills/harness-doctor/fixtures/hook-cases.jsonl` всегда совпадает с выводом `python3 docs/retro/scripts/hook_cases_build.py`. +- Зелёным считается только весь набор `bash tests/run.sh`. +- Коммиты — в ветке `feat/29-harness-retro`, trailer `Co-Authored-By: Claude Opus 5.5 `. Push и PR — после задачи 4. +- Тексты документации и вывода — по-русски. + +## Предусловия окружения + +- Сессия в worktree `/Users/andrejepifancev/Projects/cc-harness/.claude/worktrees/b12-harness-retro` на ветке `feat/29-harness-retro`. +- `python3 --version` ≥ 3.8, `git --version` ≥ 2.31: `init -b`, клон пустого репозитория с unborn HEAD. +- `bash tests/run.sh` на старте зелёный — 519 passed. +- Стенд, порты, ключи API не нужны. + +## Review Focus + +1. **Одно сообщение модели на нескольких строках транскрипта** с общим `message.id` и одинаковым `usage` — ходы и токены считаются один раз. Тест: задача 2 (`tokens.output` = 10 при двух строках `m1`). +2. **Bash-команда с незакрытой кавычкой** (`grep "unbalanced hooks/a.py`) — разбор не падает, путь распознаётся по `split`. Тест: задача 2. +3. **Строки транскрипта — валидный JSON, но не объект** (`[]`), и записи без `cwd` и `timestamp` — пропуск без исключения. Тест: задача 2. +4. **Репозиторий без `origin`** — одна строка `no-origin-main`, без исключения. Тест: задача 3. +5. **Разметка, не совпадающая с выгрузкой** (нет id или лишний id) — сборка корпуса падает с понятным сообщением. Тест: задача 1. + +--- + +### Task 1: Корпус по сегментам, `known`, выгрузка блокировок за период + +**Files:** +- Modify: `docs/retro/scripts/hook_blocks_export.py` — переписать целиком (код ниже) +- Modify: `docs/retro/scripts/hook_cases_build.py` — шапка, `import glob`, `label_segments`, `main(argv)` +- Modify: `skills/harness-doctor/scripts/hook_corpus.py` — переписать целиком (код ниже) +- Modify: `skills/harness-doctor/scripts/doctor.sh` — строка вывода расхождений корпуса +- Test: `tests/test-hook-export.sh`, `tests/test-hook-corpus.sh` + +**Interfaces:** +- Consumes: — +- Produces: + - `hook_blocks_export.HOOK_ERROR` (regex), `redact(s) -> str`, `hook_of(txt, tool) -> str`; + - `export(dirs, since=None, prefix=None)` — генератор dict; + - `main(argv) -> int`; CLI: `[--since ГГГГ-ММ-ДД] [--id-prefix <дата>] <каталог> ...`. + - Формат `docs/retro/fixtures/hook-labels-<дата>.json`: `{"home", "layout", "cases": {"": {"expected", "why"[, "known"]} | {"skip": "<причина>"}}}`. + - `hook_cases_build.py [--fixtures-dir <каталог>]`. + - Строка `hook_corpus.py`: `id\twhy\texpected\tgot\tstatus\tcmd`, где status ∈ `ok|MISMATCH|KNOWN|KNOWN-FIXED`. + - Итог `--summary`: `корпус хуков: N/M` или `корпус хуков: N/M, известных ошибок K`. + +- [ ] **Step 1: Тесты выгрузки за период и импорта без побочного вывода** + +Дописать в конец `tests/test-hook-export.sh`: + +```bash +# --since и --id-prefix (B12, #29): только сессии не раньше даты, id <дата>-NNN; модуль импортируется без выгрузки +T2="$TMP/tr2"; mkdir -p "$T2" +blk='{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"u1","is_error":true,"content":"PreToolUse:Bash hook error: BLOCKED [guard-main]: запись в f"}]}}' +printf '%s\n%s\n' '{"type":"assistant","cwd":"/r","gitBranch":"main","timestamp":"2026-10-01T10:00:00Z","message":{"content":[{"type":"tool_use","id":"u1","name":"Bash","input":{"command":"echo a > f"}}]}}' "$blk" > "$T2/old.jsonl" +printf '%s\n%s\n' '{"type":"assistant","cwd":"/r","gitBranch":"main","timestamp":"2026-11-02T10:00:00Z","message":{"content":[{"type":"tool_use","id":"u1","name":"Bash","input":{"command":"echo b > f"}}]}}' "$blk" > "$T2/new.jsonl" +out=$(python3 "$X" --since 2026-11-01 --id-prefix 2026-11-05 "$T2") +assert_eq "--since: только сессия не раньше даты" "$(printf '%s\n' "$out" | grep -c .)" "1" +assert_eq "--id-prefix: id <дата>-NNN" "$(printf '%s\n' "$out" | python3 -c 'import json,sys; r=json.loads(sys.stdin.readline()); print(r["id"], r["command"])')" "2026-11-05-001 echo b > f" +assert_eq "без флагов поля id нет (выгрузка 12.10 не меняется)" "$(python3 "$X" "$T2" | python3 -c 'import json,sys; print(sum("id" in json.loads(l) for l in sys.stdin))')" "0" +assert_eq "импорт модуля не выгружает" "$(python3 -c "import sys; sys.argv=['x', '$T2']; sys.path.insert(0, '$ROOT/docs/retro/scripts'); import hook_blocks_export" 2>&1)" "" +``` + +- [ ] **Step 2: Запустить — тесты падают** + +Run: `bash tests/run.sh hook-export` +Expected: FAIL. `--since` и `--id-prefix` считаются каталогами (лишние записи, нет `id`), а импорт печатает выгрузку. + +- [ ] **Step 3: Переписать `docs/retro/scripts/hook_blocks_export.py`** + +```python +#!/usr/bin/env python3 +"""Выгрузка блокировок хуков из транскриптов Claude Code в JSONL — сырьё для регрессионных тестов хуков (B2). + +Запуск: python3 hook_blocks_export.py [--since ГГГГ-ММ-ДД] [--id-prefix <дата>] <каталог-транскриптов> [...] +Одна строка — одна блокировка: сессия, время, cwd и ветка на момент вызова, инструмент, команда или путь файла, +первая строка ответа хука. Поле expected не заполняется: вердикт (block/allow) проставляется при разметке. +Выгружаются только ошибки хуков (результат начинается с `PreToolUse:<инструмент> hook error`); вывод команд, +в котором встретилось слово BLOCKED (файлы памяти, исходники хуков), не попадает. +--since — только сессии с первой меткой времени не раньше даты; --id-prefix (по умолчанию — дата --since) — +поле id вида <префикс>-NNN (срез ретро, B12). Похожие на секреты подстроки заменяются на . +""" +import glob +import json +import os +import re +import sys + +HOOK_ERROR = re.compile(r"\s*PreToolUse:\S+ hook error") + +RE_SECRET = re.compile( + r'(?i)gh[pousr]_[A-Za-z0-9]{20,}|sk-[A-Za-z0-9_-]{20,}|\b[0-9a-f]{40}\b' + r'|(?P(?:token|password|passwd|secret|api[_-]?key)\s*[=:]\s*)\S+' + r'|(?P--(?:token|password)\s+)\S+|(?PAuthorization:\s*\S+\s+)\S+' +) + + +def redact(s): + def sub(m): + keep = m.group("kv") or m.group("flag") or m.group("auth") or "" + return keep + "" + return RE_SECRET.sub(sub, s) + + +def hook_of(txt, tool): + if "[guard-main]" in txt: + return "guard-main(Bash)" if tool == "Bash" else "guard-main(Edit/Write)" + if "[require-plan]" in txt: + return "require-plan" + if "shell write" in txt: # формулировки до 0.4.0 + return "guard-main(Bash)" + if "you are on" in txt: + return "guard-main(Edit/Write)" + if "without a plan" in txt: + return "require-plan" + if "commit/push в main" in txt or "ветке main" in txt or "reference-old-arm" in txt or "docs/source" in txt: + return "workspace-guard" + return "other" + + +def _records(path): + for line in open(path, encoding="utf-8", errors="replace"): + try: + j = json.loads(line) + except ValueError: + continue + if isinstance(j, dict): + yield j + + +def first_ts(path): + for j in _records(path): + if isinstance(j.get("timestamp"), str) and j["timestamp"]: + return j["timestamp"] + return "" + + +def export(dirs, since=None, prefix=None): + """Записи выгрузки по каталогам транскриптов, по порядку файлов и строк.""" + n = 0 + for d in dirs: + for f in sorted(glob.glob(os.path.join(os.path.expanduser(d), "*.jsonl"))): + if since and first_ts(f)[:10] < since: + continue + sid = os.path.basename(f)[:8] + pending = {} # tool_use_id -> (name, input, cwd, branch, ts) + for j in _records(f): + m = j.get("message") or {} + cont = m.get("content") + if j.get("type") == "assistant": + for b in cont or []: + if isinstance(b, dict) and b.get("type") == "tool_use": + pending[b.get("id")] = (b.get("name"), b.get("input") or {}, j.get("cwd", ""), + j.get("gitBranch", ""), j.get("timestamp", "")) + elif j.get("type") == "user" and isinstance(cont, list): + for b in cont: + if not (isinstance(b, dict) and b.get("type") == "tool_result"): + continue + txt = b.get("content", "") + if isinstance(txt, list): + txt = "\n".join(x.get("text", "") for x in txt if isinstance(x, dict)) + if not isinstance(txt, str) or not HOOK_ERROR.match(txt): + continue # обычный вывод команды, даже если в нём есть слово BLOCKED + name, inp, cwd, branch, ts = pending.get(b.get("tool_use_id"), ("?", {}, "", "", "")) + if name not in ("Bash", "Edit", "Write", "MultiEdit"): + continue + msg = next((l for l in txt.splitlines() if "BLOCKED" in l), "") + rec = { + "session": sid, "ts": ts, "cwd": cwd, "branch": branch, "tool": name, + "command": redact(inp.get("command", "")) if name == "Bash" else None, + "file_path": inp.get("file_path") if name != "Bash" else None, + "hook": hook_of(txt, name), "message": redact(msg[:300]), "expected": None, + } + if prefix: + n += 1 + rec = dict({"id": "%s-%03d" % (prefix, n)}, **rec) + yield rec + + +def main(argv): + args, dirs, since, prefix = argv[1:], [], None, None + i = 0 + while i < len(args): + if args[i] == "--since" and i + 1 < len(args): + since, i = args[i + 1], i + 2 + elif args[i] == "--id-prefix" and i + 1 < len(args): + prefix, i = args[i + 1], i + 2 + else: + dirs.append(args[i]) + i += 1 + for rec in export(dirs, since, prefix or since): + print(json.dumps(rec, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) +``` + +- [ ] **Step 4: Запустить — тесты выгрузки зелёные** + +Run: `bash tests/run.sh hook-export` +Expected: `passed 7, failed 0`. + +- [ ] **Step 5: Тесты сегментов, `known` и разметки ретро** + +В `tests/test-hook-corpus.sh` заменить цикл проверки записей: + +```bash +while IFS=$'\t' read -r id why exp got cmd; do + case "$id" in total*) continue ;; esac + assert_eq "корпус $id ($why): $cmd" "$got" "$exp" +done < <(python3 "$R" "$ROOT/hooks") +``` + +на: + +```bash +while IFS=$'\t' read -r id why exp got st cmd; do + case "$id" in total*) continue ;; esac + case "$st" in + ok|KNOWN) ok "корпус $id ($why)" ;; + *) fail "корпус $id ($why): $cmd" "ожидалось $exp, получено $got ($st)" ;; + esac +done < <(python3 "$R" "$ROOT/hooks") +``` + +и дописать в конец файла: + +```bash +# сегменты и known (#29): синтетический корпус из двух сегментов с разными home +S="$TMP/seg"; mkdir -p "$S" +cat > "$S/c.jsonl" <<'EOF' +{"home": "/h1", "layout": [{"path": "m", "branch": "main"}]} +{"id": "a1", "why": "main", "tool": "Write", "file_path": "/h1/m/a.md", "cwd": "/h1/m", "expected": "block"} +{"id": "a2", "why": "known-x", "tool": "Write", "file_path": "/h1/m/b.md", "cwd": "/h1/m", "expected": "allow", "known": "RT-21"} +{"home": "/h2", "layout": [{"path": "r", "branch": "feat/x"}]} +{"id": "b1", "why": "branch", "tool": "Write", "file_path": "/h2/r/a.md", "cwd": "/h2/r", "expected": "allow"} +EOF +out=$(python3 "$R" "$ROOT/hooks" --summary --fixtures "$S/c.jsonl"); rc=$? +assert_eq "два сегмента и известная ошибка: exit 0" "$rc" "0" +assert_eq "итог с известными ошибками" "$out" "корпус хуков: 3/3, известных ошибок 1" +sed 's/"expected": "allow", "known"/"expected": "block", "known"/' "$S/c.jsonl" > "$S/f.jsonl" +out=$(python3 "$R" "$ROOT/hooks" --summary --fixtures "$S/f.jsonl"); rc=$? +assert_eq "known совпал с вердиктом: exit 1" "$rc" "1" +assert_grep "KNOWN-FIXED с id записи" '^KNOWN-FIXED a2' <(printf '%s' "$out") +# разметка ретро (#29): hook-labels-<дата>.json + hook-blocks-<дата>.jsonl → новый сегмент корпуса +B="$ROOT/docs/retro/scripts/hook_cases_build.py" +FX="$TMP/fx"; mkdir -p "$FX"; cp "$ROOT/docs/retro/fixtures/hook-blocks.jsonl" "$FX/" +cat > "$FX/hook-blocks-2026-11-01.jsonl" <<'EOF' +{"id": "2026-11-01-001", "session": "s", "ts": "2026-11-02T10:00:00Z", "cwd": "/h3/r", "branch": "main", "tool": "Bash", "command": "ls x 2>/dev/null", "file_path": null, "hook": "guard-main(Bash)", "message": "BLOCKED", "expected": null} +{"id": "2026-11-01-002", "session": "s", "ts": "2026-11-02T10:01:00Z", "cwd": "/h3/r", "branch": "main", "tool": "Bash", "command": "echo x > f", "file_path": null, "hook": "guard-main(Bash)", "message": "BLOCKED", "expected": null} +{"id": "2026-11-01-003", "session": "s", "ts": "2026-11-02T10:02:00Z", "cwd": "/h3/r", "branch": "main", "tool": "Bash", "command": "cat $X", "file_path": null, "hook": "guard-main(Bash)", "message": "BLOCKED", "expected": null} +EOF +cat > "$FX/hook-labels-2026-11-01.json" <<'EOF' +{"home": "/h3", "layout": [{"path": "r", "branch": "main"}], + "cases": {"2026-11-01-001": {"expected": "allow", "why": "devnull"}, + "2026-11-01-002": {"expected": "block", "why": "main-redirect"}, + "2026-11-01-003": {"skip": "цель в переменной, состояние неизвестно"}}} +EOF +out=$(python3 "$B" --fixtures-dir "$FX"); rc=$? +assert_eq "сборка с разметкой ретро: exit 0" "$rc" "0" +assert_eq "в корпусе два сегмента" "$(printf '%s\n' "$out" | grep -c '"layout"')" "2" +assert_eq "сегмент ретро: две записи" "$(printf '%s\n' "$out" | grep -c '"2026-11-01-00[12]"')" "2" +assert_eq "запись со skip не в корпусе" "$(printf '%s\n' "$out" | grep -c '2026-11-01-003')" "0" +printf '%s\n' "$out" > "$FX/cases.jsonl" +assert_eq "корпус с сегментом ретро проходит хуки" "$(python3 "$R" "$ROOT/hooks" --summary --fixtures "$FX/cases.jsonl" | tail -1)" "корпус хуков: 131/131" +python3 - "$FX/hook-labels-2026-11-01.json" <<'PY' +import json, sys +p = sys.argv[1]; d = json.load(open(p)); del d["cases"]["2026-11-01-002"]; d["cases"]["2026-11-01-009"] = {"skip": "x"} +json.dump(d, open(p, "w")) +PY +out=$(python3 "$B" --fixtures-dir "$FX" 2>&1 >/dev/null); rc=$? +assert_eq "разметка не совпадает с выгрузкой: exit 1" "$rc" "1" +assert_grep "сообщение называет недостающий и лишний id" '2026-11-01-002.*2026-11-01-009' <(printf '%s' "$out") +``` + +- [ ] **Step 6: Запустить — тесты падают** + +Run: `bash tests/run.sh hook-corpus` +Expected: FAIL. Раннер выдаёт 5 колонок (status пустой — FAIL по записям корпуса), второй сегмент не собирается, `--fixtures-dir` не принимается. + +- [ ] **Step 7: Переписать `skills/harness-doctor/scripts/hook_corpus.py`** + +```python +#!/usr/bin/env python3 +"""Регрессионный корпус хуков (B2): прогон fixtures/hook-cases.jsonl через guard-main и require-plan в песочнице. + +Запуск: python3 hook_corpus.py <каталог хуков> [--summary] [--fixtures ] +Корпус — сегменты: строка с ключом layout ({home, layout}) открывает сегмент, следующие строки — его записи. +Для каждого сегмента строится своя песочница; префикс home записей заменяется на её home, HOME хуков — туда же. +Bash идёт через guard-main, Edit/Write — через guard-main и require-plan (блок, если блокирует любой). +Запись с known — известная ошибка хуков: расхождение — KNOWN (не ошибка прогона), совпадение — KNOWN-FIXED (ошибка: +снять known). Вывод: строка «idwhyожидалосьполученостатускоманда» и итог «total N/M»; +с --summary — только MISMATCH и KNOWN-FIXED и «корпус хуков: N/M[, известных ошибок K]». Код 0 — нет ошибок. +""" +import json +import os +import shutil +import subprocess +import sys +import tempfile + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +def _git(*args): + subprocess.run(["git", "-c", "user.name=corpus", "-c", "user.email=corpus@example.invalid"] + list(args), + check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def build(home, layout): + for e in layout: + p = os.path.join(home, e["path"]) + if "worktree_of" in e: + _git("-C", os.path.join(home, e["worktree_of"]), "worktree", "add", "-q", p, "-b", e["branch"]) + elif "branch" in e: + os.makedirs(p, exist_ok=True) + _git("init", "-q", p) + _git("-C", p, "symbolic-ref", "HEAD", "refs/heads/" + e["branch"]) + _git("-C", p, "commit", "-q", "--allow-empty", "-m", "init") + else: + os.makedirs(p, exist_ok=True) + + +def verdict(hooks, case, prefix, home, env): + swap = lambda s: s.replace(prefix, home) if isinstance(s, str) else s + tool = case["tool"] + ti = {"command": swap(case.get("command"))} if tool == "Bash" else {"file_path": swap(case.get("file_path"))} + payload = json.dumps({"cwd": swap(case.get("cwd")), "tool_name": tool, "tool_input": ti}) + for name in ["guard-main"] + ([] if tool == "Bash" else ["require-plan"]): + r = subprocess.run(["bash", os.path.join(hooks, name + ".sh")], input=payload, universal_newlines=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, timeout=30) + if r.returncode == 2: + return "block" + if r.returncode != 0: + return "error" + return "allow" + + +def segments(lines): + """[(шапка, [записи])]: строка с ключом layout открывает сегмент.""" + segs = [] + for obj in lines: + if "layout" in obj: + segs.append((obj, [])) + elif segs: + segs[-1][1].append(obj) + return segs + + +def status(case, got): + if got == case["expected"]: + return "KNOWN-FIXED" if case.get("known") else "ok" + return "KNOWN" if case.get("known") else "MISMATCH" + + +def main(argv): + args = argv[1:] + summary = "--summary" in args + fx = os.path.join(HERE, "..", "fixtures", "hook-cases.jsonl") + if "--fixtures" in args: + fx = args[args.index("--fixtures") + 1] + pos = [a for k, a in enumerate(args) if not a.startswith("--") and (k == 0 or args[k - 1] != "--fixtures")] + if not pos: + print(__doc__.strip().splitlines()[2]) + return 1 + hooks = os.path.abspath(pos[0]) + lines = [json.loads(l) for l in open(fx, encoding="utf-8") if l.strip()] + env0 = {k: v for k, v in os.environ.items() if k not in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE")} + tmp = tempfile.mkdtemp(prefix="hook-corpus-") + try: + total = good = known = 0 + for n, (head, cases) in enumerate(segments(lines)): + home = os.path.join(tmp, "seg-%d" % n, "home") + try: + build(home, head["layout"]) + except Exception as e: + print("корпус хуков: песочница сегмента %d не собрана (%s)" % (n + 1, e)) + return 1 + env = dict(env0, HOME=home) + for c in cases: + got = verdict(hooks, c, head["home"], home, env) + st = status(c, got) + total += 1 + good += st in ("ok", "KNOWN") + known += st == "KNOWN" + body = (c.get("command") or c.get("file_path") or "").replace(head["home"], "~") + row = "\t".join([c["id"], c["why"], c["expected"], got, st, " ".join(body.split())[:70]]) + if not summary: + print(row) + elif st in ("MISMATCH", "KNOWN-FIXED"): + print(st + " " + row) + tail = ", известных ошибок %d" % known if known else "" + print(("корпус хуков: %d/%d%s" if summary else "total %d/%d%s") % (good, total, tail)) + return 0 if good == total else 1 + finally: + shutil.rmtree(tmp, ignore_errors=True) + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) +``` + +- [ ] **Step 8: Изменить `docs/retro/scripts/hook_cases_build.py`** + +Строки шапки: + +```python +Запуск из корня репозитория: + python3 docs/retro/scripts/hook_cases_build.py > skills/harness-doctor/fixtures/hook-cases.jsonl +Вход: docs/retro/fixtures/hook-blocks.jsonl (hook_blocks_export.py, 12.10.2026, 126 записей). +Выход: первая строка — {"home": …, "layout": […]} (раскладка песочницы), дальше по записи на случай: +поля выгрузки + id, expected (allow|block), why (класс). Вердикт размечен вручную по спеке +openspec/specs/hook-guards/spec.md, а не прогоном хука. +""" +import json +``` + +заменить на: + +```python +Запуск из корня репозитория: + python3 docs/retro/scripts/hook_cases_build.py [--fixtures-dir <каталог>] > skills/harness-doctor/fixtures/hook-cases.jsonl +Вход: /hook-blocks.jsonl (hook_blocks_export.py, 12.10.2026, 126 записей) и пары ретро +hook-blocks-<дата>.jsonl + hook-labels-<дата>.json (B12, #29). +Выход — сегменты: строка {"home": …, "layout": […]} (раскладка песочницы), дальше по записи на случай: +поля выгрузки + id, expected (allow|block), why (класс)[, known]. Первый сегмент — пилот 12.10 и живые случаи, +дальше по сегменту на пару ретро; записи со skip не входят. Вердикт размечен вручную по спеке +openspec/specs/hook-guards/spec.md, а не прогоном хука. +""" +import glob +import json +``` + +Строку `RAW = os.path.join(HERE, "..", "fixtures", "hook-blocks.jsonl")` заменить на: + +```python +FIX = os.path.join(HERE, "..", "fixtures") +``` + +Функцию `main` и блок `if __name__ == "__main__":` целиком заменить на: + +```python +def label_segments(fix): + """Сегменты ретро: пары hook-blocks-<дата>.jsonl + hook-labels-<дата>.json. Разметка должна совпадать с выгрузкой.""" + for lp in sorted(glob.glob(os.path.join(fix, "hook-labels-*.json"))): + date = os.path.basename(lp)[len("hook-labels-"):-len(".json")] + lab = json.load(open(lp, encoding="utf-8")) + cases = lab.get("cases", {}) + recs = [json.loads(l) for l in open(os.path.join(fix, "hook-blocks-%s.jsonl" % date), encoding="utf-8") + if l.strip()] + ids = [r.get("id") for r in recs] + missing, extra = [i for i in ids if i not in cases], sorted(set(cases) - set(ids)) + if missing or extra: + raise SystemExit("hook-labels-%s.json: нет разметки для %s; лишняя разметка %s" + % (date, ", ".join(missing) or "—", ", ".join(extra) or "—")) + yield {"home": lab["home"], "layout": lab["layout"]} + for r in recs: + c = cases[r["id"]] + if "skip" in c: + continue + r.update({"expected": c["expected"], "why": c["why"]}) + if c.get("known"): + r["known"] = c["known"] + yield r + + +def main(argv): + fix = argv[argv.index("--fixtures-dir") + 1] if "--fixtures-dir" in argv else FIX + print(json.dumps({"home": HOME, "layout": LAYOUT}, ensure_ascii=False)) + raw = os.path.join(fix, "hook-blocks.jsonl") + for n, line in enumerate(l for l in open(raw, encoding="utf-8") if l.strip()): + r = json.loads(line) + cid = "c%03d" % (n + 1) + r.update({"id": cid, "expected": "block" if cid in BLOCK else "allow", "why": why_of(cid, r)}) + print(json.dumps(r, ensure_ascii=False)) + for cid, why, cmd in LIVE: + print(json.dumps({"id": cid, "session": "live-1015", "cwd": HOME + "/" + CH, "branch": "main", + "tool": "Bash", "command": cmd, "file_path": None, "hook": "guard-main(Bash)", + "message": "", "expected": "allow", "why": why}, ensure_ascii=False)) + for obj in label_segments(fix): + print(json.dumps(obj, ensure_ascii=False)) + + +if __name__ == "__main__": + main(sys.argv) +``` + +- [ ] **Step 9: Корпус собирается без изменений** + +Run: `python3 docs/retro/scripts/hook_cases_build.py | cmp - skills/harness-doctor/fixtures/hook-cases.jsonl && echo same` +Expected: `same`. Пар ретро пока нет, вывод тот же байт в байт. + +- [ ] **Step 10: Строка расхождений в `doctor.sh`** + +В `skills/harness-doctor/scripts/doctor.sh` строку + +```bash + printf '%s\n' "$out" | grep '^MISMATCH' | sed 's/^MISMATCH / /' +``` + +заменить на + +```bash + printf '%s\n' "$out" | grep -E '^(MISMATCH|KNOWN-FIXED) ' | sed 's/^/ /' +``` + +- [ ] **Step 11: Запустить тесты задачи и весь набор** + +Run: `bash tests/run.sh hook-corpus && bash tests/run.sh hook-export && bash tests/run.sh doctor && bash tests/run.sh` +Expected: везде `failed 0`. В `hook-corpus` по одной строке `ok` на запись корпуса (129) плюс 11 новых проверок. + +- [ ] **Step 12: Commit** + +```bash +git add docs/retro/scripts/hook_blocks_export.py docs/retro/scripts/hook_cases_build.py skills/harness-doctor/scripts/hook_corpus.py skills/harness-doctor/scripts/doctor.sh tests/test-hook-export.sh tests/test-hook-corpus.sh +git commit -m "feat(retro): корпус хуков по сегментам и known, выгрузка блокировок за период (#29)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 2: `retro_metrics.py` — срез метрик, кандидаты в поправки, сравнение + +**Files:** +- Create: `docs/retro/scripts/retro_metrics.py` +- Test: `tests/test-retro-metrics.sh` + +**Interfaces:** +- Consumes: `hook_blocks_export.HOOK_ERROR`, `hook_of(txt, tool)`, `redact(s)` (задача 1). +- Produces: + - CLI `retro_metrics.py <проект> [--since] [--harness] [--projects-root] [--labels] [--out] [--markdown] [--prev] [--corrections]`; + - функции `slug(path)`, `to_markdown(doc, prev) -> str`; + - ключи `metrics` (по ним задача 4 заполняет `2026-10-11.metrics.json`): `sessions`, `prompts`, `interrupts`, `turns`, `tool_calls`, `tools`, `roles`, `subagents`, `subagent_models`, `skills`, `tokens.{output,cache_read,cache_write}`, `compactions`, `start_context.{median,min,max}`, `hook_blocks`, `hook_blocks_by_hook`, `false_blocks.{labelled,skipped,false,share,fixed,known}`, `classifier_denials`, `classifier_by_reason`, `owner_denials`, `codegraph.{init,explore,code_reads,explore_share}`, `corrections`, `recall`, `memory.{index_bytes,files,bytes}`, `cd_main`. + - Значение метрики в прошлом файле: число, словарь, `{"retired": "<причина>"}` или `{"value": <число>, "note": "<как считалось>"}`. + +- [ ] **Step 1: Тест на синтетических транскриптах** + +Создать `tests/test-retro-metrics.sh`: + +```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, name, **inp): + return {"type": "tool_use", "id": tid, "name": name, "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", + "[]", +]) +W = P + "/.claude/worktrees/x" +w(A + "--claude-worktrees-x", "s2.jsonl", [ + u("ок", "2026-11-03T09:00:00Z", cwd=W), + 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 "токен заменён на " '' "$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") +``` + +- [ ] **Step 2: Запустить — тест падает** + +Run: `bash tests/run.sh retro-metrics` +Expected: FAIL — `retro_metrics.py` нет. + +- [ ] **Step 3: Создать `docs/retro/scripts/retro_metrics.py`** + +```python +#!/usr/bin/env python3 +"""Срез метрик обвязки по транскриптам и памяти одного проекта или workspace (B12, #29). + +Запуск: python3 retro_metrics.py <папка проекта> [--since ГГГГ-ММ-ДД] [--harness <репозиторий обвязки>] + [--projects-root <каталог>] [--labels ] [--out ] + [--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, 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"памят|перечитай|сверься|контекст|вспомни|прочитай .*md", re.I) +MARKERS = re.compile(r"(? os.path.getsize(best[sid]): + best[sid] = f + files = [] + for f in sorted(best.values()): + cwd, ts = first_meta(f) + if not cwd or not (inside(cwd, project) or inside(cwd, project + ".wt")): + continue + if since and (not ts or ts[:10] < since): + continue + files.append(f) + return sorted(set(os.path.basename(os.path.dirname(f)) for f in files)), 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("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 bash_reads_code(cmd, cwd, cache): + for part in SEPARATORS.split(cmd or ""): + try: + words = shlex.split(part) + except ValueError: + words = part.split() + if not words or 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"] = (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): + 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 = set(), {} + for rec in read_session(path, bad): + ts = rec.get("timestamp") + if isinstance(ts, str) and ts > s["last_ts"]: + s["last_ts"] = ts + t = rec.get("type") + if t == "system" and rec.get("subtype") == "compact_boundary": + s["compactions"] += 1 + elif t == "assistant": + msg = rec.get("message") or {} + mid = msg.get("id") + if mid is None or mid not in seen: + if mid is not None: + seen.add(mid) + s["turns"] += 1 + u = msg.get("usage") or {} + if u: + n = lambda k: u.get(k, 0) or 0 + if s["start"] is None: + s["start"] = n("input_tokens") + n("cache_creation_input_tokens") + n("cache_read_input_tokens") + 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 "?" + count_tool(s, b, rec.get("cwd"), cache) + elif t == "user": + 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 + 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): + """Кандидаты в поправки владельца с последним действием и хвостом ответа агента перед ними.""" + out, act, reply, sid = [], "", "", os.path.basename(path)[:8] + for rec in read_session(path, {}): + 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": + 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": redact(txt[:300]), + "action": redact(act[:300]), "reply_tail": redact(" ".join(reply.split())[-200:])}) + 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("--corrections", action="store_true") + return p.parse_args(argv[1:]) + + +def main(argv): + a = parse_args(argv) + project = os.path.abspath(a.project) + since = a.since or default_since(a.harness) + dirs, files = project_sessions(project, a.projects_root, since) + bad, cache = {"n": 0}, {} + stats = [s for s in (session_stats(f, cache, bad) for f in files) if s["prompts"] or s["turns"]] + if not stats: + sys.stderr.write("retro_metrics: нет сессий проекта %s (slug %s) в %s с %s\n" + % (project, slug(project), a.projects_root, since or "начала")) + return 1 + cands = [c for f in files for c in corrections(f)] + 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, json.load(open(a.prev, encoding="utf-8")) if a.prev else None)) + elif not a.out: + print(json.dumps(doc, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) +``` + +- [ ] **Step 4: Запустить — тест зелёный** + +Run: `bash tests/run.sh retro-metrics` +Expected: `passed 36, failed 0`. + +- [ ] **Step 5: Прогон на этой машине (живая проверка, только чтение)** + +Run: `python3 docs/retro/scripts/retro_metrics.py /Users/andrejepifancev/Projects/cc-harness --since 2026-10-15 --markdown | head -40` +Expected: таблица «Цифры» с ненулевыми сессиями, промптами и вызовами `codegraph_explore` (эта сессия), без исключений. Числа не проверяются: важно, что формат транскриптов этой машины разбирается. + +- [ ] **Step 6: Весь набор и коммит** + +Run: `bash tests/run.sh` +Expected: `failed 0`. + +```bash +git add docs/retro/scripts/retro_metrics.py tests/test-retro-metrics.sh +git commit -m "feat(retro): retro_metrics.py — срез метрик, кандидаты в поправки владельца, сравнение с прошлым ретро (#29)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3: `retro_leftovers.py` — хвосты чеклиста закрытия + +**Files:** +- Create: `docs/retro/scripts/retro_leftovers.py` +- Test: `tests/test-retro-leftovers.sh` + +**Interfaces:** +- Consumes: — +- Produces: + - CLI `retro_leftovers.py <проект или workspace> [--fetch] [--markdown]`; + - JSON `{"repos": [...], "items": [{"kind", "repo", "item", "hint"}], "totals": {kind: n}}`; + - kind ∈ `remote-branch|local-branch|worktree|sdd-workspace|openspec-change|main-behind|main-dirty|no-origin-main`. + +- [ ] **Step 1: Тест на песочнице репозиториев** + +Создать `tests/test-retro-leftovers.sh`: + +```bash +# retro_leftovers.py: хвосты чеклиста закрытия по состоянию репозиториев (спека harness-retro, #29). +L="$ROOT/docs/retro/scripts/retro_leftovers.py" +G="$TMP/lo"; mkdir -p "$G" +git init -q --bare -b main "$G/o.git" +git clone -q "$G/o.git" "$G/c" 2>/dev/null; git -C "$G/c" symbolic-ref HEAD refs/heads/main +printf '.superpowers/\n.claude/worktrees/\n' > "$G/c/.gitignore" +git -C "$G/c" add .gitignore && git -C "$G/c" commit -q -m init && git -C "$G/c" push -q origin main 2>/dev/null +git -C "$G/c" switch -q -c feat/1 && git -C "$G/c" commit -q --allow-empty -m f1 && git -C "$G/c" push -q -u origin feat/1 2>/dev/null +git -C "$G/c" switch -q main && git -C "$G/c" merge -q --no-ff feat/1 -m "merge feat/1" +mkdir -p "$G/c/openspec/changes/foo" "$G/c/openspec/changes/archive/2026-01-01-bar" +echo x > "$G/c/openspec/changes/foo/proposal.md"; echo y > "$G/c/openspec/changes/archive/2026-01-01-bar/p.md" +git -C "$G/c" add openspec && git -C "$G/c" commit -q -m spec && git -C "$G/c" push -q origin main 2>/dev/null +git -C "$G/c" worktree add -q "$G/c/.claude/worktrees/w1" feat/1 +mkdir -p "$G/c/.superpowers/sdd/plan-x" && touch "$G/c/.superpowers/sdd/plan-x/progress.md" +git clone -q "$G/o.git" "$G/c2" 2>/dev/null && git -C "$G/c2" commit -q --allow-empty -m ahead && git -C "$G/c2" push -q origin main 2>/dev/null +git -C "$G/c" fetch -q origin +echo x > "$G/c/untracked.txt" +J="$TMP/lo.json" +python3 "$L" "$G/c" > "$J"; rc=$? +assert_eq "хвосты: exit 0" "$rc" "0" +k(){ python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(eval(sys.argv[2]))' "$J" "$1"; } +assert_eq "виды хвостов" "$(k 'sorted(d["totals"])')" "['local-branch', 'main-behind', 'main-dirty', 'openspec-change', 'remote-branch', 'sdd-workspace', 'worktree']" +assert_eq "ветка на сервере и подсказка" "$(k '[(i["item"], i["hint"]) for i in d["items"] if i["kind"] == "remote-branch"]')" "[('origin/feat/1', 'git push origin --delete feat/1')]" +assert_eq "локальная ветка" "$(k '[i["item"] for i in d["items"] if i["kind"] == "local-branch"]')" "['feat/1']" +assert_eq "worktree на слитой ветке" "$(k '[i["item"].endswith("/.claude/worktrees/w1") for i in d["items"] if i["kind"] == "worktree"]')" "[True]" +assert_eq "SDD workspace" "$(k '[i["item"].endswith("/sdd/plan-x") for i in d["items"] if i["kind"] == "sdd-workspace"]')" "[True]" +assert_eq "неархивный change — без archive" "$(k '[i["item"] for i in d["items"] if i["kind"] == "openspec-change"]')" "['openspec/changes/foo']" +assert_grep "отставание от origin/main" 'позади origin/main на 1' "$J" +assert_grep "грязное дерево с путём" 'untracked.txt' "$J" +# только чтение: без --fetch ссылки и дерево не меняются +git -C "$G/c2" commit -q --allow-empty -m ahead2 && git -C "$G/c2" push -q origin main 2>/dev/null +before="$(git -C "$G/c" for-each-ref)$(git -C "$G/c" status --porcelain)" +python3 "$L" "$G/c" > /dev/null +assert_eq "без --fetch ничего не меняется" "$(git -C "$G/c" for-each-ref)$(git -C "$G/c" status --porcelain)" "$before" +python3 "$L" "$G/c" --fetch > "$J" +assert_grep "--fetch видит новые коммиты" 'позади origin/main на 2' "$J" +md=$(python3 "$L" "$G/c" --markdown) +assert_grep "таблица для черновика" '^\| remote-branch \| c \| `origin/feat/1` \| `git push origin --delete feat/1` \|$' <(printf '%s' "$md") +# чистый репозиторий: свежая ветка на вершине origin/main — не хвост +git init -q --bare -b main "$G/k.git"; git clone -q "$G/k.git" "$G/k" 2>/dev/null; git -C "$G/k" symbolic-ref HEAD refs/heads/main +git -C "$G/k" commit -q --allow-empty -m init && git -C "$G/k" push -q origin main 2>/dev/null && git -C "$G/k" branch feat/new +python3 "$L" "$G/k" > "$J" +assert_eq "чистый репозиторий — хвостов нет" "$(k 'd["items"]')" "[]" +assert_eq "чистый репозиторий — markdown" "$(python3 "$L" "$G/k" --markdown)" "Хвостов закрытия нет." +# без origin +git init -q -b main "$G/n" && git -C "$G/n" commit -q --allow-empty -m init +python3 "$L" "$G/n" > "$J"; rc=$? +assert_eq "без origin: exit 0 и no-origin-main" "$rc $(k '[i["kind"] for i in d["items"]]')" "0 ['no-origin-main']" +# workspace: репозитории из таблицы CLAUDE.md +W="$TMP/ws"; mkdir -p "$W"; git clone -q "$G/o.git" "$W/r" 2>/dev/null +printf '# w\n\n| Каталог | Репозиторий | Роль |\n|---|---|---|\n| r/ | o/r | dev |\n| - | x | qa |\n' > "$W/CLAUDE.md" +python3 "$L" "$W" > "$J" +assert_eq "workspace: репозитории из таблицы" "$(k '[r.endswith("/ws/r") for r in d["repos"]]')" "[True]" +assert_eq "workspace: ветка на сервере видна" "$(k '[i["item"] for i in d["items"] if i["kind"] == "remote-branch"]')" "['origin/feat/1']" +# не проект и не workspace +mkdir -p "$TMP/empty"; python3 "$L" "$TMP/empty" > /dev/null 2>&1; rc=$? +assert_eq "не репозиторий и не workspace: exit 1" "$rc" "1" +``` + +- [ ] **Step 2: Запустить — тест падает** + +Run: `bash tests/run.sh retro-leftovers` +Expected: FAIL — `retro_leftovers.py` нет. + +- [ ] **Step 3: Создать `docs/retro/scripts/retro_leftovers.py`** + +```python +#!/usr/bin/env python3 +"""Хвосты чеклиста закрытия по текущему состоянию репозиториев проекта или workspace (B12, #29). + +Запуск: python3 retro_leftovers.py <папка проекта или workspace> [--fetch] [--markdown] +Репозитории: сам git-проект или каталоги из таблицы «| Каталог | Репозиторий | Роль |» в CLAUDE.md workspace. +Хвост: слитая в origin/main ветка на сервере или локально, worktree на ней или prunable, .superpowers/sdd/*, +неархивный openspec/changes// в origin/main, основной checkout позади origin/main или с изменениями. +Ничего не меняет; --fetch делает только git fetch --prune origin. Спека: openspec/specs/harness-retro/spec.md. +""" +import collections +import glob +import json +import os +import re +import subprocess +import sys + +TABLE = re.compile(r"^\|\s*Каталог\s*\|\s*Репозиторий\s*\|\s*Роль\s*\|") +PROTECTED = ("main", "master") + + +def git(repo, *args): + env = {k: v for k, v in os.environ.items() if k not in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE")} + r = subprocess.run(["git", "-C", repo] + list(args), stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + universal_newlines=True, env=env) + return r.stdout if r.returncode == 0 else None + + +def lines(s): + return [l.strip() for l in (s or "").splitlines() if l.strip()] + + +def repos_of(project): + project = os.path.abspath(project) + if os.path.exists(os.path.join(project, ".git")): + return [project] + md, out, on = os.path.join(project, "CLAUDE.md"), [], False + if not os.path.isfile(md): + return out + for line in open(md, encoding="utf-8", errors="replace"): + if TABLE.match(line): + on = True + continue + if not on: + continue + if not line.startswith("|"): + break + d = line.strip().strip("|").split("|")[0].strip() + if not d or set(d) <= set("-: "): + continue + p = os.path.join(project, d.rstrip("/")) + if os.path.exists(os.path.join(p, ".git")): + out.append(p) + return out + + +def worktrees(repo): + out, cur = [], {} + for l in (git(repo, "worktree", "list", "--porcelain") or "").splitlines() + [""]: + if not l.strip(): + if cur: + out.append(cur) + cur = {} + continue + k, _, v = l.partition(" ") + cur[k] = v or True + return out + + +def leftovers(repo, fetch=False): + items = [] + + def add(kind, item, hint): + items.append({"kind": kind, "repo": repo, "item": item, "hint": hint}) + + if fetch: + git(repo, "fetch", "--prune", "-q", "origin") + sha = lambda ref: (git(repo, "rev-parse", "-q", "--verify", ref) or "").strip() + tip = sha("origin/main") + if not tip: + add("no-origin-main", "origin/main", "нет origin/main: git fetch origin или проверить remote") + return items + for b in lines(git(repo, "branch", "-r", "--merged", "origin/main")): + if "->" in b or not b.startswith("origin/") or b[len("origin/"):] in PROTECTED + ("HEAD",): + continue + if sha(b) != tip: + add("remote-branch", b, "git push origin --delete " + b[len("origin/"):]) + stale = set() + for b in lines(git(repo, "branch", "--merged", "origin/main", "--format=%(refname:short)")): + if b not in PROTECTED and sha("refs/heads/" + b) != tip: + stale.add(b) + for l in lines(git(repo, "for-each-ref", "--format=%(refname:short) %(upstream:track)", "refs/heads")): + name, _, track = l.partition(" ") + if "gone" in track and name not in PROTECTED: + stale.add(name) + for b in sorted(stale): + add("local-branch", b, "git branch -D " + b) + wts = worktrees(repo) + main_wt = wts[0]["worktree"] if wts else repo + for w in wts[1:]: + br = str(w.get("branch") or "") + if br.startswith("refs/heads/"): + br = br[len("refs/heads/"):] + if w.get("prunable") or br in stale: + add("worktree", w["worktree"], "ExitWorktree, затем git worktree remove " + w["worktree"]) + for w in wts or [{"worktree": repo}]: + for d in sorted(glob.glob(os.path.join(w["worktree"], ".superpowers", "sdd", "*"))): + if os.path.isdir(d): + add("sdd-workspace", d, "rm -rf " + d) + for c in lines(git(repo, "ls-tree", "-d", "--name-only", "origin/main", "openspec/changes/")): + name = c.rstrip("/").rsplit("/", 1)[-1] + if name != "archive": + add("openspec-change", c, "openspec archive %s — отдельным PR" % name) + behind = (git(main_wt, "rev-list", "--count", "HEAD..origin/main") or "0").strip() + if behind not in ("", "0"): + add("main-behind", main_wt, "git pull --ff-only (позади origin/main на %s)" % behind) + dirty = [l for l in (git(main_wt, "status", "--porcelain") or "").splitlines() if l.strip()] + if dirty: + add("main-dirty", main_wt, "не трогать, перечислить: " + ", ".join(l[3:] for l in dirty[:5])) + return items + + +def to_markdown(items): + if not items: + return "Хвостов закрытия нет." + out = ["| Вид | Репозиторий | Что | Чем убрать |", "|---|---|---|---|"] + for i in items: + out.append("| %s | %s | `%s` | `%s` |" % (i["kind"], os.path.basename(i["repo"]), i["item"], i["hint"])) + return "\n".join(out) + + +def main(argv): + args = argv[1:] + pos = [a for a in args if not a.startswith("--")] + if not pos: + print(__doc__.strip().splitlines()[2]) + return 1 + repos = repos_of(pos[0]) + if not repos: + sys.stderr.write("retro_leftovers: в %s нет git-репозитория и таблицы репозиториев workspace\n" % pos[0]) + return 1 + items = [i for r in repos for i in leftovers(r, "--fetch" in args)] + if "--markdown" in args: + print(to_markdown(items)) + else: + print(json.dumps({"repos": repos, "items": items, + "totals": dict(collections.Counter(i["kind"] for i in items))}, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) +``` + +- [ ] **Step 4: Запустить — тест зелёный** + +Run: `bash tests/run.sh retro-leftovers` +Expected: `passed 18, failed 0`. + +- [ ] **Step 5: Прогон на этом репозитории (живая проверка, только чтение)** + +Run: `python3 docs/retro/scripts/retro_leftovers.py /Users/andrejepifancev/Projects/cc-harness --markdown` +Expected: таблица или «Хвостов закрытия нет.». Ветка `feat/29-harness-retro` хвостом не считается: её нет на сервере, и она не слита. + +- [ ] **Step 6: Весь набор и коммит** + +Run: `bash tests/run.sh` +Expected: `failed 0`. + +```bash +git add docs/retro/scripts/retro_leftovers.py tests/test-retro-leftovers.sh +git commit -m "feat(retro): retro_leftovers.py — хвосты чеклиста закрытия по состоянию репозиториев (#29)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 4: Skill `/harness-retro`, шаблон, метрики 11.10, документация + +**Files:** +- Create: `.claude/skills/harness-retro/SKILL.md`, `.claude/skills/harness-retro/template.md` +- Create: `docs/retro/2026-10-11.metrics.json` +- Delete: `docs/retro/scripts/retro_stats.py`, `docs/retro/scripts/retro_blocks.py` +- Modify: `CLAUDE.md`, `docs/HARNESS.md`, `README.md`, `CHANGELOG.md` +- Test: `tests/test-retro-skill.sh` + +**Interfaces:** +- Consumes: + - CLI задач 1–3; + - `retro_metrics.to_markdown(doc, prev)`; + - ключи `metrics` и формат `{"retired"}`/`{"value","note"}` (задача 2). +- Produces: — + +- [ ] **Step 1: Тест** + +Создать `tests/test-retro-skill.sh`: + +```bash +# /harness-retro: проектный skill, шаблон, метрики 11.10, удалённые скрипты (#29). +S="$ROOT/.claude/skills/harness-retro" +assert_file "SKILL.md" "$S/SKILL.md" +assert_grep "SKILL.md: name" '^name: harness-retro$' "$S/SKILL.md" +for step in 'retro_metrics\.py' 'hook_blocks_export\.py' 'hook_cases_build\.py' 'retro_leftovers\.py' '--corrections' 'EnterWorktree' 'tea pulls create'; do + assert_grep "SKILL.md упоминает $step" "$step" "$S/SKILL.md" +done +assert_file "template.md" "$S/template.md" +for sec in "## 1. Цифры" "## 2. Находки" "## 3. Поправки владельца" "## 4. Что сработало" "## 5. Бэклог" "## 6. Решения владельца" "## 7. Что не измерялось"; do + assert_grep "шаблон: $sec" "^$sec" "$S/template.md" +done +J="$ROOT/docs/retro/2026-10-11.metrics.json" +assert_eq "метрики 11.10: схема, cd снят, ложные" "$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); m=d["metrics"]; print(d["schema"], "retired" in m["cd_main"], m["false_blocks"]["false"], m["hook_blocks"])' "$J")" "1 True 72 76" +out=$(python3 - "$ROOT/docs/retro/scripts" "$J" <<'PY' +import json, sys +sys.path.insert(0, sys.argv[1]) +import retro_metrics +prev = json.load(open(sys.argv[2])) +doc = {"metrics": {"sessions": 3, "turns": 7, "hook_blocks": 1}, "period": {"since": "2026-10-11", "until": "2026-10-24"}, + "sources": {"sessions": 3, "dirs": [], "bad_lines": 0}} +print(retro_metrics.to_markdown(doc, prev)) +PY +) +assert_grep "сравнение с 11.10: сессии" '^\| Сессий \| 3 \| 44 \| -41 \|$' <(printf '%s' "$out") +assert_grep "сравнение с 11.10: блокировки" '^\| Блокировок хуков \| 1 \| 76 \| -75 \|$' <(printf '%s' "$out") +assert_grep "сравнение с 11.10: cd снят" '^\| cd в основной checkout на main \| — \| — \| не считается' <(printf '%s' "$out") +assert_grep "сравнение с 11.10: ходы считались по строкам" '^\| Ходов модели \| 7 \| 12 121 \(по строкам' <(printf '%s' "$out") +assert_eq "retro_stats.py удалён" "$([ -e "$ROOT/docs/retro/scripts/retro_stats.py" ] && echo есть || echo нет)" "нет" +assert_eq "retro_blocks.py удалён" "$([ -e "$ROOT/docs/retro/scripts/retro_blocks.py" ] && echo есть || echo нет)" "нет" +assert_eq "CLAUDE.md без ссылок на удалённые скрипты" "$(grep -c 'retro_stats\|retro_blocks' "$ROOT/CLAUDE.md")" "0" +assert_grep "CLAUDE.md: retro_metrics в командах" 'retro_metrics\.py' "$ROOT/CLAUDE.md" +assert_grep "HARNESS.md: раздел Ретро" '^## Ретро' "$ROOT/docs/HARNESS.md" +``` + +- [ ] **Step 2: Запустить — тест падает** + +Run: `bash tests/run.sh retro-skill` +Expected: FAIL — нет skill, шаблона и метрик 11.10, скрипты ещё на месте. + +- [ ] **Step 3: Создать `.claude/skills/harness-retro/SKILL.md`** + +~~~markdown +--- +name: harness-retro +description: Ретро обвязки claude-harness по транскриптам, памяти и репозиториям одного проекта или workspace — срез метрик, разметка блокировок хуков в корпус, хвосты закрытия, поправки владельца, черновик docs/retro/<дата>.md и PR. Запускать в сессии в репозитории обвязки по команде «/harness-retro <папка> [--since ГГГГ-ММ-ДД]» после среза. +--- + +# /harness-retro + +Аргумент — папка проекта или workspace, который изучаем. Сессия — в репозитории обвязки, роль dev. Дата ретро `` — сегодня, `ГГГГ-ММ-ДД`. Скрипты — `docs/retro/scripts/`, все команды — из корня worktree ретро. Спека — `openspec/specs/harness-retro/spec.md`. + +## Шаги + +1. **Задача и worktree.** Issue «Ретро : <проект>» (`tea issues create`). Затем `EnterWorktree` с именем `retro-`, ветка `git branch -m retro/`, `codegraph init` в worktree. +2. **Срез.** Выполнить `python3 docs/retro/scripts/retro_metrics.py <папка> [--since …] --out docs/retro/.metrics.json`. + - Exit 1 — сессий нет. Сообщить владельцу slug и период и остановиться. + - Битых строк больше 1 % — отметить в «Что не измерялось». +3. **Блокировки.** Сначала выгрузить: `python3 docs/retro/scripts/hook_blocks_export.py --since --id-prefix <каталоги из sources.dirs> > docs/retro/fixtures/hook-blocks-.jsonl`. Каталоги — `~/.claude/projects/<имя>`. + - Разметить каждую запись в `docs/retro/fixtures/hook-labels-.json`: + - `expected` (`allow|block`) и `why` по спеке `hook-guards`; + - раскладку песочницы — по `gitBranch` сессий и выводу `git worktree list` и `git branch` в транскриптах; + - если не восстановить — `skip` с причиной. + - Собрать корпус: `python3 docs/retro/scripts/hook_cases_build.py > skills/harness-doctor/fixtures/hook-cases.jsonl` и прогнать `bash tests/run.sh hook-corpus`. Если текущий хук даёт не тот вердикт — проставить записи `"known": "RT-n"` и завести находку. + - Пересчитать срез с `--labels docs/retro/fixtures/hook-labels-.json --out docs/retro/.metrics.json`. +4. **Хвосты закрытия.** `python3 docs/retro/scripts/retro_leftovers.py <папка> --fetch --markdown`. Ничего не удалять: таблица идёт в черновик, уборка — по решению владельца. +5. **Поправки владельца.** `python3 docs/retro/scripts/retro_metrics.py <папка> [--since …] --corrections`. + - Отобрать только поправки по процессу: пропущенный шаг, лишний вопрос, неверный совет, обход правила, расширение задачи. Вопросы и просьбы по содержанию выбросить. + - Сгруппировать по видам. + - Вид, который повторяется (2 раза и больше) или стоил владельцу заметного времени, — находка `RT-n` и пункт бэклога. +6. **Качественная часть.** Прочитать память проекта целиком (`~/.claude/projects//memory/`) и примеры из выгрузок. Находки `RT-n` — сквозная нумерация: максимум в прошлых `docs/retro/*.md` плюс 1. Приоритет: P1 — теряет время каждую неделю или ломает процесс; P2 — трение; P3 — наблюдение. +7. **Черновик.** Скопировать `.claude/skills/harness-retro/template.md` в `docs/retro/.md` и заполнить. + - «Цифры»: `python3 docs/retro/scripts/retro_metrics.py <папка> [--since …] --labels … --markdown --prev docs/retro/<прошлое>.metrics.json`. + - Секретов в черновике быть не должно. Цитаты брать только из вывода скриптов: он уже прошёл `redact`. +8. **PR.** `bash tests/run.sh` должен быть зелёным. Коммит: черновик, `metrics.json`, выгрузка, разметка, корпус. Затем `git push -u origin retro/` и `tea pulls create` со ссылкой на issue. Merge — только владелец. + +## Не делать + +- Не удалять хвосты и не чинить хуки в PR ретро — это находки и бэклог. +- Не заполнять раздел «Решения владельца»: он пишется на ревью PR. +- Не вставлять в черновик сырые куски транскриптов мимо скриптов. +~~~ + +- [ ] **Step 4: Создать `.claude/skills/harness-retro/template.md`** + +~~~markdown +# Ретроспектива обвязки claude-harness — <ДД.ММ.ГГГГ> + +Период: –, полигон — <проект или workspace>. +Предыдущая точка сравнения — `docs/retro/<прошлое>.md`. + +Источники: +- транскриптов сессий (<каталоги из sources.dirs>, <объём>); +- файлов auto-memory (<объём>); +- состояние репозиториев (`retro_leftovers.py`); +- блокировки среза, размеченные в корпус: `docs/retro/fixtures/hook-labels-.json`. + +Количественную часть считал `docs/retro/scripts/retro_metrics.py`, качественную — по прочтению памяти целиком и кандидатов в поправки. + +Маркировка находок: `RT-n`, продолжение нумерации прошлых ретро. Приоритет: P1 — теряет время каждую неделю или ломает процесс; P2 — трение; P3 — наблюдение. + +## 1. Цифры + +<таблица из retro_metrics.py --markdown --prev> + +Хвосты чеклиста закрытия: + +<таблица из retro_leftovers.py --markdown> + +## 2. Находки + +**RT- (P). <Одна фраза — что происходит>.** <Факты: где, сколько раз, чем стоило>. Источники: <сессии, память, метрика>. + +## 3. Поправки владельца + +| Вид | Сколько | Пример (цитата, что было до неё) | Что вышло | Находка | +|---|---|---|---|---| + +## 4. Что сработало (сохранить) + +- <…> + +## 5. Бэклог улучшений (по приоритету) + +| # | Что | Находки | Оценка | +|---|---|---|---| + +## 6. Решения владельца (<дата>) + +Заполняется на ревью PR. + +## 7. Что не измерялось + +- Сторона Gitea чеклиста закрытия: открытые issue после слитого PR, пропущенные follow-up. +- <…> +~~~ + +- [ ] **Step 5: Создать `docs/retro/2026-10-11.metrics.json`** + +```json +{ + "schema": 1, + "period": {"since": "2026-10-02", "until": "2026-10-11"}, + "project": "/Users/andrey.epifancev/projects/awp-ca", + "sources": { + "dirs": ["-Users-andrey-epifancev-projects-awp-ca"], + "sessions": 44, + "bytes": 881852416, + "bad_lines": null, + "note": "перенесено вручную из таблицы «Цифры» docs/retro/2026-10-11.md (#29); 47 транскриптов, 841 МБ" + }, + "metrics": { + "sessions": 44, + "prompts": 613, + "interrupts": null, + "turns": {"value": 12121, "note": "по строкам транскрипта, не по сообщениям"}, + "tool_calls": 6583, + "tools": {"Bash": 4302, "Playwright": 1100, "Read": 346, "Write": 271, "Edit": 233}, + "roles": {"dev": 32, "admin": 7, "qa": 5}, + "subagents": 61, + "subagent_models": {"sonnet": 47, "opus": 9, "не указана": 5}, + "skills": {"brainstorming": 19, "writing-plans": 14, "systematic-debugging": 8, "subagent-driven-development": 4, + "executing-plans": 3, "using-git-worktrees": 3, "test-driven-development": 1, + "finishing-a-development-branch": 1}, + "tokens": { + "output": {"value": 18300000, "note": "по строкам транскрипта"}, + "cache_read": {"value": 3820000000, "note": "по строкам транскрипта"}, + "cache_write": {"value": 145000000, "note": "по строкам транскрипта"} + }, + "compactions": 4, + "start_context": {"median": 56000, "min": 52000, "max": 61000}, + "hook_blocks": 76, + "hook_blocks_by_hook": null, + "false_blocks": {"labelled": 76, "skipped": 0, "false": 72, "share": 0.95, "fixed": 72, "known": 0}, + "classifier_denials": 6, + "classifier_by_reason": {"Git Destructive": 2, "Auto-Mode Bypass": 2, "Interfere With Workloads": 1, "safety-check": 1}, + "owner_denials": 6, + "codegraph": {"init": 43, "explore": 15, "code_reads": null, "explore_share": null}, + "corrections": null, + "recall": 9, + "memory": {"index_bytes": null, "files": 47, "bytes": 183296}, + "cd_main": {"retired": "хуки 0.4.0 не зависят от cwd (RT-2); считалось по именам репозиториев awp-ca, было 84"} + } +} +``` + +- [ ] **Step 6: Удалить старые скрипты** + +Run: `git rm -q docs/retro/scripts/retro_stats.py docs/retro/scripts/retro_blocks.py` +Expected: файлы удалены из индекса и с диска. + +- [ ] **Step 7: Документация** + +**`CLAUDE.md`, раздел «Архитектура»**, в строке про `docs/HARNESS.md` фразу + +`` `docs/retro/` — ретроспектива 11.10.2026 и скрипты подсчёта по транскриптам. `` + +заменить на + +`` `docs/retro/` — ретроспективы, их метрики (`<дата>.metrics.json`) и скрипты: `retro_metrics.py` (срез метрик и поправки владельца), `retro_leftovers.py` (хвосты закрытия), `hook_blocks_export.py` и `hook_cases_build.py` (блокировки → корпус); проектный skill `/harness-retro` — `.claude/skills/harness-retro/`. `` + +**`CLAUDE.md`, раздел «Команды»**, строку + +`` - `python3 docs/retro/scripts/retro_stats.py <каталог транскриптов>` и `retro_blocks.py` — статистика сессий и блокировок хуков для ретро; `hook_blocks_export.py <каталог> [...]` — выгрузка блокировок в JSONL (сырьё для тестов хуков); `hook_cases_build.py > skills/harness-doctor/fixtures/hook-cases.jsonl` — сборка размеченного корпуса из выгрузки. `` + +заменить на + +`` - `/harness-retro <папка проекта> [--since ГГГГ-ММ-ДД]` — ретро обвязки (в сессии в этом репозитории). Под ним: `python3 docs/retro/scripts/retro_metrics.py <папка> [--since …] [--out …] [--markdown --prev …] [--corrections]` — срез метрик; `retro_leftovers.py <папка> [--fetch] [--markdown]` — хвосты закрытия; `hook_blocks_export.py [--since …] [--id-prefix …] <каталог> [...]` — выгрузка блокировок в JSONL; `hook_cases_build.py > skills/harness-doctor/fixtures/hook-cases.jsonl` — сборка корпуса из разметки. `` + +**`CLAUDE.md`, раздел «Тесты»**, в строке «Хуки: …» фразу `` `test-hook-export` — экстрактор. `` заменить на + +`` `test-hook-export` — экстрактор. Ретро: `test-retro-metrics` (синтетические транскрипты), `test-retro-leftovers` (песочница репозиториев), `test-retro-skill`. `` + +**`CLAUDE.md`, раздел «Текущая работа»**, строку `- Дальше: B12.` заменить на + +```markdown +- Сделано: B12 — `/harness-retro`: срез метрик, поправки владельца, хвосты закрытия, корпус по сегментам (issue #29, 0.6.0), спека — `openspec/specs/harness-retro/`. +- Дальше: первое ретро `/harness-retro` по полигону — на машине с awp-ca; остальные пункты бэклога — по мере надобности. +``` + +**`docs/HARNESS.md`**: перед строкой `## Стоимость` вставить + +```markdown +## Ретро + +Регулярное ретро — `/harness-retro <папка проекта или workspace> [--since ГГГГ-ММ-ДД]`, проектный skill этого репозитория (`.claude/skills/harness-retro/`). Запускается в сессии в репозитории обвязки по команде владельца после среза и идёт по циклу фичи: issue, worktree, PR. Результат — черновик `docs/retro/<дата>.md` по шаблону и `docs/retro/<дата>.metrics.json`. + +- `retro_metrics.py` — срез метрик: + - сессии, промпты, инструменты, роли, сабагенты, токены, стартовый контекст; + - блокировки и отказы; + - codegraph против чтения кода; + - память. + + Сравнение с прошлым ретро — таблица «Цифры». Кандидаты в поправки владельца по процессу — `--corrections`. +- Блокировки среза агент размечает по спеке `hook-guards` в `docs/retro/fixtures/hook-labels-<дата>.json`, и они становятся сегментом регрессионного корпуса. Запись с `known` — известная ошибка хуков; doctor показывает их число. +- `retro_leftovers.py` — хвосты чеклиста закрытия по состоянию репозиториев. Он только подсказывает, как убрать; убирает владелец. + +``` + +**`README.md`**, в блоке «Файлы» после строки `tests/run.sh тесты пакета (bash): bash tests/run.sh` добавить + +``` +.claude/skills/harness-retro/ проектный skill /harness-retro: ретро обвязки по транскриптам проекта (запуск из этого репозитория) +docs/retro/scripts/ retro_metrics.py (срез метрик), retro_leftovers.py (хвосты закрытия), выгрузка и разметка блокировок +``` + +**`CHANGELOG.md`**: после строки `# Changelog` и пустой строки вставить + +```markdown +## 0.6.0 — 2026-10-24 +- **`/harness-retro`** (B12, #29) — проектный skill регулярного ретро обвязки. Работает по циклу фичи: issue, worktree, PR. Результат — черновик `docs/retro/<дата>.md` по шаблону и `<дата>.metrics.json` со сравнением с прошлым ретро. +- `docs/retro/scripts/retro_metrics.py` считает срез метрик проекта по транскриптам и памяти: + - сессии, промпты, инструменты, роли, сабагенты, skills, токены (по `message.id`), компакции, стартовый контекст; + - блокировки хуков, отказы классификатора и владельца; + - codegraph против чтения кода (решение владельца 3); + - «перечитай», память; + - кандидаты в поправки владельца по процессу (`--corrections`). + + Заменяет `retro_stats.py` и `retro_blocks.py`. +- `docs/retro/scripts/retro_leftovers.py` ищет хвосты чеклиста закрытия по состоянию репозиториев: слитые ветки на сервере и локально, worktree, SDD workspace, неархивные changes, отставший или грязный main. +- Корпус хуков разбит на сегменты со своей раскладкой; записи `known` — известные ошибки хуков (`KNOWN-FIXED` — пора снять пометку), doctor показывает их число. `hook_blocks_export.py --since --id-prefix` выгружает блокировки среза, `hook_cases_build.py` собирает корпус из `hook-labels-<дата>.json`. +- `docs/retro/2026-10-11.metrics.json` — метрики ретро 11.10, перенесённые из таблицы. Блокировки — 76 ошибок хука, из них 72 ложные (O-1). Ходы и токены 11.10 считались по строкам и помечены как несравнимые. + +``` + +- [ ] **Step 8: Запустить тест задачи и весь набор** + +Run: `bash tests/run.sh retro-skill && bash tests/run.sh` +Expected: везде `failed 0`. + +- [ ] **Step 9: Commit** + +```bash +git add .claude/skills/harness-retro docs/retro/2026-10-11.metrics.json CLAUDE.md docs/HARNESS.md README.md CHANGELOG.md tests/test-retro-skill.sh +git commit -m "feat(retro): skill /harness-retro, шаблон ретро, метрики 11.10; документация 0.6.0 (#29)" -m "Co-Authored-By: Claude Opus 5.5 " +``` diff --git a/openspec/changes/harness-retro/design.md b/openspec/changes/harness-retro/design.md index 7a5ebfa..913aa58 100644 --- a/openspec/changes/harness-retro/design.md +++ b/openspec/changes/harness-retro/design.md @@ -83,7 +83,9 @@ Bash-команда считается чтением кода, если хот ### 5. Блокировки, отказы, роли - Блокировка: результат инструмента совпадает с `^\s*PreToolUse:\S+ hook error` и содержит `BLOCKED`. Хук определяется так же, как в `hook_blocks_export.hook_of`, функция импортируется. - Отказ классификатора: `denied by the Claude Code auto mode classifier. Reason: \[([^\]]+)\]`. -- Отказ владельца: результат инструмента начинается с `The user doesn't want to proceed with this tool use` или содержит `[Request interrupted by user for tool use]`. +- Отказ владельца: результат инструмента содержит `The user doesn't want to proceed with this tool use`. Прерывание (`[Request interrupted by user…`) считается отдельной метрикой, а не отказом. +- Ход модели — уникальный `message.id` ассистента. Claude Code пишет одно сообщение несколькими строками с общим `id` и одинаковым `usage` (проверено на транскриптах этой машины: 538 id на 1381 строку). Поэтому токены считаются один раз на `id`. Строки без `id` считаются по одной. +- Сессия — файл `.jsonl`. Транскрипт переезжает в каталог worktree (запись `relocated`); если один id встретился в нескольких каталогах, берётся самый большой файл. - Роль: первый `tool_use` с `name: Skill` и `input.skill` = `role` (или `…:role`), аргумент — `input.args`. ### 6. Кандидаты в поправки @@ -171,4 +173,6 @@ Bash-команда считается чтением кода, если хот | 2026-10-24 | Метрика `cd` в main — `retired` | RT-2 снят хуками 0.4.0; счёт был по именам репозиториев awp-ca | Потеря одного ряда сравнения | | 2026-10-24 | Блокировки 11.10 в `metrics.json` — 76 ошибок хука, 72 ложных (по корпусу), а не 103 из таблицы | 103 завышено выводами со словом `BLOCKED` (O-1) | Сравнение с таблицей 11.10 по этой строке расходится с документом | | 2026-10-24 | `retro_stats.py` и `retro_blocks.py` удаляются | Их заменяет `retro_metrics.py`; в ретро 11.10 они упомянуты как история | Чтобы воспроизвести 11.10 буква в букву, нужен git history | +| 2026-10-24 | Ходы и токены — по уникальному `message.id`, а не по строкам транскрипта | Строки одного сообщения повторяют `usage`; подсчёт по строкам завышает токены в 2–3 раза | Ходы и токены 11.10 (по строкам) несравнимы; в `2026-10-11.metrics.json` они помечены | +| 2026-10-24 | Свежая ветка, совпадающая с `origin/main`, — не хвост | `--merged` считает слитой и ветку без коммитов, например только что созданный worktree | Брошенная пустая ветка не попадёт в хвосты | | 2026-10-24 | Сторона Gitea в хвостах не проверяется | Хрупко (формат описаний PR и issue); агент видит это при чтении | Незакрытая issue после merge не попадёт в метрику |