Merge pull request 'B12: /harness-retro — срез метрик, поправки владельца, хвосты закрытия, корпус по сегментам (#29, 0.6.0)' (#30) from feat/29-harness-retro into main

This commit is contained in:
aep 2026-10-24 09:07:59 +03:00
commit 1697c77a83
26 changed files with 3694 additions and 378 deletions

View File

@ -0,0 +1,40 @@
---
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`.
- Период `<since>` — `period.since` из `docs/retro/<D>.metrics.json`. На шагах 3 и 5 передавать его явно (`--since <since>`). Повторный запуск с тем же `--out` берёт период из него, поэтому черновик `<D>.md` период не сдвигает.
- В срез входят записи периода, в том числе из длинных сессий, начатых раньше. Транскрипты сабагентов (`<сессия>/subagents/`) входят в сессию-родителя.
- 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 <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 <папка> --from docs/retro/<D>.metrics.json --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.
- Не вставлять в черновик сырые куски транскриптов мимо скриптов.

View File

@ -0,0 +1,49 @@
# Ретроспектива обвязки 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.
- <…>

View File

@ -1,5 +1,19 @@
# 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`, без синтетических сообщений об ошибке API), компакции, стартовый контекст;
- блокировки хуков, отказы классификатора и владельца;
- codegraph против чтения кода (решение владельца 3);
- «перечитай», память;
- кандидаты в поправки владельца по процессу (`--corrections`).
Транскрипты сабагентов (`<сессия>/subagents/`) входят в сессию-родителя; период режется по меткам времени записей, поэтому длинная сессия делится между ретро. Без `--since` период берётся из существующего `--out`, `--from` рисует «Цифры» из сохранённого среза. Заменяет `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 считались по строкам и помечены как несравнимые.
## 0.5.1 — 2026-10-24
- Worktree живут в `<repo>/.claude/worktrees/<slug>`, а не в соседнем каталоге: их создаёт `EnterWorktree` с именем, ветка `worktree-<slug>` переименовывается в `feat/<id>-<slug>` (#25). Вход в worktree вне `.claude/worktrees/` спрашивает подтверждение при любых allow-правилах (Claude Code ≥ 2.1.206). Описано в `roles/dev.md` и `HARNESS.md`.
- `.claude/worktrees/` — в `.gitignore` проектов: `adopt-project.sh`, `project-template/gitignore`, этот репозиторий. `doctor.sh`: `WARN .claude/worktrees/ не в .gitignore`.

View File

@ -18,7 +18,7 @@
- `roles/` — общий слой ролей (формат: четыре раздела, см. спеку ролей), `skills/role/` — skill `/role`, `agents/briefing.md` — агент выжимки, `link-roles.sh` — симлинки workspace → репозитории направлений. Ставятся `install.sh` в `~/.claude/{roles,skills/role,agents,harness}`.
- `tests/` — тесты пакета на bash: `run.sh` + `test-*.sh`, работают с источниками, `install.sh` ставят во временный `CLAUDE_CONFIG_DIR`.
- `caveman/config.json` — выключенный по умолчанию caveman. `examples/` — реальный `CLAUDE.md` проекта-пилота.
- `docs/HARNESS.md` — как устроен цикл фичи, SDD, маркировка, закрытие. `docs/PILOT-2026-09-27.md` — пилот. `docs/retro/` — ретроспектива 11.10.2026 и скрипты подсчёта по транскриптам. `docs/superpowers/specs/` — спеки фич обвязки, `docs/superpowers/plans/` — планы.
- `docs/HARNESS.md` — как устроен цикл фичи, SDD, маркировка, закрытие. `docs/PILOT-2026-09-27.md` — пилот. `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/`. `docs/superpowers/specs/` — спеки фич обвязки, `docs/superpowers/plans/` — планы.
- Связь слоёв: хуки читают JSON вызова из stdin и состояние git проекта; скрипты бутстрапа читают `project-template/`; `doctor.sh` читает установленные копии в `~/.claude`, не репозиторий.
## Команды
@ -28,13 +28,13 @@
- `bash skills/harness-doctor/scripts/doctor.sh [проект]` — отчёт и самотест хуков. Только чтение.
- `bash adopt-project.sh <папка> [--no-merge] [--push]` — подключить существующий проект. Из терминала, не из Claude Code.
- `bash new-project.sh <папка> [ssh-url]` — каркас нового проекта.
- `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` — сборка корпуса из разметки.
- `python3 skills/harness-doctor/scripts/hook_corpus.py hooks [--summary]` — корпус хуков по источникам. `python3 hooks/harness_target.py targets '<команда>' [cwd]` — какие цели записи видит хук.
## Тесты
- `tests/run.sh` — автоматические тесты источников (хуки через фиктивный JSON, скрипты во временных каталогах, размеры). Самотест хуков в `doctor.sh` проверяет установленные копии; перед ним `bash install.sh`.
- Ручная проверка хука: на main в любом репозитории попросить агента создать файл, ожидается `BLOCKED`.
- Хуки: `test-hook-parse` — разбор команды на цели; `test-hook-guard-main`, `test-hook-require-plan` — сценарии спеки `hook-guards` в песочнице из настоящих репозиториев; `test-hook-corpus` — корпус реальных команд (assert на запись, корпус должен совпадать со сборкой `hook_cases_build.py`); `test-hook-export` — экстрактор. Плюс `role-hint` и скрипты `install.sh`, `doctor.sh`, `link-roles.sh`.
- Хуки: `test-hook-parse` — разбор команды на цели; `test-hook-guard-main`, `test-hook-require-plan` — сценарии спеки `hook-guards` в песочнице из настоящих репозиториев; `test-hook-corpus` — корпус реальных команд (assert на запись, корпус должен совпадать со сборкой `hook_cases_build.py`); `test-hook-export` — экстрактор. Ретро: `test-retro-metrics` (синтетические транскрипты), `test-retro-leftovers` (песочница репозиториев), `test-retro-skill`. Плюс `role-hint` и скрипты `install.sh`, `doctor.sh`, `link-roles.sh`.
- Правишь разбор или вердикт — сначала случай в `test-hook-parse`/`test-hook-guard-main`; меняется ожидаемый вердикт реальной команды — разметка в `hook_cases_build.py` и пересборка корпуса.
## Текущая работа
@ -44,7 +44,8 @@
- Сделано: B3 — allow-правило `git push origin --delete` печатают adopt/new-project, проверяет doctor (issue #22, 0.4.2). B4 закрыт в #6 (решение владельца 1). B10 — workspace-хук awp-ca, в части 2 issue #2.
- Сделано: allow-правила цикла фичи ставит `install.sh` (issue #24, 0.5.0; пересмотр решения владельца 2).
- Сделано: worktree — `EnterWorktree` в `<repo>/.claude/worktrees/<slug>`, вход без подтверждения (issue #25, 0.5.1). Для awp-ca вопрос открыт в части 2 issue #2.
- Дальше: B12.
- Сделано: B12 — `/harness-retro`: срез метрик, поправки владельца, хвосты закрытия, корпус по сегментам (issue #29, 0.6.0), спека — `openspec/specs/harness-retro/`.
- Дальше: первое ретро `/harness-retro` по полигону — на машине с awp-ca; остальные пункты бэклога — по мере надобности.
## OpenSpec и superpowers
- Вход в любую задачу — superpowers:brainstorming. Skills openspec-propose и openspec-explore сами по себе не запускать.

View File

@ -38,6 +38,8 @@ link-roles.sh симлинки ролей/агентов из worksp
project-template/workspace/ CLAUDE.md workspace с таблицей репозиториев, roles/dev.md
project-template/role-repo/ репозиторий направления: CLAUDE.md, ROLE.md, notes/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 (хвосты закрытия), выгрузка и разметка блокировок
docs/HARNESS.md как это работает: слои, цикл фичи, SDD, дисциплина закрытия
docs/PILOT-2026-09-27.md результаты пилота и находки
```

View File

@ -99,6 +99,20 @@ Workspace из нескольких репозиториев (не git, cwd вс
Политика памяти (глобальный CLAUDE.md): состояние задачи — в трекере; уроки, решения владельца и рецепты — в `notes/` направления и памяти агентов (в git); в локальной auto-memory — только ловушки машины и индекс со ссылками. При закрытии файл auto-memory задачи сворачивается в строку `registry-closed.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` — хвосты чеклиста закрытия по состоянию репозиториев. Он только подсказывает, как убрать; убирает владелец.
## Стоимость
Bootstrap superpowers (~3 КБ) вставляется на каждом старте и после компакции. SDD стоит свежий контекст на задачу и на ревью; подробный план с готовым кодом позволяет отдать большинство задач haiku. Для планов до 5 задач дешевле `executing-plans` (Native): один контекст, одно ревью в конце.

View File

@ -0,0 +1,44 @@
{
"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": {"value": 6583, "note": "без сабагентов"},
"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": {"value": 76, "note": "без сабагентов"},
"hook_blocks_by_hook": null,
"false_blocks": {"labelled": 76, "skipped": 0, "false": 72, "share": 0.95, "fixed": 72, "known": 0},
"classifier_denials": {"value": 6, "note": "без сабагентов"},
"classifier_by_reason": {"Git Destructive": 2, "Auto-Mode Bypass": 2, "Interfere With Workloads": 1, "safety-check": 1},
"owner_denials": 6,
"codegraph": {"init": {"value": 43, "note": "без сабагентов"}, "explore": {"value": 15, "note": "без сабагентов"}, "code_reads": null, "explore_share": null},
"corrections": null,
"recall": {"value": 9, "note": "широкий регэксп, со сводками компакции"},
"memory": {"index_bytes": null, "files": 47, "bytes": 183296},
"cd_main": {"retired": "хуки 0.4.0 не зависят от cwd (RT-2); считалось по именам репозиториев awp-ca, было 84"}
}
}

View File

@ -1,14 +1,20 @@
#!/usr/bin/env python3
"""Выгрузка блокировок хуков из транскриптов Claude Code в JSONL — сырьё для регрессионных тестов хуков (B2).
Запуск: python3 hook_blocks_export.py <каталог-транскриптов> [<каталог> ...] > blocks.jsonl
Запуск: python3 hook_blocks_export.py [--since ГГГГ-ММ-ДД] [--id-prefix <дата>] <каталог-транскриптов> [...]
Одна строка — одна блокировка: сессия, время, cwd и ветка на момент вызова, инструмент, команда или путь файла,
первая строка ответа хука. Поле expected не заполняется: вердикт (block/allow) проставляется при разметке.
Выгружаются только ошибки хуков (результат начинается с `PreToolUse:<инструмент> hook error`); вывод команд,
в котором встретилось слово BLOCKED (файлы памяти, исходники хуков), не попадает.
Похожие на секреты подстроки заменяются на <redacted>.
Транскрипты сабагентов (<сессия>/subagents/*.jsonl) выгружаются с сессией-родителем. --since — только вызовы
с меткой времени не раньше даты; --id-prefix (по умолчанию — дата --since) — поле id вида <префикс>-NNN (срез
ретро, B12). Похожие на секреты подстроки заменяются на <redacted> до обрезки.
"""
import json, sys, glob, os, re
import glob
import json
import os
import re
import sys
HOOK_ERROR = re.compile(r"\s*PreToolUse:\S+ hook error")
@ -26,6 +32,12 @@ def redact(s):
return RE_SECRET.sub(sub, s)
def clip(s, n, tail=False):
"""redact, затем обрезка: секрет на границе обрезки не утекает частично."""
s = redact(s or "")
return s[-n:] if tail else s[:n]
def hook_of(txt, tool):
if "[guard-main]" in txt:
return "guard-main(Bash)" if tool == "Bash" else "guard-main(Edit/Write)"
@ -42,38 +54,80 @@ def hook_of(txt, tool):
return "other"
for d in sys.argv[1:]:
for f in sorted(glob.glob(os.path.join(os.path.expanduser(d), "*.jsonl"))):
sid = os.path.basename(f)[:8]
pending = {} # tool_use_id -> (name, input, cwd, branch, ts)
for line in open(f, encoding="utf-8", errors="replace"):
try:
j = json.loads(line)
except Exception:
continue
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 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), "")
print(json.dumps({
"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,
}, ensure_ascii=False))
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 _blocks(path, sid, since):
"""Блокировки одного транскрипта; с since — только вызовы с меткой времени не раньше даты."""
pending = {} # tool_use_id -> (name, input, cwd, branch, ts)
for j in _records(path):
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
if since and (ts or "")[:10] < since:
continue
msg = next((l for l in txt.splitlines() if "BLOCKED" in l), "")
yield {
"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": clip(msg, 300), "expected": None,
}
def export(dirs, since=None, prefix=None):
"""Записи выгрузки по каталогам транскриптов: сессия, затем её сабагенты (<сессия>/subagents/*.jsonl)."""
n = 0
for d in dirs:
for f in sorted(glob.glob(os.path.join(os.path.expanduser(d), "*.jsonl"))):
sid = os.path.basename(f)[:8]
subs = sorted(glob.glob(os.path.join(f[:-len(".jsonl")], "subagents", "*.jsonl")))
for path in [f] + subs:
for rec in _blocks(path, sid, since):
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))

