claude-harness/docs/superpowers/plans/2026-10-24-harness-retro.md

1765 lines
100 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# `/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 <noreply@anthropic.com>`. 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": {"<id>": {"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). Похожие на секреты подстроки заменяются на <redacted>.
"""
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<kv>(?:token|password|passwd|secret|api[_-]?key)\s*[=:]\s*)\S+'
r'|(?P<flag>--(?:token|password)\s+)\S+|(?P<auth>Authorization:\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 + "<redacted>"
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 <jsonl>]
Корпус — сегменты: строка с ключом layout ({home, layout}) открывает сегмент, следующие строки — его записи.
Для каждого сегмента строится своя песочница; префикс home записей заменяется на её home, HOME хуков — туда же.
Bash идёт через guard-main, Edit/Write — через guard-main и require-plan (блок, если блокирует любой).
Запись с known — известная ошибка хуков: расхождение — KNOWN (не ошибка прогона), совпадение — KNOWN-FIXED (ошибка:
снять known). Вывод: строка «id<TAB>why<TAB>ожидалось<TAB>получено<TAB>статус<TAB>команда» и итог «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
Вход: <fixtures>/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 <noreply@anthropic.com>"
```
---
### 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("<command-name>/clear</command-name>", T % 4),
u("служебное", T % 4, isMeta=True),
u("[Request interrupted by user]", T % 4),
a("m4", [{"type": "text", "text": "Вхожу в worktree."}, tu("t11", "EnterWorktree", name="x")], T % 5),
u("почему мне нужно это каждый раз подтверждать?", T % 6),
u("перечитай память", T % 7),
a("m5", [tu("t12", "Bash", command="git branch -m feat/x")], T % 8),
u("нет, tea login add --token 0123456789abcdef0123456789abcdef01234567 не так", T % 9),
{"type": "system", "subtype": "compact_boundary", "timestamp": T % 9},
"not json",
"[]",
])
W = P + "/.claude/worktrees/x"
w(A + "--claude-worktrees-x", "s2.jsonl", [
u("ок", "2026-11-03T09:00:00Z", cwd=W),
a("n1", [tu("x1", "Bash", command='grep "unbalanced hooks/a.py')], "2026-11-03T09:01:00Z", cwd=W,
usage={"input_tokens": 0, "cache_creation_input_tokens": 6000, "cache_read_input_tokens": 50000, "output_tokens": 0}),
])
s5 = [u("да", "2026-11-04T09:00:00Z", cwd=P + ".wt/y"),
a("k1", [tu("y1", "Grep", pattern="x")], "2026-11-04T09:01:00Z", cwd=P + ".wt/y",
usage={"input_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 61000, "output_tokens": 0})]
w(slug(P + ".wt/y"), "s5.jsonl", s5)
w(A, "s5.jsonl", s5[:1]) # усечённая копия той же сессии — берётся больший файл
w(A, "s0.jsonl", [u("старое", "2026-10-01T09:00:00Z")])
w(A + "-tools", "s3.jsonl", [u("чужое", "2026-11-02T09:00:00Z", cwd=P + "-tools")])
os.makedirs(os.path.join(CR, A, "memory"), exist_ok=True)
open(os.path.join(CR, A, "memory", "MEMORY.md"), "w").write("x" * 100)
open(os.path.join(CR, A, "memory", "note.md"), "w").write("y" * 50)
PY
python3 "$TMP/gen.py" "$P" "$CR"
J="$TMP/m.json"
python3 "$M" "$P/" --since 2026-11-01 --projects-root "$CR" --out "$J"; rc=$?
assert_eq "срез: exit 0" "$rc" "0"
q(){ python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); m=d["metrics"]; print(eval(sys.argv[2]))' "$J" "$1"; }
assert_eq "схема 1" "$(q 'd["schema"]')" "1"
assert_eq "сессии: s1, s2 (worktree), s5 (.wt) — без старой и чужой" "$(q 'm["sessions"]')" "3"
assert_eq "каталоги транскриптов среза" "$(q 'len(d["sources"]["dirs"])')" "3"
assert_eq "битые строки" "$(q 'd["sources"]["bad_lines"]')" "1"
assert_eq "промпты владельца без служебных и прерываний" "$(q 'm["prompts"]')" "6"
assert_eq "прерывания" "$(q 'm["interrupts"]')" "1"
assert_eq "ходы — по message.id" "$(q 'm["turns"]')" "7"
assert_eq "вызовы инструментов" "$(q 'm["tool_calls"]')" "15"
assert_eq "токены вывода один раз на сообщение" "$(q 'm["tokens"]["output"]')" "10"
assert_eq "стартовый контекст" "$(q 'sorted(m["start_context"].items())')" "[('max', 61000), ('median', 56000), ('min', 51100)]"
assert_eq "блокировки только по ошибке хука" "$(q 'm["hook_blocks"], m["hook_blocks_by_hook"]')" "(1, {'guard-main(Bash)': 1})"
assert_eq "отказы классификатора по причине" "$(q 'm["classifier_by_reason"]')" "{'Self-Modification': 1}"
assert_eq "отказы владельца" "$(q 'm["owner_denials"]')" "1"
assert_eq "codegraph против чтения кода" "$(q 'sorted(m["codegraph"].items())')" "[('code_reads', 5), ('explore', 2), ('explore_share', 0.29), ('init', 1)]"
assert_eq "роли" "$(q 'sorted(m["roles"].items())')" "[('dev', 1), ('не выбрана', 2)]"
assert_eq "сабагенты по моделям" "$(q 'sorted(m["subagent_models"].items())')" "[('sonnet', 1), ('не указана', 1)]"
assert_eq "skills" "$(q 'm["skills"]')" "{'role': 1}"
assert_eq "компакции, перечитай" "$(q 'm["compactions"], m["recall"]')" "(1, 1)"
assert_eq "кандидаты в поправки" "$(q 'm["corrections"]')" "4"
assert_eq "память" "$(q 'sorted(m["memory"].items())')" "[('bytes', 150), ('files', 2), ('index_bytes', 100)]"
assert_eq "памяти нет — нули" "$(python3 -c "import sys; sys.path.insert(0, '$ROOT/docs/retro/scripts'); import retro_metrics; print(sorted(retro_metrics.memory_stats('$TMP/none', '$P').items()))")" "[('bytes', 0), ('files', 0), ('index_bytes', 0)]"
# кандидаты в поправки: контекст и redact
C="$TMP/corr.jsonl"
python3 "$M" "$P" --since 2026-11-01 --projects-root "$CR" --corrections > "$C"
assert_eq "кандидатов 4" "$(grep -c . "$C")" "4"
assert_eq "«почему» после EnterWorktree" "$(python3 -c 'import json,sys; print([c["action"] for c in map(json.loads, open(sys.argv[1])) if "почему" in c["text"]])' "$C")" "['EnterWorktree']"
assert_eq "последнее действие Bash" "$(python3 -c 'import json,sys; print([c["action"] for c in map(json.loads, open(sys.argv[1])) if c["text"].startswith("нет")])' "$C")" "['Bash: git branch -m feat/x']"
assert_eq "токен замазан" "$(grep -c '0123456789abcdef' "$C")" "0"
assert_grep "токен заменён на <redacted>" '<redacted>' "$C"
assert_eq "виды кандидатов" "$(python3 -c 'import json,sys; print(sorted(set(c["kind"] for c in map(json.loads, open(sys.argv[1])))))' "$C")" "['denial', 'interrupt', 'prompt']"
# сравнение с прошлым ретро
cat > "$TMP/prev.json" <<'EOF'
{"schema": 1, "metrics": {"sessions": 44, "turns": {"value": 12121, "note": "по строкам"}, "cd_main": {"retired": "хуки 0.4.0"}}}
EOF
md=$(python3 "$M" "$P" --since 2026-11-01 --projects-root "$CR" --markdown --prev "$TMP/prev.json")
assert_grep "строка с разницей" '^\| Сессий \| 3 \| 44 \| -41 \|$' <(printf '%s' "$md")
assert_grep "новая метрика — прочерки" '^\| Кандидатов в поправки владельца \| 4 \| — \| — \|$' <(printf '%s' "$md")
assert_grep "снятая метрика" '^\| cd в основной checkout на main \| — \| — \| не считается: хуки 0.4.0 \|$' <(printf '%s' "$md")
assert_grep "метрика с другим способом подсчёта" '^\| Ходов модели \| 7 \| 12 121 \(по строкам\) \| — \|$' <(printf '%s' "$md")
# ложные блокировки по разметке
cat > "$TMP/labels.json" <<'EOF'
{"home": "/h", "layout": [], "cases": {"x-001": {"expected": "allow", "why": "a"}, "x-002": {"expected": "allow", "why": "b"},
"x-003": {"expected": "allow", "why": "c", "known": "RT-21"}, "x-004": {"expected": "block", "why": "d"}, "x-005": {"skip": "s"}}}
EOF
python3 "$M" "$P" --since 2026-11-01 --projects-root "$CR" --labels "$TMP/labels.json" --out "$J"
assert_eq "ложные блокировки" "$(q 'sorted(m["false_blocks"].items())')" "[('false', 3), ('fixed', 2), ('known', 1), ('labelled', 4), ('share', 0.75), ('skipped', 1)]"
# период по умолчанию — от даты последнего ретро
H="$TMP/harness"; mkdir -p "$H/docs/retro"; touch "$H/docs/retro/2026-11-03.md" "$H/docs/retro/2026-10-11.md"
python3 "$M" "$P" --harness "$H" --projects-root "$CR" --out "$J"
assert_eq "период с последнего ретро" "$(q 'd["period"]["since"], m["sessions"]')" "('2026-11-03', 2)"
# нет сессий
err=$(python3 "$M" "$P" --since 2030-01-01 --projects-root "$CR" 2>&1 >/dev/null); rc=$?
assert_eq "нет сессий: exit 1" "$rc" "1"
assert_grep "сообщение со slug" 'proj' <(printf '%s' "$err")
```
- [ ] **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 <hook-labels.json>] [--out <metrics.json>]
[--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"(?<!\w)(нет|не так|почему|зачем|опять|снова|я же|стоп|подожди|не надо|сначала|верни|откати)"
r"(?!\w)", re.I)
READERS = ("grep", "rg", "find", "cat", "head")
SEPARATORS = re.compile(r"&&|\|\||;|\|")
FILE_TOOLS = ("Edit", "Write", "Read", "MultiEdit", "NotebookEdit")
def slug(path):
"""Имя каталога транскриптов Claude Code: каждый символ кроме [A-Za-z0-9] заменён на «-»."""
return re.sub(r"[^A-Za-z0-9]", "-", path)
def inside(path, root):
path, root = os.path.normpath(path), os.path.normpath(root)
return path == root or path.startswith(root + os.sep)
def read_session(path, bad):
"""Записи-объекты транскрипта; битые строки считаются в bad["n"]."""
with open(path, encoding="utf-8", errors="replace") as fh:
for line in fh:
if not line.strip():
continue
try:
rec = json.loads(line)
except ValueError:
bad["n"] = bad.get("n", 0) + 1
continue
if isinstance(rec, dict):
yield rec
def first_meta(path):
"""(cwd, метка времени) первых записей, где они есть."""
cwd = ts = None
for rec in read_session(path, {}):
if not cwd and isinstance(rec.get("cwd"), str) and rec["cwd"]:
cwd = rec["cwd"]
if not ts and isinstance(rec.get("timestamp"), str) and rec["timestamp"]:
ts = rec["timestamp"]
if cwd and ts:
break
return cwd, ts
def project_sessions(project, root, since):
"""(использованные каталоги, транскрипты среза): каталоги по префиксу slug, сессии по первому cwd и since."""
pre = slug(project)
best = {}
for d in sorted(glob.glob(os.path.join(root, "*"))):
if not (os.path.isdir(d) and os.path.basename(d).startswith(pre)):
continue
for f in glob.glob(os.path.join(d, "*.jsonl")):
sid = os.path.basename(f)
if sid not in best or os.path.getsize(f) > 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 <noreply@anthropic.com>"
```
---
### 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/<id>/ в 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 <noreply@anthropic.com>"
```
---
### 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. Дата ретро `<D>` — сегодня, `ГГГГ-ММ-ДД`. Скрипты — `docs/retro/scripts/`, все команды — из корня worktree ретро. Спека — `openspec/specs/harness-retro/spec.md`.
## Шаги
1. **Задача и worktree.** Issue «Ретро <D>: <проект>» (`tea issues create`). Затем `EnterWorktree` с именем `retro-<D>`, ветка `git branch -m retro/<D>`, `codegraph init` в worktree.
2. **Срез.** Выполнить `python3 docs/retro/scripts/retro_metrics.py <папка> [--since …] --out docs/retro/<D>.metrics.json`.
- Exit 1 — сессий нет. Сообщить владельцу slug и период и остановиться.
- Битых строк больше 1 % — отметить в «Что не измерялось».
3. **Блокировки.** Сначала выгрузить: `python3 docs/retro/scripts/hook_blocks_export.py --since <since> --id-prefix <D> <каталоги из sources.dirs> > docs/retro/fixtures/hook-blocks-<D>.jsonl`. Каталоги — `~/.claude/projects/<имя>`.
- Разметить каждую запись в `docs/retro/fixtures/hook-labels-<D>.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-<D>.json --out docs/retro/<D>.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/<slug>/memory/`) и примеры из выгрузок. Находки `RT-n` — сквозная нумерация: максимум в прошлых `docs/retro/*.md` плюс 1. Приоритет: P1 — теряет время каждую неделю или ломает процесс; P2 — трение; P3 — наблюдение.
7. **Черновик.** Скопировать `.claude/skills/harness-retro/template.md` в `docs/retro/<D>.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/<D>` и `tea pulls create` со ссылкой на issue. Merge — только владелец.
## Не делать
- Не удалять хвосты и не чинить хуки в PR ретро — это находки и бэклог.
- Не заполнять раздел «Решения владельца»: он пишется на ревью PR.
- Не вставлять в черновик сырые куски транскриптов мимо скриптов.
~~~
- [ ] **Step 4: Создать `.claude/skills/harness-retro/template.md`**
~~~markdown
# Ретроспектива обвязки claude-harness — <ДД.ММ.ГГГГ>
Период: <since>–<until>, полигон — <проект или workspace>.
Предыдущая точка сравнения — `docs/retro/<прошлое>.md`.
Источники:
- <N> транскриптов сессий (<каталоги из sources.dirs>, <объём>);
- <N> файлов auto-memory (<объём>);
- состояние репозиториев (`retro_leftovers.py`);
- блокировки среза, размеченные в корпус: `docs/retro/fixtures/hook-labels-<D>.json`.
Количественную часть считал `docs/retro/scripts/retro_metrics.py`, качественную — по прочтению памяти целиком и кандидатов в поправки.
Маркировка находок: `RT-n`, продолжение нумерации прошлых ретро. Приоритет: P1 — теряет время каждую неделю или ломает процесс; P2 — трение; P3 — наблюдение.
## 1. Цифры
<таблица из retro_metrics.py --markdown --prev>
Хвосты чеклиста закрытия:
<таблица из retro_leftovers.py --markdown>
## 2. Находки
**RT-<n> (P<k>). <Одна фраза — что происходит>.** <Факты: где, сколько раз, чем стоило>. Источники: <сессии, память, метрика>.
## 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 <noreply@anthropic.com>"
```