# `/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 " ```