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

100 KiB
Raw Blame History

/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:

# --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
#!/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 заменить цикл проверки записей:

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

на:

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

и дописать в конец файла:

# сегменты и 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
#!/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

Строки шапки:

Запуск из корня репозитория:
    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

заменить на:

Запуск из корня репозитория:
    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") заменить на:

FIX = os.path.join(HERE, "..", "fixtures")

Функцию main и блок if __name__ == "__main__": целиком заменить на:

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 строку

  printf '%s\n' "$out" | grep '^MISMATCH' | sed 's/^MISMATCH /        /'

заменить на

  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
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:

# 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
#!/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.

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:

# 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
#!/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.

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:

# /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
---
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
# Ретроспектива обвязки 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
{
  "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. заменить на

- Сделано: B12 — `/harness-retro`: срез метрик, поправки владельца, хвосты закрытия, корпус по сегментам (issue #29, 0.6.0), спека — `openspec/specs/harness-retro/`.
- Дальше: первое ретро `/harness-retro` по полигону — на машине с awp-ca; остальные пункты бэклога — по мере надобности.

docs/HARNESS.md: перед строкой ## Стоимость вставить

## Ретро

Регулярное ретро — `/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 и пустой строки вставить

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