View File

@ -2,19 +2,22 @@
"""Сборка регрессионного корпуса хуков из сырой выгрузки блокировок (B2, #6).
Запуск из корня репозитория:
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 (класс). Вердикт размечен вручную по спеке
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
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
RAW = os.path.join(HERE, "..", "fixtures", "hook-blocks.jsonl")
FIX = os.path.join(HERE, "..", "fixtures")
HOME = "/Users/andrey.epifancev"
AW, CH = "projects/awp-ca", "projects/cc-skills/claude-harness"
@ -77,9 +80,40 @@ def why_of(cid, r):
return "devnull" if DEVNULL.search(r.get("command") or "") else "read-only"
def main():
def label_segments(fix):
"""Сегменты ретро: пары hook-blocks-<дата>.jsonl + hook-labels-<дата>.json. Разметка должна совпадать с выгрузкой."""
for bp in sorted(glob.glob(os.path.join(fix, "hook-blocks-*.jsonl"))):
date = os.path.basename(bp)[len("hook-blocks-"):-len(".jsonl")]
if not os.path.isfile(os.path.join(fix, "hook-labels-%s.json" % date)):
raise SystemExit("%s: нет разметки hook-labels-%s.json — ретро не уходит с неразмеченными блокировками"
% (os.path.basename(bp), date))
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))
for n, line in enumerate(l for l in open(RAW, encoding="utf-8") if l.strip()):
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)})
@ -88,7 +122,9 @@ def main():
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()
main(sys.argv)

View File

@ -1,140 +0,0 @@
#!/usr/bin/env python3
"""Классификация блокировок хуков, отказов auto mode, стартового контекста и ручных напоминаний."""
import json, sys, glob, os, re, collections
d = sys.argv[1]
files = sorted(glob.glob(os.path.join(d, "*.jsonl")))
RE_REAL = re.compile(r'(^|[^<\d])>{1,2}[^&]|\btee\b|\bsed\s+-i|\bmv\b|\bcp\b|\brm\b|\btouch\b|\bmkdir\b|\bgit\s+(add|commit|push|apply|stash\s+pop)\b|\bnpm\s+(i|install|init)\b|\bpip\s+install\b')
RE_STDERR = re.compile(r'\d>{1,2}\s*/dev/null|\d>&\d')
RE_HEREDOC_PY = re.compile(r"<<\s*'?(PY|EOF)")
RE_HOOK_ERROR = re.compile(r"\s*PreToolUse:\S+ hook error") # только ошибка хука, не вывод команды со словом BLOCKED
blocks = collections.Counter()
by_hook = collections.Counter()
by_session = collections.Counter()
examples = collections.defaultdict(list)
denials = collections.Counter()
denial_ex = []
start_ctx = []
recall_prompts = []
codegraph_init = 0
cd_main = 0
n_prompts_total = 0
for f in files:
sid = os.path.basename(f)[:8]
pending = {} # tool_use_id -> (name, input)
first_usage = None
title = ""
for line in open(f, encoding="utf-8", errors="replace"):
try:
j = json.loads(line)
except Exception:
continue
t = j.get("type")
if t == "ai-title" and not title:
title = j.get("aiTitle", "")
if t == "assistant":
m = j.get("message", {})
u = m.get("usage") or {}
if first_usage is None and u:
first_usage = (u.get("input_tokens", 0), u.get("cache_creation_input_tokens", 0), u.get("cache_read_input_tokens", 0))
for b in m.get("content", []) or []:
if isinstance(b, dict) and b.get("type") == "tool_use":
pending[b.get("id")] = (b.get("name"), b.get("input") or {})
if b.get("name") == "Bash":
cmd = (b.get("input") or {}).get("command", "")
if "codegraph init" in cmd:
codegraph_init += 1
if re.search(r"\bcd\s+\S*(arm-core|docs|tabular-parser|scan-gateway|arm-web)/?\s*($|;|&&)", cmd) and "/.wt/" not in cmd:
cd_main += 1
if t == "user":
m = j.get("message", {})
cont = m.get("content", "")
if isinstance(cont, str) and not cont.startswith("<") and not j.get("isMeta"):
n_prompts_total += 1
if re.search(r"памят|перечитай|сверься|контекст|вспомни|прочитай .*md", cont, re.I):
recall_prompts.append((sid, cont[:140].replace("\n", " ")))
if 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))
name, inp = pending.get(b.get("tool_use_id"), ("?", {}))
if RE_HOOK_ERROR.match(txt) and "BLOCKED" in txt:
if name not in ("Bash", "Edit", "Write", "MultiEdit"):
continue
if "[guard-main]" in txt:
hook = "guard-main(Bash)" if name == "Bash" else "guard-main(Edit/Write)"
elif "[require-plan]" in txt:
hook = "require-plan"
elif "shell write" in txt:
hook = "guard-main(Bash)"
elif "you are on" in txt:
hook = "guard-main(Edit/Write)"
elif "without a plan" in txt:
hook = "require-plan"
elif "commit/push в main" in txt or "ветке main" in txt or "reference-old-arm" in txt or "docs/source" in txt:
hook = "workspace-guard"
else:
hook = "other"
by_hook[hook] += 1
by_session[sid] += 1
cmd = inp.get("command", "") if name == "Bash" else inp.get("file_path", "")
if name == "Bash":
if RE_STDERR.search(cmd) and not RE_REAL.sub("", cmd) != cmd:
cls = "stderr-redirect-only"
else:
stripped = RE_STDERR.sub("", cmd)
if RE_REAL.search(stripped):
mm = RE_REAL.search(stripped).group(0)
if re.match(r"^\s*(cat|python3|grep|ls|find|sed -n|head|git (log|status|diff|show|branch|worktree list))", cmd) and ("mkdir" not in cmd and "git commit" not in cmd and "git push" not in cmd and "git add" not in cmd and "rm " not in cmd):
cls = "read-only-but-matched:" + mm.strip()[:12]
else:
cls = "real-write:" + mm.strip()[:12]
else:
cls = "no-pattern(workspace or cwd)"
else:
cls = name
blocks[(hook, cls)] += 1
if len(examples[(hook, cls)]) < 3:
examples[(hook, cls)].append(f"[{sid}] {cmd[:150]!r}")
if "denied by the Claude Code auto mode classifier" in txt or "denied by a built-in Claude Code safety check" in txt or "The user doesn't want to proceed" in txt:
r = re.search(r"Reason: \[([^\]]+)\]", txt)
key = r.group(1) if r else ("safety-check" if "safety check" in txt else "user-rejected")
denials[key] += 1
cmd = inp.get("command", "") if name == "Bash" else name
denial_ex.append(f"[{sid}] {key}: {cmd[:140]!r}")
if first_usage:
start_ctx.append((sid, title[:30], sum(first_usage), first_usage))
print("== Блокировки по хукам")
for k, v in by_hook.most_common():
print(f"{k}: {v}")
print("\n== Блокировки по классу")
for k, v in sorted(blocks.items(), key=lambda x: -x[1]):
print(f"{v:3d} {k[0]} / {k[1]}")
for e in examples[k]:
print(" ", e)
print("\n== Сессии с наибольшим числом блокировок")
for k, v in by_session.most_common(8):
print(f"{k}: {v}")
print("\n== Отказы auto mode / safety / пользователь")
for k, v in denials.most_common():
print(f"{k}: {v}")
for e in denial_ex:
print(" ", e)
print("\n== Стартовый контекст (первый ответ модели): input+cache_create+cache_read")
start_ctx.sort(key=lambda x: -x[2])
for s, t, tot, fu in start_ctx:
print(f"{s} {tot:7d} (in={fu[0]} create={fu[1]} read={fu[2]}) {t}")
vals = [x[2] for x in start_ctx if x[2] > 0]
vals.sort()
print(f"median={vals[len(vals)//2]} min={vals[0]} max={vals[-1]} n={len(vals)}")
print(f"\n== codegraph init в командах: {codegraph_init}; cd в основной checkout (не .wt): {cd_main}")
print(f"\n== Промпты владельца с просьбой перечитать память/контекст: {len(recall_prompts)} из {n_prompts_total}")
for s, p in recall_prompts:
print(f" [{s}] {p}")

View File

@ -0,0 +1,154 @@
#!/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
done = [main_wt] # SDD workspace — хвост в основном checkout и в worktree закрытой задачи, не в активном
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"])
done.append(w["worktree"])
for w in done:
for d in sorted(glob.glob(os.path.join(w, ".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))

View File

@ -0,0 +1,544 @@
#!/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, clip, 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"(перечитай|прочитай|сверься|вспомни|освежи)[^.?!\n]{0,40}?(памят|контекст|claude\.md|\.md\b)",
re.I)
MARKERS = re.compile(r"(?<!\w)(нет|не так|почему|зачем|опять|снова|я же|стоп|подожди|не надо|сначала|верни|откати"
r"|сделай сам)(?!\w)", re.I)
READERS = ("grep", "rg", "find", "cat", "head")
FILE_TOOLS = ("Edit", "Write", "Read", "MultiEdit", "NotebookEdit")
MERGED = ("turns", "tools", "subagents", "skills", "tokens", "blocks", "classifier", "owner_denials", "cg_init",
"cg_explore", "code_reads") # что сабагент добавляет сессии-родителю
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 subagent_files(path):
"""Транскрипты сабагентов сессии: <каталог>/<сессия>/subagents/*.jsonl."""
return sorted(glob.glob(os.path.join(path[:-len(".jsonl")], "subagents", "*.jsonl")))
def in_period(ts, since):
return not since or (ts or "")[:10] >= since
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, _ = first_meta(f)
if cwd and (inside(cwd, project) or inside(cwd, project + ".wt")):
files.append(f)
return 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("isCompactSummary") or rec.get("isVisibleInTranscriptOnly"):
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 simple_commands(cmd):
"""Простые команды Bash списками слов: кавычки учитываются, делят &&, ||, ;, |, &, скобки; цель редиректа
пропускается. Незакрытая кавычка — разбор по пробелам."""
try:
lx = shlex.shlex(cmd or "", posix=True, punctuation_chars=True)
lx.whitespace_split = True
toks = list(lx)
except ValueError:
toks = (cmd or "").split()
segs, cur, skip = [], [], False
for t in toks:
if t and set(t) <= set(";&|()"):
if cur:
segs.append(cur)
cur = []
elif t and set(t) <= set("<>"):
skip = True
elif skip:
skip = False
else:
cur.append(t)
if cur:
segs.append(cur)
return segs
def bash_reads_code(cmd, cwd, cache):
for words in simple_commands(cmd):
if 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"] = redact(str(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, since=None):
"""Счётчики сессии по записям периода (запись без метки — по предыдущей). Сабагенты сессии добавляют MERGED."""
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, cur, first_turn = set(), {}, "", True
for rec in read_session(path, bad):
ts = rec.get("timestamp")
if isinstance(ts, str) and ts:
cur = ts
live = in_period(cur, since)
if live and cur > s["last_ts"]:
s["last_ts"] = cur
t = rec.get("type")
if t == "system" and rec.get("subtype") == "compact_boundary":
s["compactions"] += live
elif t == "assistant":
msg = rec.get("message") or {}
if rec.get("isApiErrorMessage") or msg.get("model") == "<synthetic>":
continue # сообщение Claude Code об ошибке API — не ход модели
mid = msg.get("id")
if mid is None or mid not in seen:
if mid is not None:
seen.add(mid)
u = msg.get("usage") or {}
n = lambda k: u.get(k, 0) or 0
if u and first_turn:
first_turn = False # стартовый контекст — первый ход сессии, если он в периоде
if live:
s["start"] = n("input_tokens") + n("cache_creation_input_tokens") + n("cache_read_input_tokens")
if live:
s["turns"] += 1
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 "?"
if live:
count_tool(s, b, rec.get("cwd"), cache)
elif t == "user" and live:
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
for sf in subagent_files(path):
sub = session_stats(sf, cache, bad, since)
for k in MERGED:
s[k] = s[k] + sub[k]
s["last_ts"] = max(s["last_ts"], sub["last_ts"])
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, since=None):
"""Кандидаты в поправки владельца с последним действием и хвостом ответа агента перед ними (записи периода)."""
out, act, reply, sid, cur = [], "", "", os.path.basename(path)[:8], ""
for rec in read_session(path, {}):
if isinstance(rec.get("timestamp"), str) and rec["timestamp"]:
cur = rec["timestamp"]
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" or not in_period(cur, since):
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": clip(txt, 300),
"action": clip(act, 300), "reply_tail": clip(" ".join(reply.split()), 200, tail=True)})
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("--from", dest="from_file")
p.add_argument("--corrections", action="store_true")
return p.parse_args(argv[1:])
def main(argv):
a = parse_args(argv)
prev = json.load(open(a.prev, encoding="utf-8")) if a.prev else None
if a.from_file: # таблица из сохранённого среза, без пересчёта
print(to_markdown(json.load(open(a.from_file, encoding="utf-8")), prev))
return 0
project = os.path.abspath(a.project)
since = a.since
if not since and a.out and os.path.isfile(a.out): # повторный срез того же ретро — тот же период
try:
since = json.load(open(a.out, encoding="utf-8"))["period"]["since"]
except (ValueError, KeyError, TypeError):
since = None
since = since or default_since(a.harness)
bad, cache, kept = {"n": 0}, {}, []
stats = []
for f in project_sessions(project, a.projects_root, since):
s = session_stats(f, cache, bad, since)
if s["prompts"] or s["turns"]:
stats.append(s)
kept.append(f)
if not stats:
sys.stderr.write("retro_metrics: нет сессий проекта %s (slug %s) в %s с %s\n"
% (project, slug(project), a.projects_root, since or "начала"))
return 1
files, dirs = kept, sorted(set(os.path.basename(os.path.dirname(f)) for f in kept))
cands = [c for f in files for c in corrections(f, since)]
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, prev))
elif not a.out:
print(json.dumps(doc, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))

View File

@ -1,159 +0,0 @@
#!/usr/bin/env python3
"""Статистика по транскриптам сессий Claude Code для ретро обвязки.
Только чтение. Вывод: таблица по сессиям + агрегаты + примеры блокировок."""
import json, sys, glob, os, re, collections, datetime
d = sys.argv[1]
files = sorted(glob.glob(os.path.join(d, "*.jsonl")))
rows = []
agg = collections.Counter()
blocked_examples = collections.defaultdict(list)
denied_examples = []
system_subtypes = collections.Counter()
hook_names = collections.Counter()
skills = collections.Counter()
agent_models = collections.Counter()
agent_types = collections.Counter()
first_prompts = {}
PAT_BLOCK = re.compile(r"BLOCKED[^\n]{0,160}")
PAT_DENY = re.compile(r"(Auto-Mode Bypass|permission denied|user denied|denied by|The user doesn't want|rejected this tool)", re.I)
def text_of(content):
if isinstance(content, str):
return content
out = []
if isinstance(content, list):
for b in content:
if isinstance(b, dict):
if b.get("type") == "text":
out.append(b.get("text", ""))
elif b.get("type") == "tool_result":
out.append(text_of(b.get("content", "")))
return "\n".join(out)
for f in files:
sid = os.path.basename(f)[:8]
c = collections.Counter()
title = ""
pm = ""
t0 = t1 = None
usage_in = usage_out = cache_r = cache_c = 0
human_prompts = 0
first_prompt = ""
for line in open(f, encoding="utf-8", errors="replace"):
try:
j = json.loads(line)
except Exception:
continue
t = j.get("type")
if t == "ai-title" and not title:
title = j.get("aiTitle", "")
if t == "permission-mode":
pm = j.get("permissionMode", "")
ts = j.get("timestamp")
if ts:
try:
tt = datetime.datetime.fromisoformat(ts.replace("Z", "+00:00"))
t0 = tt if t0 is None or tt < t0 else t0
t1 = tt if t1 is None or tt > t1 else t1
except Exception:
pass
if t == "system":
system_subtypes[j.get("subtype", "?")] += 1
if j.get("subtype") == "compact_boundary" or "compact" in str(j.get("subtype", "")):
c["compact"] += 1
hn = j.get("hookName") or j.get("hook_name")
if hn:
hook_names[hn] += 1
if t == "user":
m = j.get("message", {})
cont = m.get("content", "")
if isinstance(cont, str):
if not cont.startswith("<") and j.get("userType", "external") == "external" and not j.get("isMeta"):
human_prompts += 1
if not first_prompt:
first_prompt = cont[:120].replace("\n", " ")
elif isinstance(cont, list):
has_tr = any(isinstance(b, dict) and b.get("type") == "tool_result" for b in cont)
if not has_tr and not j.get("isMeta"):
txt = text_of(cont)
if txt and not txt.startswith("<"):
human_prompts += 1
if not first_prompt:
first_prompt = txt[:120].replace("\n", " ")
for b in cont:
if isinstance(b, dict) and b.get("type") == "tool_result":
txt = text_of(b.get("content", ""))
for mm in PAT_BLOCK.findall(txt):
c["blocked"] += 1
key = re.sub(r"\s+", " ", mm)[:90]
if len(blocked_examples[key]) < 2:
blocked_examples[key].append(sid)
if PAT_DENY.search(txt):
c["denied"] += 1
if len(denied_examples) < 15:
denied_examples.append((sid, re.sub(r"\s+", " ", txt)[:160]))
# hook feedback in attachments / text
txt = text_of(cont)
if "BLOCKED" in txt and isinstance(cont, str):
c["blocked_text"] += 1
if t == "assistant":
m = j.get("message", {})
u = m.get("usage", {}) or {}
usage_in += u.get("input_tokens", 0) or 0
usage_out += u.get("output_tokens", 0) or 0
cache_r += u.get("cache_read_input_tokens", 0) or 0
cache_c += u.get("cache_creation_input_tokens", 0) or 0
c["assistant"] += 1
for b in m.get("content", []) or []:
if isinstance(b, dict) and b.get("type") == "tool_use":
n = b.get("name", "")
c["tool"] += 1
c["t:" + n] += 1
agg["t:" + n] += 1
inp = b.get("input", {}) or {}
if n in ("Agent", "Task"):
agent_models[inp.get("model", "default")] += 1
agent_types[inp.get("subagent_type", "?")] += 1
if n == "Skill":
skills[inp.get("skill", "?")] += 1
dur = (t1 - t0).total_seconds() / 3600 if t0 and t1 else 0
rows.append(dict(sid=sid, date=t0.strftime("%m-%d") if t0 else "?", hours=round(dur, 1), title=title[:38], pm=pm,
prompts=human_prompts, asst=c["assistant"], tools=c["tool"], agents=c["t:Agent"] + c["t:Task"],
blocked=c["blocked"], denied=c["denied"], compact=c["compact"],
out_k=usage_out // 1000, cache_r_m=round(cache_r / 1e6, 1), first=first_prompt[:70]))
for k in ("assistant", "tool", "blocked", "denied", "compact"):
agg[k] += c[k]
agg["prompts"] += human_prompts
agg["out"] += usage_out
agg["cache_r"] += cache_r
agg["cache_c"] += cache_c
agg["hours"] += dur
rows.sort(key=lambda r: r["date"])
hdr = ["sid", "date", "hours", "pm", "prompts", "asst", "tools", "agents", "blocked", "denied", "compact", "out_k", "cache_r_m", "title"]
print("\t".join(hdr))
for r in rows:
print("\t".join(str(r[h]) for h in hdr))
print("\n== Первые промпты сессий")
for r in rows:
print(f"{r['date']} {r['sid']} | {r['first']}")
print("\n== Агрегаты")
for k in ("prompts", "assistant", "tool", "blocked", "denied", "compact"):
print(f"{k}: {agg[k]}")
print(f"hours: {agg['hours']:.0f}; output tokens: {agg['out']/1e6:.1f}M; cache read: {agg['cache_r']/1e9:.2f}G; cache create: {agg['cache_c']/1e6:.0f}M")
print("\n== Инструменты (top 25)")
for k, v in sorted(((k, v) for k, v in agg.items() if k.startswith("t:")), key=lambda x: -x[1])[:25]:
print(f"{k[2:]}: {v}")
print("\n== Skills"); [print(f"{k}: {v}") for k, v in skills.most_common()]
print("\n== Модели сабагентов"); [print(f"{k}: {v}") for k, v in agent_models.most_common()]
print("\n== Типы сабагентов"); [print(f"{k}: {v}") for k, v in agent_types.most_common()]
print("\n== system subtypes"); [print(f"{k}: {v}") for k, v in system_subtypes.most_common(15)]
print("\n== hookName в system"); [print(f"{k}: {v}") for k, v in hook_names.most_common(15)]
print("\n== Блокировки хуков (уникальные тексты, top 40)")
for k, v in sorted(blocked_examples.items(), key=lambda x: -len(x[1]))[:40]:
print(f"[{','.join(v)}] {k}")
print("\n== Отказы (примеры)")
for s, e in denied_examples:
print(f"[{s}] {e}")

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-24

View File

@ -0,0 +1,190 @@
# Design: `/harness-retro`
## Context
Ретро 11.10.2026 (`docs/retro/2026-10-11.md`) сделано вручную. Таблицу «Цифры» собрали из вывода `retro_stats.py` и `retro_blocks.py`, находки — по прочтению 47 файлов памяти. В скриптах зашиты имена репозиториев awp-ca, вывод — текст, тестов нет. Подсчёт блокировок завышен: в 103 вошли выводы команд со словом `BLOCKED`, настоящих ошибок хука было 76 (O-1, #6).
После ретро вышли хуки по цели записи (0.4.0, корпус 129 записей), роли (0.3.0), allow-правила (0.5.0) и worktree в `.claude/worktrees/` (0.5.1). Что из этого помогло, видно только по сравнимым цифрам.
Владелец решил (brainstorming 24.10.2026):
- результат — черновик ретро-документа;
- срез — один проект или workspace;
- запуск — из репозитория обвязки, проект передаётся аргументом;
- ложные блокировки размечает агент по спеке, и они идут в корпус;
- хвосты закрытия ищутся по текущему состоянию репозиториев;
- поправки владельца по процессу собираются в ретро;
- подход A — проектный skill и модуль метрик с тестами.
Транскрипты Claude Code: `~/.claude/projects/<slug>/<session>.jsonl`, по строке на запись. Типы записей: `user`, `assistant` (`message.content`: `text`, `tool_use`; `message.usage`), `system` (`subtype: compact_boundary`), `ai-title`, `permission-mode`. У записей есть `cwd`, `gitBranch`, `timestamp`.
Сессия, перешедшая в worktree, пишет транскрипт в каталог со slug пути worktree. Пример — эта сессия: `-Users-andrejepifancev-Projects-cc-harness-wt-6-guard-main-target`.
## Goals / Non-Goals
**Goals**
- Повторяемый срез метрик одного проекта в JSON и сравнение с прошлым ретро.
- Блокировки среза размечаются и становятся регрессионными записями корпуса; известные ошибки хуков видны в doctor.
- Кандидаты в поправки владельца с контекстом; агент отбирает и группирует.
- Хвосты чеклиста закрытия по состоянию репозиториев.
- Черновик ретро по шаблону, утверждение через PR.
**Non-Goals**
- Сторона Gitea в хвостах: открытые issue после слитого PR, пропущенные follow-up.
- Срез по нескольким проектам сразу.
- Автоматические находки по порогам.
- Автоматическая очистка хвостов: скрипт только подсказывает команду.
- Переписывать ретро 11.10.
## Decisions
### 1. Проектный skill, без установки
`/harness-retro` нужен только в сессии в репозитории обвязки: туда пишется черновик, там корпус и `hook_cases_build.py`. Поэтому skill проектный (`.claude/skills/harness-retro/`), как skills openspec. `install.sh` и `~/.claude` не меняются.
`.claude/skills/` не входит в `.claude/code-paths`, поэтому `SKILL.md` и шаблон — документация. Код лежит в `docs/retro/scripts/`, как и сейчас.
### 2. `retro_metrics.py`: модуль на функциях
Только stdlib, Python 3.8+. CLI:
```
retro_metrics.py <папка проекта> [--since ГГГГ-ММ-ДД] [--harness <репозиторий обвязки>]
[--projects-root <каталог>] [--out <metrics.json>]
[--markdown [--prev <прошлый.metrics.json>]] [--corrections]
```
- `--projects-root` по умолчанию — `${CLAUDE_CONFIG_DIR:-~/.claude}/projects`; тесты подставляют свой.
- `--harness` по умолчанию — репозиторий, в котором лежит скрипт. Из него берутся даты прошлых ретро для `--since`.
Функции:
- `slug(path)`;
- `project_sessions(project, root, since)` — список транскриптов среза;
- `read_session(path)` — генератор записей, битые строки считаются;
- `session_stats(records, code_of)` — словарь счётчиков сессии;
- `aggregate(stats)` — метрики среза;
- `corrections(records)` — кандидаты;
- `to_markdown(metrics, prev)`;
- `main(argv)`.
Каждая метрика считается в одном месте, в `session_stats`, и проверяется отдельным тестом.
### 3. Сессии проекта
Каталоги-кандидаты — те, чьё имя начинается со slug проекта. Сессия принадлежит проекту по первому `cwd`: путь внутри проекта или внутри `<проект>.wt/` (старое место worktree). Префикс slug нужен, чтобы найти каталоги worktree; проверка `cwd` отсекает чужой проект `/p/repo-tools` при проекте `/p/repo`.
Период режется по записям, а не по сессиям: запись входит, если её метка времени (у записи без метки — предыдущая) не раньше `since`. Сессия входит, если в ней есть ходы или промпты периода. Стартовый контекст — только если первый ход сессии в периоде. Так длинная сессия на стыке периодов (владелец держит одну сессию на много задач) делится между ретро, а не теряется.
Транскрипты сабагентов `<каталог>/<сессия>/subagents/*.jsonl` (Claude Code пишет их отдельно, в основном транскрипте `isSidechain` нет) вливаются в сессию-родителя: ходы, токены, инструменты, codegraph, блокировки, отказы. Промпты, роль, стартовый контекст и кандидаты в поправки — только из основного транскрипта.
### 4. Что такое «код»
Пути в транскриптах относятся к репозиториям, которых может уже не быть: worktree удалены. Относительный путь считается от корня worktree или репозитория. Корень определяется в таком порядке:
1. часть пути после `/.claude/worktrees/<имя>/`;
2. затем после `<репо>.wt/<имя>/`;
3. затем после `<workspace>/.wt/<задача>/<репо>/`;
4. затем после ближайшего существующего каталога с `.git`.
Префиксы кода берутся из `.claude/code-paths` основного репозитория, без него — правило `src/` на любой глубине. Так же решает require-plan.
Bash-команда считается чтением кода, если хотя бы одна её простая команда начинается с `grep`, `rg`, `find`, `cat`, `sed -n` или `head` и хотя бы один аргумент указывает в каталог кода относительно `cwd`. Простые команды выделяет `shlex` с `punctuation_chars`: кавычки учитываются, делят `&&`, `||`, `;`, `|`, `&` и скобки, цель редиректа пропускается; при незакрытой кавычке — `split`. Правило `src/` срабатывает и на сам каталог `src` в аргументе.
### 5. Блокировки, отказы, роли
- Блокировка: результат инструмента совпадает с `^\s*PreToolUse:\S+ hook error` и содержит `BLOCKED`. Хук определяется так же, как в `hook_blocks_export.hook_of`, функция импортируется.
- Отказ классификатора: `denied by the Claude Code auto mode classifier. Reason: \[([^\]]+)\]`.
- Отказ владельца: результат инструмента содержит `The user doesn't want to proceed with this tool use`. Прерывание (`[Request interrupted by user…`) считается отдельной метрикой, а не отказом.
- Ход модели — уникальный `message.id` ассистента. Claude Code пишет одно сообщение несколькими строками с общим `id` и одинаковым `usage` (проверено на транскриптах этой машины: 538 id на 1381 строку). Поэтому токены считаются один раз на `id`. Строки без `id` считаются по одной.
- Сессия — файл `<id>.jsonl`. Транскрипт переезжает в каталог worktree (запись `relocated`); если один id встретился в нескольких каталогах, берётся самый большой файл.
- Роль: первый `tool_use` с `name: Skill` и `input.skill` = `role` (или `…:role`), аргумент — `input.args`.
### 6. Кандидаты в поправки
Признаки ищутся в первых 200 символах промпта, без учёта регистра и с границами слов. Список признаков — в спеке. Промпт длиннее 2000 символов признаком не проверяется: это вставка материала.
Последнее действие — последний `tool_use` ассистента перед промптом: `Bash: <команда>`, `Edit|Write|Read: <путь>`, `<Имя>` для остальных. Хвост ответа — последние 200 символов последнего текстового блока ассистента.
Весь вывод проходит через `redact()` из `hook_blocks_export.py`.
### 7. `retro_leftovers.py`
Репозитории: сам git-проект или каталоги из таблицы workspace (разбор как в `doctor.sh`). Для каждого:
- `git branch -r --merged origin/main` без `origin/main` и `origin/HEAD`;
- `git for-each-ref --format='%(refname:short) %(upstream:track)' refs/heads`, где слитые в `origin/main` или `[gone]`;
- `git worktree list --porcelain` — ветка из слитых или `prunable`;
- `ls .superpowers/sdd/*/` в основном checkout и в worktree, которые сами хвост (слитая или `gone` ветка, `prunable`). SDD workspace в worktree активной задачи — работа в процессе, не хвост;
- `git ls-tree -d --name-only origin/main openspec/changes/` без `archive`;
- `git rev-list --count HEAD..origin/main` и `git status --porcelain` основного checkout.
Нет `origin/main` — у репозитория одна строка `kind: no-origin-main`. `--fetch` перед проверками делает `git fetch --prune origin`.
### 8. Корпус: сегменты и `known`
Формат `hook-cases.jsonl`: строка с ключом `layout` — шапка сегмента `{home, layout}`, следующие строки — записи этого сегмента. Первый сегмент — пилот 12.10 и живые случаи, без изменений.
`hook_corpus.py` строит песочницу на каждый сегмент (`<tmp>/seg-<n>/home`). Поле `known`:
- расхождение при `known` — `KNOWN <id>`, тест не падает;
- совпадение при `known` — `KNOWN-FIXED <id>`, тест падает.
Итог: `корпус хуков: N/M`, при известных ошибках — `корпус хуков: N/M, известных ошибок K`. N — записи без расхождений, считая известные; M — все записи.
`hook-labels-<дата>.json`:
```json
{"home": "/Users/x", "layout": [...], "cases": {"2026-11-01-001": {"expected": "allow", "why": "devnull"},
"2026-11-01-002": {"skip": "ветка репозитория на момент вызова неизвестна"},
"2026-11-01-003": {"expected": "allow", "why": "read-only", "known": "RT-21"}}}
```
`hook_cases_build.py` падает, если у записи выгрузки нет разметки: ретро не уходит с неразмеченными блокировками.
### 9. `metrics.json` и сравнение
Схема `{"schema": 1, "period": {"since", "until"}, "project", "sources": {...}, "metrics": {...}}`. Ключи `metrics` стабильны: по ним `--prev` сопоставляет строки. Метрика, которая больше не считается, хранится как `{"retired": "<причина>"}`.
`2026-10-11.metrics.json` заполняется из таблицы «Цифры» 11.10. Блокировки — по разметке корпуса: 76 настоящих ошибок хуков, из них 4 по делу, ложных 72 (95 %). В таблице 11.10 было 103 — это завышено (O-1). `cd` в main — `retired`.
### 10. Шаблон и порядок skill
`template.md` повторяет разделы ретро 11.10 и добавляет «Поправки владельца» и «Что не измерялось». `SKILL.md` — шаги:
1. issue и worktree;
2. `retro_metrics.py --out`;
3. `hook_blocks_export.py --since` → разметка → `hook_cases_build.py` → `tests/run.sh`;
4. `retro_leftovers.py --fetch`;
5. `--corrections` → отбор и группировка;
6. чтение памяти целиком;
7. черновик и `--markdown --prev`;
8. коммит и PR.
Маркеры: находки `RT-n` по сквозной нумерации ретро.
## Risks / Trade-offs
- **Формат транскриптов Claude Code не документирован.** Признаки (`hook error`, `compact_boundary`, текст отказа) могут смениться. Смягчение: тесты на синтетических транскриптах фиксируют известный формат, а неожиданные записи пропускаются. Изменение формата увидят нулевые метрики при новом ретро.
- **Эвристика поправок шумит.** «Почему» часто бывает вопросом по содержанию. Скрипт только предлагает кандидатов, решает агент, проверяет владелец в PR.
- **Корень пути у удалённого worktree** определяется по шаблону пути (решение 4). Нестандартный путь даёт правило `src/`, и чтения кода могут не посчитаться.
- **Разметка блокировок требует раскладки песочницы,** которую агент восстанавливает по транскриптам. Ошибка в раскладке даёт неверный вердикт в корпусе. Смягчение: `skip` и ревью PR.
- **`KNOWN-FIXED` валит тесты при исправлении хука,** пока не снята пометка. Это сделано намеренно: исправление хука включает снятие `known`.
## Migration Plan
1. Добавить скрипты, тесты, skill и шаблон; изменить `hook_corpus.py` и `hook_cases_build.py` — корпус пересобирается без изменения записей.
2. `2026-10-11.metrics.json` — из таблицы ретро 11.10.
3. Удалить `retro_stats.py` и `retro_blocks.py`, поправить ссылки в `CLAUDE.md`.
4. После merge — `bash install.sh --no-permissions`: doctor и раннер корпуса. Первое ретро — по команде владельца на машине с полигоном.
Откат — revert PR. Корпус возвращается к одному сегменту.
## Decisions made without discussion
| Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|
| 2026-10-24 | Skill проектный, `install.sh` не меняется | Запуск решён «из репозитория обвязки»; данные корпуса и черновик там же | Чтобы запускать из другого каталога, придётся ставить skill глобально |
| 2026-10-24 | Сессия входит в срез по `cwd` первой записи, а каталоги ищутся по префиксу slug | Префикс находит каталоги worktree, `cwd` отсекает чужой проект с тем же префиксом | Сессия, начатая вне проекта и перешедшая в него, не войдёт в срез |
| 2026-10-24 | Корень пути удалённого worktree — по шаблонам `.claude/worktrees/`, `.wt/` | Каталогов уже нет, а `code-paths` нужен для «кода» | Нестандартное место worktree: чтения кода не посчитаются |
| 2026-10-24 | Признаки поправок — в первых 200 символах; промпты > 2000 символов не проверяются | Длинные промпты — вставки материала, признаки в них случайны | Пропуск поправки внутри длинного сообщения |
| 2026-10-24 | `KNOWN-FIXED` — FAIL | Иначе пометка `known` переживёт исправление и скроет регрессию | Исправление хука требует ещё одной правки (снять `known`) |
| 2026-10-24 | `hook_cases_build.py` падает на записи без разметки | Ретро не должно уходить с неразмеченными блокировками | Нельзя закоммитить частичную разметку |
| 2026-10-24 | Метрика `cd` в main — `retired` | RT-2 снят хуками 0.4.0; счёт был по именам репозиториев awp-ca | Потеря одного ряда сравнения |
| 2026-10-24 | Блокировки 11.10 в `metrics.json` — 76 ошибок хука, 72 ложных (по корпусу), а не 103 из таблицы | 103 завышено выводами со словом `BLOCKED` (O-1) | Сравнение с таблицей 11.10 по этой строке расходится с документом |
| 2026-10-24 | `retro_stats.py` и `retro_blocks.py` удаляются | Их заменяет `retro_metrics.py`; в ретро 11.10 они упомянуты как история | Чтобы воспроизвести 11.10 буква в букву, нужен git history |
| 2026-10-24 | Ходы и токены — по уникальному `message.id`, а не по строкам транскрипта | Строки одного сообщения повторяют `usage`; подсчёт по строкам завышает токены в 2–3 раза | Ходы и токены 11.10 (по строкам) несравнимы; в `2026-10-11.metrics.json` они помечены |
| 2026-10-24 | Записи ассистента с `isApiErrorMessage` или `model: <synthetic>` — не ход модели: не считаются в ходах, токенах и стартовом контексте | Это сообщения Claude Code об ошибке API с нулевым `usage`; на живых транскриптах давали стартовый контекст 0 (O-18) | Если Claude Code пометит так настоящий ход, он выпадет из подсчёта |
| 2026-10-24 | SDD workspace в worktree неслитой ветки — не хвост | Живой прогон показал рабочий каталог текущего плана как хвост (O-19); это работа в процессе | Брошенная неслитая задача со своим SDD workspace в хвосты не попадёт вовсе: worktree на неслитой ветке скрипт тоже не выводит |
| 2026-10-24 | Транскрипты сабагентов вливаются в сессию-родителя (C-1, финальное ревью) | В режиме SDD почти вся работа — в сабагентах; без них замер codegraph (решение владельца 3) пуст по построению | Метрики 11.10 считались без сабагентов: `tool_calls`, `hook_blocks`, `classifier_denials`, `codegraph.init/explore` в `2026-10-11.metrics.json` помечены несравнимыми |
| 2026-10-24 | Период режется по меткам времени записей, а не по первой метке сессии (I-7, финальное ревью) | Владелец держит одну сессию на много задач; при фильтре по началу сессии её работа после прошлого ретро не измерялась бы никогда | Сессия на стыке делится между двумя ретро; запись без метки относится к предыдущей |
| 2026-10-24 | Без `--since` период берётся из существующего `--out`; «Цифры» — `--from` сохранённого среза (I-4, финальное ревью) | Черновик `docs/retro/<D>.md` иначе сдвигал период по умолчанию на дату самого ретро | Чтобы пересчитать срез за другой период, нужен явный `--since` или новый `--out` |
| 2026-10-24 | Простые команды Bash — `shlex` с `punctuation_chars`, а не `Lexer` хука (I-1, финальное ревью) | Самодостаточно, учитывает кавычки; ретро не зависит от внутреннего API `harness_target.py` | Разбор heredoc и `$(…)` грубее, чем у хука: чтение кода внутри них может не посчитаться |
| 2026-10-24 | Секреты заменяются до обрезки — `clip()` в `hook_blocks_export.py` (I-3, финальное ревью) | Обрезка до `redact` оставляла обрывок токена, который шаблон уже не узнаёт | Нет |
| 2026-10-24 | «Перечитай» — побуждение перечитать рядом с «память», «контекст», `.md`; сводка компакции — не промпт (I-6, финальное ревью) | Широкий регэксп 11.10 ловил любое упоминание контекста и сводки компакции | `recall` 11.10 (широкий регэксп) помечен несравнимым |
| 2026-10-24 | Свежая ветка, совпадающая с `origin/main`, — не хвост | `--merged` считает слитой и ветку без коммитов, например только что созданный worktree | Брошенная пустая ветка не попадёт в хвосты |
| 2026-10-24 | Сторона Gitea в хвостах не проверяется | Хрупко (формат описаний PR и issue); агент видит это при чтении | Незакрытая issue после merge не попадёт в метрику |

View File

@ -0,0 +1,69 @@
# Proposal: `/harness-retro` — регулярное ретро обвязки
Issue: #29. Источники:
- ретро `docs/retro/2026-10-11.md` — B12, решение владельца 3 (замер codegraph);
- находки O-10, O-11 (#21), O-17 (#28);
- brainstorming 24.10.2026.
## Why
Ретро 11.10 считали два одноразовых скрипта, `docs/retro/scripts/retro_stats.py` и `retro_blocks.py`. У них нет функций и тестов. В них зашиты имена репозиториев awp-ca, и они ничего не знают про `.claude/worktrees/`. Вывод — текст для глаз, поэтому таблицу «Цифры» собирали вручную.
Повторить такое ретро и сравнить его с прошлым нельзя, а именно сравнение показывает, помогли ли изменения обвязки: хуки 0.4.0, роли 0.3.0, allow-правила 0.5.0. Решение владельца 3 требует замерить codegraph в одном срезе, и метрика должна входить в `/harness-retro`.
Владелец хочет видеть в ретро места, где он поправлял агента по процессу. Сейчас они теряются в транскриптах.
Блокировки, размеченные при ретро, в регрессионный корпус хуков не попадают. Ложный блок, найденный на ретро, тестом не становится.
## What Changes
- **Новый проектный skill `/harness-retro`** (`.claude/skills/harness-retro/`). Запускается в сессии в репозитории обвязки: `/harness-retro <папка проекта или workspace> [--since ГГГГ-ММ-ДД]`. Работа идёт по циклу фичи: issue, `EnterWorktree`, PR. Результат — черновик `docs/retro/<дата>.md` по шаблону и `docs/retro/<дата>.metrics.json`. Утверждение — merge владельца.
- **`docs/retro/scripts/retro_metrics.py`** — stdlib, на функциях, с тестами. Даёт метрики среза по транскриптам и памяти проекта:
- сессии, промпты, инструменты, роли, сабагенты, skills, токены, компакции;
- стартовый контекст;
- блокировки хуков;
- отказы классификатора и владельца;
- codegraph против чтения кода;
- поправки владельца;
- «перечитай память»;
- размер памяти.
Выводит JSON, таблицу «Цифры» со сравнением с прошлым `*.metrics.json` и кандидатов в поправки владельца с контекстом.
- **`docs/retro/scripts/retro_leftovers.py`** ищет хвосты чеклиста закрытия по текущему состоянию репозиториев проекта:
- слитые ветки на сервере и локально, worktree на них;
- `.superpowers/sdd/*`;
- неархивные changes в `origin/main`;
- отставший или грязный основной checkout.
Только чтение; `--fetch` по запросу.
- **`hook_blocks_export.py --since`** выгружает блокировки среза в `docs/retro/fixtures/hook-blocks-<дата>.jsonl`. Агент размечает их по спеке `hook-guards` в `hook-labels-<дата>.json` (вердикт, класс, раскладка песочницы), и `hook_cases_build.py` дописывает их в корпус.
- **Корпус хуков** состоит из сегментов: у каждого своя шапка `{home, layout}`, раннер строит песочницу на сегмент. Запись с `known: "RT-n"` — известная ошибка текущих хуков, тест на ней не падает. Doctor показывает число известных ошибок.
- `retro_stats.py` и `retro_blocks.py` удаляются. Их заменяет `retro_metrics.py`. Ретро 11.10 ссылается на них как на историю.
- `docs/retro/2026-10-11.metrics.json` переносится один раз из таблицы ретро 11.10, чтобы следующему ретро было с чем сравнивать.
## Capabilities
### New Capabilities
- `harness-retro`: срез метрик обвязки по транскриптам, памяти и состоянию репозиториев проекта; кандидаты в поправки владельца; разметка блокировок в корпус; черновик ретро и сравнение с прошлым.
### Modified Capabilities
- `hook-guards`: требование «Регрессионный корпус хуков» — сегменты с собственной раскладкой и известные ошибки `known`.
## Impact
- **Код:**
- новые `docs/retro/scripts/retro_metrics.py`, `retro_leftovers.py`;
- `hook_blocks_export.py` (`--since`, id);
- `hook_cases_build.py` (разметка из `hook-labels-*.json`, сегменты);
- `skills/harness-doctor/scripts/hook_corpus.py` (сегменты, `known`);
- `doctor.sh` (строка корпуса).
- **Skill:** `.claude/skills/harness-retro/SKILL.md` и `template.md`. Он проектный, поэтому `install.sh` не меняется.
- **Тесты:**
- новые `tests/test-retro-metrics.sh`, `tests/test-retro-leftovers.sh`;
- дополнения в `test-hook-corpus.sh`, `test-hook-export.sh`, `test-doctor.sh`.
- **Документация:** `CLAUDE.md` («Команды», «Архитектура», «Тесты»), `docs/HARNESS.md` (раздел «Ретро»), `README.md`, `CHANGELOG.md` 0.6.0.
- **Вне задачи:**
- сторона Gitea в хвостах закрытия: открытые issue после слитого PR, пропущенные follow-up;
- срезы по нескольким проектам сразу;
- автоматические находки по порогам;
- workspace awp-ca как полигон — первое ретро по нему делается на той машине.

View File

@ -0,0 +1,238 @@
# Spec Delta: harness-retro
## Purpose
Регулярное ретро обвязки claude-harness. Срез метрик берётся из транскриптов, памяти и состояния репозиториев одного проекта или workspace. Из него получается черновик ретро-документа со сравнением с прошлым ретро, разметкой блокировок хуков в регрессионный корпус и списком поправок владельца по процессу.
## ADDED Requirements
### Requirement: Запуск из репозитория обвязки
`/harness-retro` SHALL быть проектным skill репозитория обвязки (`.claude/skills/harness-retro/`). Он принимает папку проекта или workspace и необязательный `--since ГГГГ-ММ-ДД`. Работа MUST идти по циклу фичи: issue, worktree через `EnterWorktree`, коммит, PR. Skill MUST NOT делать merge.
#### Scenario: Черновик и метрики в PR
- **WHEN** владелец запускает `/harness-retro ~/projects/awp-ca` в сессии в репозитории обвязки
- **THEN** в ветке ретро появляются `docs/retro/<дата>.md` и `docs/retro/<дата>.metrics.json`, открыт PR со ссылкой на issue ретро
- **AND** main не меняется до merge владельцем
### Requirement: Каталоги транскриптов проекта
`retro_metrics.py` SHALL брать сессии из каталогов `~/.claude/projects/<имя>`, где `<имя>` начинается со slug папки проекта. Slug — абсолютный путь, в котором каждый символ кроме `[A-Za-z0-9]` заменён на `-`. Сессия MUST входить в срез, только если `cwd` её первой записи с этим полем лежит внутри папки проекта или внутри `<папка проекта>.wt/`.
#### Scenario: Сессии в worktree проекта
- **WHEN** у проекта `/p/repo` есть транскрипты в `~/.claude/projects/-p-repo/` и в `~/.claude/projects/-p-repo--claude-worktrees-x/`
- **THEN** сессии обоих каталогов входят в срез
#### Scenario: Чужой проект с тем же префиксом
- **WHEN** в каталоге `~/.claude/projects/-p-repo-tools/` первая запись сессии имеет `cwd` `/p/repo-tools`
- **THEN** сессия в срез проекта `/p/repo` не входит
### Requirement: Транскрипты сабагентов
Транскрипты сабагентов сессии `<каталог>/<сессия>/subagents/*.jsonl` SHALL входить в сессию-родителя: ходы, токены, вызовы инструментов, codegraph, блокировки, отказы. Промпты, роль, стартовый контекст и кандидаты в поправки MUST браться только из основного транскрипта.
#### Scenario: codegraph у implementer-а
- **WHEN** сабагент сессии вызвал `codegraph_explore` и прочитал файл кода, а его хук заблокировал запись
- **THEN** вызов, чтение и блокировка считаются в срезе, а сообщение контроллера сабагенту промптом владельца не считается
### Requirement: Период среза
Запись SHALL входить в срез, если её метка времени (у записи без метки — предыдущая) не раньше `--since`; сессия — если в ней есть записи периода. Стартовый контекст сессии MUST считаться, только если её первый ход в периоде. Без `--since` начало периода MUST браться из `period.since` существующего `--out`, иначе из даты последнего `docs/retro/ГГГГ-ММ-ДД*.md`. Если сессий нет — код 1 и сообщение со slug и периодом.
#### Scenario: Период по умолчанию
- **WHEN** последний ретро-документ — `docs/retro/2026-10-11.md`, `--since` не указан и `--out` ещё нет
- **THEN** в срез входят записи с меткой времени 2026-10-11 и позже
#### Scenario: Длинная сессия на стыке периодов
- **WHEN** сессия начата 2026-10-30, а `--since` — 2026-11-01
- **THEN** её записи с 2026-11-01 входят в срез, записи до этой даты и её стартовый контекст — нет
#### Scenario: Черновик текущего ретро не сдвигает период
- **WHEN** срез уже записан в `docs/retro/<D>.metrics.json`, создан черновик `docs/retro/<D>.md`, и срез запускается снова с тем же `--out` без `--since`
- **THEN** период тот же, что в существующем `--out`
#### Scenario: Нет транскриптов
- **WHEN** ни одного каталога со slug проекта нет или в периоде нет ни одной сессии
- **THEN** `retro_metrics.py` завершается с кодом 1 и сообщением со slug и периодом
### Requirement: Формат метрик
`retro_metrics.py` SHALL выводить JSON со схемой версии `1` и полями `period`, `project`, `sources`, `metrics`. В `sources` MUST быть использованные каталоги транскриптов, число сессий, объём и число пропущенных битых строк JSONL. Битая строка MUST NOT прерывать срез.
#### Scenario: Битая строка
- **WHEN** в транскрипте одна строка — не JSON
- **THEN** срез считается по остальным строкам, а `sources.bad_lines` равно 1
### Requirement: Промпты владельца
Промпт владельца SHALL считаться так: сообщение пользователя без `tool_result`, не `isMeta`, не сводка компакции (`isCompactSummary`), не начинающееся с `<`. Прерывания `[Request interrupted by user]` MUST считаться отдельной метрикой, не промптами.
#### Scenario: Служебные сообщения не считаются промптами
- **WHEN** в транскрипте есть сообщение пользователя с `tool_result`, сообщение с `isMeta` и строка `<command-name>…`
- **THEN** ни одно из них не входит в число промптов владельца
### Requirement: Активность сессий
Метрики SHALL включать:
- ходы модели и вызовы инструментов — всего и по инструментам;
- сессии по ролям — по первому вызову skill `role`, без вызова — «не выбрана»;
- запуски сабагентов по полю `model`, включая «не указана»;
- вызовы skills по имени;
- токены вывода, чтения и записи кэша;
- число записей `compact_boundary`.
#### Scenario: Роль сессии
- **WHEN** в сессии первый вызов skill `role` — с аргументом `dev`
- **THEN** сессия считается в роли `dev`
### Requirement: Стартовый контекст
Стартовый контекст сессии SHALL равняться сумме `input_tokens`, `cache_creation_input_tokens` и `cache_read_input_tokens` первого хода модели. Метрика MUST выводить медиану, минимум и максимум по сессиям среза.
#### Scenario: Три сессии
- **WHEN** стартовый контекст сессий — 50 000, 56 000 и 61 000 токенов
- **THEN** медиана 56 000, минимум 50 000, максимум 61 000
### Requirement: Блокировки и отказы
Блокировкой хука SHALL считаться только результат инструмента, который является ошибкой хука (`PreToolUse:<инструмент> hook error` и `BLOCKED`), с разбивкой по хукам. Отказ классификатора auto mode MUST считаться по причине `Reason: [<причина>]`, отказы владельца — отдельно.
#### Scenario: Вывод команды со словом BLOCKED
- **WHEN** результат `cat hooks/require-plan.sh` содержит `BLOCKED`
- **THEN** это не блокировка хука
#### Scenario: Отказ классификатора
- **WHEN** результат инструмента содержит `denied by the Claude Code auto mode classifier. Reason: [Self-Modification]`
- **THEN** метрика отказов классификатора увеличивается по причине `Self-Modification`
### Requirement: codegraph против чтения кода
Метрика codegraph SHALL считать команды `codegraph init`, вызовы `codegraph_explore` и чтения кода, а также долю `codegraph_explore` среди вызовов чтения. Чтение кода — Read файла в каталоге кода, Grep и Glob, Bash с `grep`, `rg`, `find`, `cat`, `sed -n` или `head` по каталогу кода. Каталог кода определяется как в require-plan: префиксы `.claude/code-paths`, без него — любой `src/`.
#### Scenario: Доля explore
- **WHEN** в сессии 2 вызова `codegraph_explore`, Read `hooks/a.py` при `.claude/code-paths` = `hooks/`, Read `docs/x.md` и `grep -rn x hooks/`
- **THEN** вызовов explore 2, чтений кода 2, доля explore 0.5; Read `docs/x.md` чтением кода не считается
#### Scenario: Разделитель внутри кавычек
- **WHEN** Bash-команда `grep -n "a\|b" hooks/x.py` при `.claude/code-paths` = `hooks/`
- **THEN** это одно чтение кода: `|` внутри кавычек команду не делит
#### Scenario: Каталог src без code-paths
- **WHEN** в репозитории без `.claude/code-paths` выполнены `grep -rn foo src/` и `find svc/src -name x`
- **THEN** обе команды — чтение кода
### Requirement: Память проекта
Метрика памяти SHALL выводить размер `~/.claude/projects/<slug проекта>/memory/MEMORY.md` в байтах, число и общий объём файлов в этом каталоге. Отсутствующий каталог MUST давать нули, а не ошибку. Отдельно SHALL считаться промпты-побуждения перечитать: «перечитай», «прочитай», «сверься», «вспомни», «освежи» рядом с «память», «контекст» или `.md`.
#### Scenario: Памяти нет
- **WHEN** каталога памяти проекта нет
- **THEN** метрики памяти равны нулю, срез завершается с кодом 0
#### Scenario: Упоминание контекста — не побуждение
- **WHEN** промпт «я сейчас смотрю на размер контекста» и сводка компакции со словами «перечитай память»
- **THEN** ни то, ни другое не считается промптом «перечитай память/контекст»
### Requirement: Кандидаты в поправки владельца
`retro_metrics.py --corrections` SHALL выгружать кандидатов в поправки владельца по процессу. Кандидат — это:
- промпт владельца со словом-признаком: «нет», «не так», «почему», «зачем», «опять», «снова», «я же», «стоп», «подожди», «не надо», «сначала», «верни», «откати», «сделай сам» (агент переложил работу на владельца, H-1);
- прерывание;
- отказ владельца в разрешении.
Отбор настоящих поправок делает агент, не скрипт.
#### Scenario: Вопрос после действия агента
- **WHEN** после вызова `EnterWorktree` владелец пишет «почему мне нужно это каждый раз подтверждать?»
- **THEN** этот промпт выгружается как кандидат
### Requirement: Контекст кандидата
Для каждого кандидата SHALL выводиться:
- сессия и время;
- текст, не длиннее 300 символов;
- последнее действие агента перед ним: инструмент и команда или путь;
- последние 200 символов последнего ответа агента.
#### Scenario: Последнее действие
- **WHEN** перед кандидатом агент вызвал `Bash` с командой `git branch -m feat/x`
- **THEN** у кандидата указано действие `Bash: git branch -m feat/x`
### Requirement: Сравнение с прошлым ретро
`retro_metrics.py --markdown --prev <прошлый>.metrics.json` SHALL печатать таблицу «Цифры» в три колонки: текущее значение, значение прошлого ретро, разница. Если метрики нет в прошлом файле или там `null`, MUST ставиться «—»; снятая или иначе считавшаяся метрика — с пометкой. `--from <metrics.json>` MUST печатать таблицу из сохранённого среза без пересчёта.
#### Scenario: Новая метрика
- **WHEN** в прошлом файле нет метрики `corrections`
- **THEN** строка поправок владельца показывает текущее значение и «—» в колонках прошлого и разницы
### Requirement: Секреты не попадают в черновик
Всё, что скрипты ретро выводят из транскриптов (промпты, команды, сообщения), SHALL проходить замену похожих на секреты подстрок на `<redacted>` — той же функцией, что в `hook_blocks_export.py`, и MUST проходить её до обрезки, чтобы секрет на границе обрезки не утёк частично.
#### Scenario: Токен в промпте
- **WHEN** промпт-кандидат содержит `tea login add --token 0123456789abcdef0123456789abcdef01234567`
- **THEN** в выводе `--corrections` вместо токена стоит `<redacted>`
### Requirement: Хвосты чеклиста закрытия
`retro_leftovers.py <папка проекта>` SHALL искать хвосты по текущему состоянию каждого репозитория проекта (для workspace — из таблицы репозиториев в `CLAUDE.md`). Хвост — это:
- ветка `origin/<ветка>`, слитая в `origin/main`;
- локальная ветка, слитая или с upstream `gone`;
- worktree на такой ветке или `prunable`;
- `.superpowers/sdd/*` в основном checkout или в worktree такой ветки;
- неархивный `openspec/changes/<id>/` в `origin/main`;
- отставший или грязный основной checkout.
#### Scenario: Слитая ветка осталась на сервере
- **WHEN** ветка `feat/6-x` слита в `origin/main` и есть на `origin`
- **THEN** выводится хвост вида `remote-branch` с подсказкой `git push origin --delete feat/6-x`
#### Scenario: SDD workspace активной задачи
- **WHEN** worktree на неслитой ветке `feat/2` содержит `.superpowers/sdd/plan-y/`
- **THEN** этот каталог хвостом не считается
#### Scenario: Чистый репозиторий
- **WHEN** у репозитория нет слитых веток кроме main, нет worktree, SDD workspace, неархивных changes, и основной checkout совпадает с `origin/main`
- **THEN** список хвостов пуст
### Requirement: Хвосты только читаются
`retro_leftovers.py` SHALL NOT менять репозитории и их рабочие деревья. Единственное исключение — `git fetch --prune origin` при флаге `--fetch`. Вывод MUST быть JSON со списком `{kind, repo, item, hint}` и итогами по видам; с `--markdown` — таблица для черновика.
#### Scenario: Без --fetch
- **WHEN** скрипт запущен без `--fetch`
- **THEN** ссылки `refs/remotes/` и рабочее дерево репозитория не меняются
### Requirement: Выгрузка блокировок среза
`hook_blocks_export.py --since <дата>` SHALL выгружать только блокировки вызовов не раньше даты, вместе с транскриптами сабагентов сессии, с идентификаторами `<дата>-NNN`. Выгрузка среза MUST сохраняться в `docs/retro/fixtures/hook-blocks-<дата>.jsonl`; прежняя выгрузка 12.10 не меняется.
#### Scenario: Ранний вызов не выгружается
- **WHEN** в длинной сессии блокировка до `--since` и блокировка после
- **THEN** выгружается только вторая
### Requirement: Разметка блокировок в корпус
Агент SHALL размечать выгрузку в `docs/retro/fixtures/hook-labels-<дата>.json`:
- для записи — `expected` (`allow|block`) и `why` по спеке `hook-guards`, либо `skip` с причиной;
- для файла — раскладку песочницы.
`hook_cases_build.py` MUST добавлять по сегменту на каждую пару выгрузка + разметка и падать на выгрузке без разметки. Записи со `skip` в корпус не входят.
#### Scenario: Размеченная выгрузка в корпусе
- **WHEN** в `hook-labels-2026-11-01.json` три записи размечены и одна имеет `skip`
- **THEN** после сборки в корпусе новый сегмент с тремя записями, а `bash tests/run.sh` зелёный
### Requirement: Метрика ложных блокировок
Ложной SHALL считаться блокировка среза, размеченная `expected: allow`. Метрика MUST выводить:
- число блокировок;
- число ложных и их долю;
- разбивку ложных на уже исправленные (текущие хуки дают `allow`) и ещё ложные (с `known`).
#### Scenario: Исправленная и неисправленная
- **WHEN** из 4 блокировок среза 3 размечены `allow`, по двум текущие хуки дают `allow`, одна помечена `known`
- **THEN** ложных 3 из 4 (75 %): исправлено 2, ещё ложных 1
### Requirement: Разделы черновика
Черновик SHALL создаваться по шаблону `.claude/skills/harness-retro/template.md`. Разделы:
- шапка (период, проект, источники);
- «Цифры» из `retro_metrics.py --markdown --prev`;
- «Находки»;
- «Поправки владельца»;
- «Что сработало»;
- «Бэклог»;
- «Решения владельца» — пустой, заполняется на ревью;
- «Что не измерялось», в том числе сторона Gitea чеклиста закрытия.
#### Scenario: Пустые решения
- **WHEN** черновик создан
- **THEN** раздел «Решения владельца» пуст и помечен «заполняется на ревью PR»
### Requirement: Нумерация находок и сохранение метрик
Находки SHALL нумероваться `RT-n`, продолжая максимальный номер в прошлых `docs/retro/*.md`. Повторяющийся вид поправки владельца MUST становиться находкой. Метрики среза MUST сохраняться в `docs/retro/<дата>.metrics.json`.
#### Scenario: Продолжение нумерации
- **WHEN** в `docs/retro/2026-10-11.md` максимальный маркер `RT-20`
- **THEN** первая находка нового черновика — `RT-21`

View File

@ -0,0 +1,33 @@
# Spec Delta: hook-guards
## MODIFIED Requirements
### Requirement: Регрессионный корпус хуков
Пакет SHALL содержать корпус вызовов хуков из реальных транскриптов и живых случаев. Каждая запись MUST иметь идентификатор, ожидаемый вердикт `allow|block` и код класса.
Корпус MUST состоять из сегментов: строка-шапка `{home, layout}`, за ней записи этого сегмента. Для каждого сегмента строится своя песочница с раскладкой из его шапки. Корпус MUST прогоняться через хуки тестами пакета по источникам и `doctor.sh` по установленным копиям.
Запись с полем `known: "<маркер находки>"` — известная ошибка текущих хуков:
- её расхождение с ожидаемым вердиктом MUST NOT давать FAIL;
- совпадение вердикта при наличии `known` MUST давать FAIL `KNOWN-FIXED`, чтобы пометку сняли.
#### Scenario: Расхождение в тестах пакета
- **WHEN** хук из `hooks/` выносит по записи корпуса без `known` вердикт, отличный от ожидаемого
- **THEN** `bash tests/run.sh` печатает FAIL с идентификатором и классом записи и завершается с ненулевым кодом
#### Scenario: Отчёт doctor
- **WHEN** запущен `doctor.sh` при установленных хуках
- **THEN** в разделе самотеста есть строка `корпус хуков: <совпало>/<всего>`, расхождения перечислены с идентификаторами
- **AND** при наличии записей `known` строка продолжается `, известных ошибок <K>`
#### Scenario: Сегменты с разной раскладкой
- **WHEN** корпус содержит сегмент пилота 12.10 и сегмент ретро с другим домашним каталогом и другими репозиториями
- **THEN** каждая запись прогоняется в песочнице своего сегмента, и вердикты совпадают с ожидаемыми
#### Scenario: Известная ошибка
- **WHEN** запись имеет `expected: allow`, `known: "RT-21"`, а текущий guard-main её блокирует
- **THEN** тест корпуса не падает, в выводе запись отмечена как известная ошибка
#### Scenario: Известная ошибка исправлена
- **WHEN** запись имеет `known: "RT-21"`, а текущие хуки выносят ожидаемый вердикт
- **THEN** тест корпуса печатает FAIL `KNOWN-FIXED` с идентификатором записи

View File

@ -115,7 +115,7 @@ rm -rf "$T"
HC="$CC/skills/harness-doctor/scripts/hook_corpus.py"
if [ -f "$HC" ] && [ -f "$CC/hooks/harness_target.py" ]; then
out=$(python3 "$HC" "$CC/hooks" --summary 2>&1); rc=$?
printf '%s\n' "$out" | grep '^MISMATCH' | sed 's/^MISMATCH / /'
printf '%s\n' "$out" | grep -E '^(MISMATCH|KNOWN-FIXED) ' | sed 's/^/ /'
[ "$rc" = 0 ] && ok "$(printf '%s\n' "$out" | tail -1)" || warn "$(printf '%s\n' "$out" | tail -1) — расхождения выше"
else miss "корпус хуков (skills/harness-doctor/scripts/hook_corpus.py, hooks/harness_target.py)"; fi

View File

@ -2,11 +2,12 @@
"""Регрессионный корпус хуков (B2): прогон fixtures/hook-cases.jsonl через guard-main и require-plan в песочнице.
Запуск: python3 hook_corpus.py <каталог хуков> [--summary] [--fixtures <jsonl>]
Песочница строится во временном каталоге по раскладке из первой строки корпуса; префикс домашнего каталога записей
заменяется на её home, HOME хуков подменяется туда же. Bash идёт через guard-main, Edit/Write — через guard-main и
require-plan (блок, если блокирует любой).
Вывод: строка на запись «id<TAB>why<TAB>ожидалось<TAB>получено<TAB>команда» и итог «total N/M»;
с --summary — только расхождения и «корпус хуков: N/M». Код выхода 0 — всё совпало, 1 — нет.
Корпус — сегменты: строка с ключом 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
@ -52,6 +53,23 @@ def verdict(hooks, case, prefix, home, env):
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
@ -64,29 +82,33 @@ def main(argv):
return 1
hooks = os.path.abspath(pos[0])
lines = [json.loads(l) for l in open(fx, encoding="utf-8") if l.strip()]
head, cases = lines[0], lines[1:]
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:
home = os.path.join(tmp, "home")
try:
build(home, head["layout"])
except Exception as e:
print("корпус хуков: песочница не собрана (%s)" % e)
return 1
env = {k: v for k, v in os.environ.items() if k not in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE")}
env["HOME"] = home
ok = 0
for c in cases:
got = verdict(hooks, c, head["home"], home, env)
ok += got == c["expected"]
body = (c.get("command") or c.get("file_path") or "").replace(head["home"], "~")
row = "\t".join([c["id"], c["why"], c["expected"], got, " ".join(body.split())[:70]])
if not summary:
print(row)
elif got != c["expected"]:
print("MISMATCH " + row)
print(("корпус хуков: %d/%d" if summary else "total %d/%d") % (ok, len(cases)))
return 0 if ok == len(cases) else 1
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)

View File

@ -4,7 +4,61 @@ F="$ROOT/skills/harness-doctor/fixtures/hook-cases.jsonl"
assert_file "hook_corpus.py существует" "$R"
assert_file "корпус hook-cases.jsonl" "$F"
assert_eq "корпус совпадает со сборкой из выгрузки" "$(python3 "$ROOT/docs/retro/scripts/hook_cases_build.py" | cmp -s - "$F" && echo same)" "same"
while IFS=$'\t' read -r id why exp got cmd; do
while IFS=$'\t' read -r id why exp got st cmd; do
case "$id" in total*) continue ;; esac
assert_eq "корпус $id ($why): $cmd" "$got" "$exp"
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")
# I-5: выгрузка ретро без парной разметки — сборка падает, а не пропускает её молча
FX2="$TMP/fx2"; mkdir -p "$FX2"; cp "$ROOT/docs/retro/fixtures/hook-blocks.jsonl" "$FX2/"
head -1 "$FX/hook-blocks-2026-11-01.jsonl" > "$FX2/hook-blocks-2026-11-07.jsonl"
out=$(python3 "$B" --fixtures-dir "$FX2" 2>&1 >/dev/null); rc=$?
assert_eq "выгрузка без разметки: exit 1" "$rc" "1"
assert_grep "сообщение называет файл выгрузки" 'hook-blocks-2026-11-07\.jsonl' <(printf '%s' "$out")

View File

@ -13,3 +13,23 @@ out=$(python3 "$X" "$T")
assert_eq "две записи: вывод cat с BLOCKED не выгружен" "$(printf '%s\n' "$out" | grep -c .)" "2"
assert_eq "новый префикс → guard-main(Bash)" "$(printf '%s\n' "$out" | sed -n 1p | python3 -c 'import json,sys; r=json.load(sys.stdin); print(r["hook"], r["command"])')" "guard-main(Bash) echo x > f"
assert_eq "старая фраза → guard-main(Edit/Write)" "$(printf '%s\n' "$out" | sed -n 2p | python3 -c 'import json,sys; r=json.load(sys.stdin); print(r["hook"], r["file_path"])')" "guard-main(Edit/Write) /r/a.md"
# --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"
# C-1: блокировка сабагента — в выгрузку сессии-родителя
mkdir -p "$T2/new/subagents"
printf '%s\n%s\n' '{"type":"assistant","isSidechain":true,"cwd":"/r","gitBranch":"main","timestamp":"2026-11-02T11:00:00Z","message":{"content":[{"type":"tool_use","id":"u1","name":"Bash","input":{"command":"echo c > f"}}]}}' "$blk" > "$T2/new/subagents/agent-x.jsonl"
# I-7: длинная сессия на стыке — блок до периода не выгружается, в периоде — выгружается; I-3: секрет на границе обрезки
lblk='{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"u2","is_error":true,"content":"PreToolUse:Bash hook error: BLOCKED [guard-main] '"$(printf 'z%.0s' $(seq 235))"' fedcba9876543210fedcba9876543210fedcba98"}]}}'
printf '%s\n%s\n%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 k > f"}}]}}' "$blk" '{"type":"assistant","cwd":"/r","gitBranch":"main","timestamp":"2026-11-03T10:00:00Z","message":{"content":[{"type":"tool_use","id":"u2","name":"Bash","input":{"command":"echo l > f"}}]}}' "$lblk" > "$T2/long.jsonl"
out=$(python3 "$X" --since 2026-11-01 --id-prefix 2026-11-05 "$T2")
assert_eq "--since по записям, сабагенты — в выгрузке, id <дата>-NNN" "$(printf '%s\n' "$out" | python3 -c 'import json,sys; print([(r["id"], r["session"], r["command"]) for r in map(json.loads, sys.stdin)])')" "[('2026-11-05-001', 'long.jso', 'echo l > f'), ('2026-11-05-002', 'new.json', 'echo b > f'), ('2026-11-05-003', 'new.json', 'echo c > f')]"
assert_eq "секрет на границе обрезки сообщения не утекает" "$(printf '%s\n' "$out" | python3 -c '
import sys
out, t = sys.stdin.read(), "fedcba9876543210fedcba9876543210fedcba98"
print(sum(t[i:i + 8] in out for i in range(len(t) - 7)))')" "0"
assert_eq "старая обрезка до redact утекла бы (проверка теста)" "$(python3 -c "import sys; sys.path.insert(0, '$ROOT/docs/retro/scripts'); import hook_blocks_export as h; m='PreToolUse:Bash hook error: BLOCKED [guard-main] ' + 'z'*235 + ' fedcba9876543210fedcba9876543210fedcba98'; print('fedcba98' in h.redact(m[:300]), 'fedcba98' in h.clip(m, 300))")" "True False"
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)" ""

View File

@ -0,0 +1,60 @@
# 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"
# активная задача: worktree на неслитой ветке со своим SDD workspace — не хвост
git -C "$G/c" worktree add -q "$G/c/.claude/worktrees/w2" -b feat/2 && git -C "$G/c/.claude/worktrees/w2" commit -q --allow-empty -m wip
mkdir -p "$G/c/.claude/worktrees/w2/.superpowers/sdd/plan-y" && touch "$G/c/.claude/worktrees/w2/.superpowers/sdd/plan-y/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"

178
tests/test-retro-metrics.sh Normal file
View File

@ -0,0 +1,178 @@
# retro_metrics.py: срез метрик по синтетическим транскриптам (спека harness-retro, #29).
M="$ROOT/docs/retro/scripts/retro_metrics.py"
P="$TMP/proj"; CR="$TMP/cc/projects"; mkdir -p "$P/.git" "$P/.claude" "$P/hooks" "$CR"
echo 'hooks/' > "$P/.claude/code-paths"
cat > "$TMP/gen.py" <<'PY'
import json, os, re, sys
P, CR = sys.argv[1], sys.argv[2]
slug = lambda p: re.sub(r"[^A-Za-z0-9]", "-", p)
def w(d, name, recs):
os.makedirs(os.path.join(CR, d), exist_ok=True)
with open(os.path.join(CR, d, name), "w", encoding="utf-8") as fh:
for r in recs:
fh.write((r if isinstance(r, str) else json.dumps(r, ensure_ascii=False)) + "\n")
def u(text, ts, cwd=P, **kw):
return dict({"type": "user", "timestamp": ts, "cwd": cwd, "message": {"role": "user", "content": text}}, **kw)
def tr(tid, text, ts):
return {"type": "user", "timestamp": ts, "cwd": P,
"message": {"content": [{"type": "tool_result", "tool_use_id": tid, "content": text}]}}
def a(mid, content, ts, cwd=P, usage=None):
m = {"id": mid, "role": "assistant", "content": content}
if usage:
m["usage"] = usage
return {"type": "assistant", "timestamp": ts, "cwd": cwd, "message": m}
def tu(tid, tool, **inp):
return {"type": "tool_use", "id": tid, "name": tool, "input": inp}
A = slug(P)
T = "2026-11-02T10:%02d:00Z"
U1 = {"input_tokens": 100, "cache_creation_input_tokens": 1000, "cache_read_input_tokens": 50000, "output_tokens": 10}
w(A, "s1.jsonl", [
u("сделай X", T % 0),
a("m1", [{"type": "text", "text": "Начинаю."}], T % 1, usage=U1),
a("m1", [tu("t0", "Skill", skill="role", args="dev")], T % 1, usage=U1),
a("m2", [tu("t1", "Read", file_path=P + "/hooks/a.py"), tu("t2", "Read", file_path=P + "/docs/x.md")], T % 2),
a("m3", [tu("t3", "mcp__codegraph__codegraph_explore", query="x"), tu("t4", "mcp__codegraph__codegraph_explore", query="y"),
tu("t5", "Bash", command="grep -rn x hooks/"), tu("t6", "Bash", command="codegraph init"),
tu("t7", "Agent", model="sonnet", prompt="p"), tu("t8", "Agent", prompt="q"),
tu("t9", "Bash", command="echo x > f"), tu("t10", "Bash", command="cat hooks/require-plan.sh")], T % 3),
tr("t9", 'PreToolUse:Bash hook error: ["$HOME/.claude/hooks/guard-main.sh"]: BLOCKED [guard-main]: запись в f', T % 3),
tr("t10", 'echo "BLOCKED: editing without a plan" >&2', T % 3),
tr("t6", "Permission for this action was denied by the Claude Code auto mode classifier. Reason: [Self-Modification]. If", T % 3),
tr("t5", "The user doesn't want to proceed with this tool use. The tool use was rejected", T % 3),
u("<command-name>/clear</command-name>", T % 4),
u("служебное", T % 4, isMeta=True),
u("[Request interrupted by user]", T % 4),
a("m4", [{"type": "text", "text": "Вхожу в worktree."}, tu("t11", "EnterWorktree", name="x")], T % 5),
u("почему мне нужно это каждый раз подтверждать?", T % 6),
u("перечитай память", T % 7),
a("m5", [tu("t12", "Bash", command="git branch -m feat/x")], T % 8),
u("нет, tea login add --token 0123456789abcdef0123456789abcdef01234567 не так", T % 9),
{"type": "system", "subtype": "compact_boundary", "timestamp": T % 9},
"not json",
"[]",
# I-3: секрет на границе обрезки хвоста ответа (200) и текста кандидата (300)
a("m6", [{"type": "text", "text": "Выполни: tea login add --token fedcba9876543210fedcba9876543210fedcba98 " + "y" * 170}], T % 10),
u("стоп, " + "x" * 270 + " 0123abcd0123abcd0123abcd0123abcd0123abcd", T % 11),
# I-6: упоминание контекста — не «перечитай»; сводка компакции — не промпт владельца
u("я сейчас смотрю на размер контекста в чистой сессии", T % 12),
{"type": "user", "timestamp": T % 13, "cwd": P, "isCompactSummary": True, "isVisibleInTranscriptOnly": True,
"message": {"role": "user", "content": "This session is being continued from a previous conversation. Нет, перечитай память."}},
# I-1: «|» внутри кавычек не режет команду
a("m7", [tu("t13", "Bash", command='grep -n "a\\|b" hooks/x.py'), tu("t14", "Bash", command='cat docs/a.md | grep "x|y" docs/')], T % 14),
])
# C-1: транскрипт сабагента — в сессию-родителя (инструменты, ходы, токены, codegraph, блокировки, отказы), без промптов
os.makedirs(os.path.join(CR, A, "s1", "subagents"), exist_ok=True)
w(os.path.join(A, "s1", "subagents"), "agent-a1.jsonl", [
{"type": "user", "isSidechain": True, "timestamp": "2026-11-02T10:03:30Z", "cwd": P,
"message": {"role": "user", "content": "Задача: реализуй, почему бы и нет"}},
a("sa1", [tu("sx1", "mcp__codegraph__codegraph_explore", query="z"), tu("sx2", "Read", file_path=P + "/hooks/b.py"),
tu("sx3", "Bash", command="echo y > g"), tu("sx4", "Bash", command="git push --force")], "2026-11-02T10:03:40Z",
usage={"input_tokens": 10, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 5}),
tr("sx3", 'PreToolUse:Bash hook error: BLOCKED [guard-main]: запись в g', "2026-11-02T10:03:41Z"),
tr("sx4", "denied by the Claude Code auto mode classifier. Reason: [Git Destructive]", "2026-11-02T10:03:42Z"),
])
# I-7: длинная сессия на стыке периодов — считаются только записи периода, стартовый контекст — вне периода
w(A, "s6.jsonl", [
u("старт до периода", "2026-10-30T09:00:00Z"),
a("p1", [], "2026-10-30T09:01:00Z",
usage={"input_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 40000, "output_tokens": 0}),
u("продолжаю", "2026-11-02T08:00:00Z"),
a("p2", [tu("z1", "Bash", command="ls")], "2026-11-02T08:01:00Z",
usage={"input_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 70000, "output_tokens": 0}),
])
W = P + "/.claude/worktrees/x"
w(A + "--claude-worktrees-x", "s2.jsonl", [
u("ок", "2026-11-03T09:00:00Z", cwd=W),
{"type": "assistant", "timestamp": "2026-11-03T09:00:30Z", "cwd": W, "isApiErrorMessage": True, # не ход модели
"message": {"id": "syn-1", "model": "<synthetic>", "role": "assistant", "content": [{"type": "text", "text": "API Error"}],
"usage": {"input_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 0}}},
a("n1", [tu("x1", "Bash", command='grep "unbalanced hooks/a.py')], "2026-11-03T09:01:00Z", cwd=W,
usage={"input_tokens": 0, "cache_creation_input_tokens": 6000, "cache_read_input_tokens": 50000, "output_tokens": 0}),
])
s5 = [u("да", "2026-11-04T09:00:00Z", cwd=P + ".wt/y"),
a("k1", [tu("y1", "Grep", pattern="x")], "2026-11-04T09:01:00Z", cwd=P + ".wt/y",
usage={"input_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 61000, "output_tokens": 0})]
w(slug(P + ".wt/y"), "s5.jsonl", s5)
w(A, "s5.jsonl", s5[:1]) # усечённая копия той же сессии — берётся больший файл
w(A, "s0.jsonl", [u("старое", "2026-10-01T09:00:00Z")])
w(A + "-tools", "s3.jsonl", [u("чужое", "2026-11-02T09:00:00Z", cwd=P + "-tools")])
os.makedirs(os.path.join(CR, A, "memory"), exist_ok=True)
open(os.path.join(CR, A, "memory", "MEMORY.md"), "w").write("x" * 100)
open(os.path.join(CR, A, "memory", "note.md"), "w").write("y" * 50)
PY
python3 "$TMP/gen.py" "$P" "$CR"
J="$TMP/m.json"
python3 "$M" "$P/" --since 2026-11-01 --projects-root "$CR" --out "$J"; rc=$?
assert_eq "срез: exit 0" "$rc" "0"
q(){ python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); m=d["metrics"]; print(eval(sys.argv[2]))' "$J" "$1"; }
assert_eq "схема 1" "$(q 'd["schema"]')" "1"
assert_eq "сессии: s1, s2 (worktree), s5 (.wt), s6 (на стыке) — без старой и чужой" "$(q 'm["sessions"]')" "4"
assert_eq "каталоги транскриптов среза" "$(q 'len(d["sources"]["dirs"])')" "3"
assert_eq "битые строки" "$(q 'd["sources"]["bad_lines"]')" "1"
assert_eq "промпты владельца без служебных, прерываний, сводки компакции, сабагента и записей до периода" "$(q 'm["prompts"]')" "9"
assert_eq "прерывания" "$(q 'm["interrupts"]')" "1"
assert_eq "ходы — по message.id, с сабагентом, только в периоде" "$(q 'm["turns"]')" "11"
assert_eq "вызовы инструментов с сабагентом" "$(q 'm["tool_calls"]')" "22"
assert_eq "токены вывода один раз на сообщение, с сабагентом" "$(q 'm["tokens"]["output"]')" "15"
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"]')" "(2, {'guard-main(Bash)': 2})"
assert_eq "отказы классификатора по причине, с сабагентом" "$(q 'sorted(m["classifier_by_reason"].items())')" "[('Git Destructive', 1), ('Self-Modification', 1)]"
assert_eq "отказы владельца" "$(q 'm["owner_denials"]')" "1"
assert_eq "codegraph против чтения кода: с сабагентом и grep с «|» в кавычках" "$(q 'sorted(m["codegraph"].items())')" "[('code_reads', 7), ('explore', 3), ('explore_share', 0.3), ('init', 1)]"
assert_eq "роли" "$(q 'sorted(m["roles"].items())')" "[('dev', 1), ('не выбрана', 3)]"
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"]')" "5"
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)]"
# I-2: правило src/ без code-paths — каталог src как аргумент тоже код
NC="$TMP/nocp"; mkdir -p "$NC/.git" "$NC/src" "$NC/svc/src"
assert_eq "src/ без code-paths: каталог как аргумент" "$(python3 -c "import sys; sys.path.insert(0, '$ROOT/docs/retro/scripts'); import retro_metrics as r; print([r.bash_reads_code(c, '$NC', {}) for c in ('grep -rn foo src/', 'grep -rn foo src', 'find svc/src -name x', 'grep -rn foo docs/')])")" "[True, True, True, False]"
# кандидаты в поправки: контекст и redact
C="$TMP/corr.jsonl"
python3 "$M" "$P" --since 2026-11-01 --projects-root "$CR" --corrections > "$C"
assert_eq "кандидатов 5" "$(grep -c . "$C")" "5"
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 "секрет на границе обрезки не утекает частично (I-3)" "$(python3 -c '
import sys
out = open(sys.argv[1], encoding="utf-8").read()
toks = ["fedcba9876543210fedcba9876543210fedcba98", "0123abcd0123abcd0123abcd0123abcd0123abcd"]
print(sum(t[i:i + 8] in out for t in toks for i in range(len(t) - 7)))' "$C")" "0"
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 "строка с разницей" '^\| Сессий \| 4 \| 44 \| -40 \|$' <(printf '%s' "$md")
assert_grep "новая метрика — прочерки" '^\| Кандидатов в поправки владельца \| 5 \| — \| — \|$' <(printf '%s' "$md")
assert_grep "снятая метрика" '^\| cd в основной checkout на main \| — \| — \| не считается: хуки 0.4.0 \|$' <(printf '%s' "$md")
assert_grep "метрика с другим способом подсчёта" '^\| Ходов модели \| 11 \| 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"
J="$TMP/d.json"
python3 "$M" "$P" --harness "$H" --projects-root "$CR" --out "$J"
assert_eq "период с последнего ретро" "$(q 'd["period"]["since"], m["sessions"]')" "('2026-11-03', 2)"
# I-4: черновик текущего ретро не сдвигает период — при существующем --out берётся его period.since
touch "$H/docs/retro/2026-11-05.md"
python3 "$M" "$P" --harness "$H" --projects-root "$CR" --out "$J"; rc=$?
assert_eq "повторный срез в тот же --out: тот же период" "$rc $(q 'd["period"]["since"], m["sessions"]')" "0 ('2026-11-03', 2)"
md=$(python3 "$M" "$P" --from "$J" --markdown --projects-root "$TMP/нет-такого")
assert_grep "--from: таблица из сохранённого среза без пересчёта" '^\| Сессий \| 2 \| — \| — \|$' <(printf '%s' "$md")
# нет сессий
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")
# H-1 (владелец, PR #30): «сделай сам» — агент переложил работу на владельца — признак поправки
assert_eq "«сделай сам» — признак поправки, «сделай X» — нет" "$(python3 -c "import sys; sys.path.insert(0, '$ROOT/docs/retro/scripts'); import retro_metrics as r; print([bool(r.MARKERS.search(t)) for t in ('сделай сам', 'Сделай сам, пожалуйста', 'сделай X', 'сделайсам')])")" "[True, True, False, False]"

33
tests/test-retro-skill.sh Normal file
View File

@ -0,0 +1,33 @@
# /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"]["value"])' "$J")" "1 True 72 76"
assert_eq "метрики 11.10: несравнимое помечено (сабагенты, широкий «перечитай»)" "$(python3 -c 'import json,sys; m=json.load(open(sys.argv[1]))["metrics"]; print([k for k in ("tool_calls", "hook_blocks", "classifier_denials", "recall") if "note" in m[k]], [k for k in ("init", "explore") if "note" in m["codegraph"][k]])' "$J")" "['tool_calls', 'hook_blocks', 'classifier_denials', 'recall'] ['init', 'explore']"
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 \(без сабагентов\) \| — \|$' <(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"