From b77776849eea9b45e9501d5383a0a93af7daf268 Mon Sep 17 00:00:00 2001 From: "andrey.epifantsev" Date: Sat, 24 Oct 2026 00:47:00 +0400 Subject: [PATCH 01/10] =?UTF-8?q?docs(spec):=20/harness-retro=20=E2=80=94?= =?UTF-8?q?=20=D1=81=D1=80=D0=B5=D0=B7=20=D0=BC=D0=B5=D1=82=D1=80=D0=B8?= =?UTF-8?q?=D0=BA,=20=D0=BF=D0=BE=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=B2=D0=BB=D0=B0=D0=B4=D0=B5=D0=BB=D1=8C=D1=86=D0=B0,=20?= =?UTF-8?q?=D1=85=D0=B2=D0=BE=D1=81=D1=82=D1=8B=20=D0=B7=D0=B0=D0=BA=D1=80?= =?UTF-8?q?=D1=8B=D1=82=D0=B8=D1=8F,=20=D0=BA=D0=BE=D1=80=D0=BF=D1=83?= =?UTF-8?q?=D1=81=20=D0=BF=D0=BE=20=D1=81=D0=B5=D0=B3=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=B0=D0=BC=20(#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenSpec change harness-retro: proposal, specs (harness-retro — новая capability; hook-guards — MODIFIED «Регрессионный корпус хуков»: сегменты и known), design с Decisions made without discussion. По итогам brainstorming 24.10.2026: черновик ретро, срез одного проекта, запуск из репозитория обвязки, разметка блокировок агентом в корпус, хвосты по текущему состоянию, поправки владельца по процессу, подход A. Co-Authored-By: Claude Opus 5.5 --- openspec/changes/harness-retro/.openspec.yaml | 2 + openspec/changes/harness-retro/design.md | 174 +++++++++++++++ openspec/changes/harness-retro/proposal.md | 69 ++++++ .../harness-retro/specs/harness-retro/spec.md | 207 ++++++++++++++++++ .../harness-retro/specs/hook-guards/spec.md | 33 +++ 5 files changed, 485 insertions(+) create mode 100644 openspec/changes/harness-retro/.openspec.yaml create mode 100644 openspec/changes/harness-retro/design.md create mode 100644 openspec/changes/harness-retro/proposal.md create mode 100644 openspec/changes/harness-retro/specs/harness-retro/spec.md create mode 100644 openspec/changes/harness-retro/specs/hook-guards/spec.md diff --git a/openspec/changes/harness-retro/.openspec.yaml b/openspec/changes/harness-retro/.openspec.yaml new file mode 100644 index 0000000..1f5d280 --- /dev/null +++ b/openspec/changes/harness-retro/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-24 diff --git a/openspec/changes/harness-retro/design.md b/openspec/changes/harness-retro/design.md new file mode 100644 index 0000000..7a5ebfa --- /dev/null +++ b/openspec/changes/harness-retro/design.md @@ -0,0 +1,174 @@ +# 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//.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 ] + [--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`. + +### 4. Что такое «код» +Пути в транскриптах относятся к репозиториям, которых может уже не быть: worktree удалены. Относительный путь считается от корня worktree или репозитория. Корень определяется в таком порядке: +1. часть пути после `/.claude/worktrees/<имя>/`; +2. затем после `<репо>.wt/<имя>/`; +3. затем после `/.wt/<задача>/<репо>/`; +4. затем после ближайшего существующего каталога с `.git`. + +Префиксы кода берутся из `.claude/code-paths` основного репозитория, без него — правило `src/` на любой глубине. Так же решает require-plan. + +Bash-команда считается чтением кода, если хотя бы один её простой сегмент (разбиение по `&&`, `||`, `;`, `|`) начинается с `grep`, `rg`, `find`, `cat`, `sed -n` или `head` и хотя бы один аргумент после разбора `shlex` (при ошибке — `split`) указывает в каталог кода относительно `cwd`. + +### 5. Блокировки, отказы, роли +- Блокировка: результат инструмента совпадает с `^\s*PreToolUse:\S+ hook error` и содержит `BLOCKED`. Хук определяется так же, как в `hook_blocks_export.hook_of`, функция импортируется. +- Отказ классификатора: `denied by the Claude Code auto mode classifier. Reason: \[([^\]]+)\]`. +- Отказ владельца: результат инструмента начинается с `The user doesn't want to proceed with this tool use` или содержит `[Request interrupted by user for tool use]`. +- Роль: первый `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; +- `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` строит песочницу на каждый сегмент (`/seg-/home`). Поле `known`: +- расхождение при `known` — `KNOWN `, тест не падает; +- совпадение при `known` — `KNOWN-FIXED `, тест падает. + +Итог: `корпус хуков: 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 | Сторона Gitea в хвостах не проверяется | Хрупко (формат описаний PR и issue); агент видит это при чтении | Незакрытая issue после merge не попадёт в метрику | diff --git a/openspec/changes/harness-retro/proposal.md b/openspec/changes/harness-retro/proposal.md new file mode 100644 index 0000000..974616c --- /dev/null +++ b/openspec/changes/harness-retro/proposal.md @@ -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 как полигон — первое ретро по нему делается на той машине. diff --git a/openspec/changes/harness-retro/specs/harness-retro/spec.md b/openspec/changes/harness-retro/specs/harness-retro/spec.md new file mode 100644 index 0000000..02e2085 --- /dev/null +++ b/openspec/changes/harness-retro/specs/harness-retro/spec.md @@ -0,0 +1,207 @@ +# 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: Период среза +Сессия SHALL входить в срез, если её первая метка времени не раньше `--since`. Без `--since` начало периода MUST браться из даты в имени последнего `docs/retro/ГГГГ-ММ-ДД*.md` репозитория обвязки. Если сессий нет, `retro_metrics.py` MUST завершаться с кодом 1 и сообщением, какой slug и период искались. + +#### Scenario: Период по умолчанию +- **WHEN** последний ретро-документ — `docs/retro/2026-10-11.md` и `--since` не указан +- **THEN** в срез входят сессии с первой меткой времени 2026-10-11 и позже + +#### 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`, не начинающееся с `<`. Прерывания `[Request interrupted by user]` MUST считаться отдельной метрикой, не промптами. + +#### Scenario: Служебные сообщения не считаются промптами +- **WHEN** в транскрипте есть сообщение пользователя с `tool_result`, сообщение с `isMeta` и строка `…` +- **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` чтением кода не считается + +### Requirement: Память проекта +Метрика памяти SHALL выводить размер `~/.claude/projects//memory/MEMORY.md` в байтах, число и общий объём файлов в этом каталоге. Отсутствующий каталог MUST давать нули, а не ошибку. Отдельно SHALL считаться промпты «перечитай память/контекст». + +#### Scenario: Памяти нет +- **WHEN** каталога памяти проекта нет +- **THEN** метрики памяти равны нулю, срез завершается с кодом 0 + +### Requirement: Кандидаты в поправки владельца +`retro_metrics.py --corrections` SHALL выгружать кандидатов в поправки владельца по процессу. Кандидат — это: +- промпт владельца со словом-признаком: «нет», «не так», «почему», «зачем», «опять», «снова», «я же», «стоп», «подожди», «не надо», «сначала», «верни», «откати»; +- прерывание; +- отказ владельца в разрешении. + +Отбор настоящих поправок делает агент, не скрипт. + +#### 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 ставиться «—». Метрика, отмеченная в прошлом файле как больше не считаемая, MUST выводиться с этой пометкой. + +#### Scenario: Новая метрика +- **WHEN** в прошлом файле нет метрики `corrections` +- **THEN** строка поправок владельца показывает текущее значение и «—» в колонках прошлого и разницы + +### Requirement: Секреты не попадают в черновик +Всё, что скрипты ретро выводят из транскриптов (промпты, команды, сообщения), SHALL проходить замену похожих на секреты подстрок на `` — той же функцией, что в `hook_blocks_export.py`. + +#### Scenario: Токен в промпте +- **WHEN** промпт-кандидат содержит `tea login add --token 0123456789abcdef0123456789abcdef01234567` +- **THEN** в выводе `--corrections` вместо токена стоит `` + +### Requirement: Хвосты чеклиста закрытия +`retro_leftovers.py <папка проекта>` SHALL искать хвосты по текущему состоянию каждого репозитория проекта (для workspace — из таблицы `| Каталог | Репозиторий | Роль |` в `CLAUDE.md`). Хвост — это: +- ветка `origin/<ветка>`, слитая в `origin/main`; +- локальная ветка, слитая или с upstream `gone`; +- worktree на такой ветке или `prunable`; +- `.superpowers/sdd/*`; +- неархивный `openspec/changes//` в `origin/main`; +- отставший или грязный основной checkout. + +#### Scenario: Слитая ветка осталась на сервере +- **WHEN** ветка `feat/6-x` слита в `origin/main` и есть на `origin` +- **THEN** выводится хвост вида `remote-branch` с подсказкой `git push origin --delete feat/6-x` + +#### 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` diff --git a/openspec/changes/harness-retro/specs/hook-guards/spec.md b/openspec/changes/harness-retro/specs/hook-guards/spec.md new file mode 100644 index 0000000..20970c9 --- /dev/null +++ b/openspec/changes/harness-retro/specs/hook-guards/spec.md @@ -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` строка продолжается `, известных ошибок ` + +#### Scenario: Сегменты с разной раскладкой +- **WHEN** корпус содержит сегмент пилота 12.10 и сегмент ретро с другим домашним каталогом и другими репозиториями +- **THEN** каждая запись прогоняется в песочнице своего сегмента, и вердикты совпадают с ожидаемыми + +#### Scenario: Известная ошибка +- **WHEN** запись имеет `expected: allow`, `known: "RT-21"`, а текущий guard-main её блокирует +- **THEN** тест корпуса не падает, в выводе запись отмечена как известная ошибка + +#### Scenario: Известная ошибка исправлена +- **WHEN** запись имеет `known: "RT-21"`, а текущие хуки выносят ожидаемый вердикт +- **THEN** тест корпуса печатает FAIL `KNOWN-FIXED` с идентификатором записи From 60d31c25db0afe27b8cc501d5fb21277f8d87673 Mon Sep 17 00:00:00 2001 From: "andrey.epifantsev" Date: Sat, 24 Oct 2026 01:10:10 +0400 Subject: [PATCH 02/10] =?UTF-8?q?docs(plan):=20=D0=BF=D0=BB=D0=B0=D0=BD=20?= =?UTF-8?q?/harness-retro=20=E2=80=94=204=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87?= =?UTF-8?q?=D0=B8;=20design:=20=D0=BF=D0=BE=D0=B4=D1=81=D1=87=D1=91=D1=82?= =?UTF-8?q?=20=D0=BF=D0=BE=20message.id,=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7?= =?UTF-8?q?=D1=8B=20=D0=B2=D0=BB=D0=B0=D0=B4=D0=B5=D0=BB=D1=8C=D1=86=D0=B0?= =?UTF-8?q?,=20=D0=B4=D0=B5=D0=B4=D1=83=D0=BF=D0=BB=D0=B8=D0=BA=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20=D1=81=D0=B5=D1=81=D1=81=D0=B8=D0=B9=20(#2?= =?UTF-8?q?9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-24-harness-retro.md | 1764 +++++++++++++++++ openspec/changes/harness-retro/design.md | 6 +- 2 files changed, 1769 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-10-24-harness-retro.md diff --git a/docs/superpowers/plans/2026-10-24-harness-retro.md b/docs/superpowers/plans/2026-10-24-harness-retro.md new file mode 100644 index 0000000..c4c82b1 --- /dev/null +++ b/docs/superpowers/plans/2026-10-24-harness-retro.md @@ -0,0 +1,1764 @@ +# `/harness-retro` — план реализации + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Повторяемое ретро обвязки по транскриптам, памяти и состоянию репозиториев одного проекта. Его результат — черновик `docs/retro/<дата>.md` со сравнением с прошлым ретро, блокировки хуков, размеченные в регрессионный корпус, и список поправок владельца по процессу. + +**Architecture:** +- **Скрипты** в `docs/retro/scripts/` (stdlib python3): + - `retro_metrics.py` — срез метрик, кандидаты в поправки, таблица «Цифры» со сравнением; + - `retro_leftovers.py` — хвосты чеклиста закрытия; + - `hook_blocks_export.py` — выгрузка блокировок за период, становится импортируемым модулем; + - `hook_cases_build.py` — сборка корпуса из разметки ретро. +- **Корпус хуков** состоит из сегментов: у каждого своя шапка `{home, layout}`. Записи с `known` — известные ошибки хуков. +- **Проектный skill** `.claude/skills/harness-retro/` задаёт порядок ретро и шаблон черновика. + +**Tech Stack:** bash, python3 stdlib (3.8+), git; тесты — `tests/run.sh` (bash, без зависимостей). + +**Spec:** `openspec/changes/harness-retro/` — `proposal.md`, `specs/harness-retro/spec.md`, `specs/hook-guards/spec.md`, `design.md`. Issue #29, ветка `feat/29-harness-retro`. + +**Worktree:** `/Users/andrejepifancev/Projects/cc-harness/.claude/worktrees/b12-harness-retro` — все пути ниже от его корня, все команды — из него. + +## Global Constraints + +- Только stdlib python3, совместимость с Python 3.8: без `match`, без `str.removeprefix`, без `X | Y` в аннотациях. +- Скрипты ретро только читают транскрипты, память и репозитории. Пишут только файл `--out`; `retro_leftovers.py --fetch` делает только `git fetch --prune origin`. +- Всё, что выводится из транскриптов (промпты, команды, сообщения), проходит `redact()` из `hook_blocks_export.py`. +- «Код» — как в require-plan: префиксы `.claude/code-paths` репозитория, без файла — любой `src/`. +- Корпус `skills/harness-doctor/fixtures/hook-cases.jsonl` всегда совпадает с выводом `python3 docs/retro/scripts/hook_cases_build.py`. +- Зелёным считается только весь набор `bash tests/run.sh`. +- Коммиты — в ветке `feat/29-harness-retro`, trailer `Co-Authored-By: Claude Opus 5.5 `. Push и PR — после задачи 4. +- Тексты документации и вывода — по-русски. + +## Предусловия окружения + +- Сессия в worktree `/Users/andrejepifancev/Projects/cc-harness/.claude/worktrees/b12-harness-retro` на ветке `feat/29-harness-retro`. +- `python3 --version` ≥ 3.8, `git --version` ≥ 2.31: `init -b`, клон пустого репозитория с unborn HEAD. +- `bash tests/run.sh` на старте зелёный — 519 passed. +- Стенд, порты, ключи API не нужны. + +## Review Focus + +1. **Одно сообщение модели на нескольких строках транскрипта** с общим `message.id` и одинаковым `usage` — ходы и токены считаются один раз. Тест: задача 2 (`tokens.output` = 10 при двух строках `m1`). +2. **Bash-команда с незакрытой кавычкой** (`grep "unbalanced hooks/a.py`) — разбор не падает, путь распознаётся по `split`. Тест: задача 2. +3. **Строки транскрипта — валидный JSON, но не объект** (`[]`), и записи без `cwd` и `timestamp` — пропуск без исключения. Тест: задача 2. +4. **Репозиторий без `origin`** — одна строка `no-origin-main`, без исключения. Тест: задача 3. +5. **Разметка, не совпадающая с выгрузкой** (нет id или лишний id) — сборка корпуса падает с понятным сообщением. Тест: задача 1. + +--- + +### Task 1: Корпус по сегментам, `known`, выгрузка блокировок за период + +**Files:** +- Modify: `docs/retro/scripts/hook_blocks_export.py` — переписать целиком (код ниже) +- Modify: `docs/retro/scripts/hook_cases_build.py` — шапка, `import glob`, `label_segments`, `main(argv)` +- Modify: `skills/harness-doctor/scripts/hook_corpus.py` — переписать целиком (код ниже) +- Modify: `skills/harness-doctor/scripts/doctor.sh` — строка вывода расхождений корпуса +- Test: `tests/test-hook-export.sh`, `tests/test-hook-corpus.sh` + +**Interfaces:** +- Consumes: — +- Produces: + - `hook_blocks_export.HOOK_ERROR` (regex), `redact(s) -> str`, `hook_of(txt, tool) -> str`; + - `export(dirs, since=None, prefix=None)` — генератор dict; + - `main(argv) -> int`; CLI: `[--since ГГГГ-ММ-ДД] [--id-prefix <дата>] <каталог> ...`. + - Формат `docs/retro/fixtures/hook-labels-<дата>.json`: `{"home", "layout", "cases": {"": {"expected", "why"[, "known"]} | {"skip": "<причина>"}}}`. + - `hook_cases_build.py [--fixtures-dir <каталог>]`. + - Строка `hook_corpus.py`: `id\twhy\texpected\tgot\tstatus\tcmd`, где status ∈ `ok|MISMATCH|KNOWN|KNOWN-FIXED`. + - Итог `--summary`: `корпус хуков: N/M` или `корпус хуков: N/M, известных ошибок K`. + +- [ ] **Step 1: Тесты выгрузки за период и импорта без побочного вывода** + +Дописать в конец `tests/test-hook-export.sh`: + +```bash +# --since и --id-prefix (B12, #29): только сессии не раньше даты, id <дата>-NNN; модуль импортируется без выгрузки +T2="$TMP/tr2"; mkdir -p "$T2" +blk='{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"u1","is_error":true,"content":"PreToolUse:Bash hook error: BLOCKED [guard-main]: запись в f"}]}}' +printf '%s\n%s\n' '{"type":"assistant","cwd":"/r","gitBranch":"main","timestamp":"2026-10-01T10:00:00Z","message":{"content":[{"type":"tool_use","id":"u1","name":"Bash","input":{"command":"echo a > f"}}]}}' "$blk" > "$T2/old.jsonl" +printf '%s\n%s\n' '{"type":"assistant","cwd":"/r","gitBranch":"main","timestamp":"2026-11-02T10:00:00Z","message":{"content":[{"type":"tool_use","id":"u1","name":"Bash","input":{"command":"echo b > f"}}]}}' "$blk" > "$T2/new.jsonl" +out=$(python3 "$X" --since 2026-11-01 --id-prefix 2026-11-05 "$T2") +assert_eq "--since: только сессия не раньше даты" "$(printf '%s\n' "$out" | grep -c .)" "1" +assert_eq "--id-prefix: id <дата>-NNN" "$(printf '%s\n' "$out" | python3 -c 'import json,sys; r=json.loads(sys.stdin.readline()); print(r["id"], r["command"])')" "2026-11-05-001 echo b > f" +assert_eq "без флагов поля id нет (выгрузка 12.10 не меняется)" "$(python3 "$X" "$T2" | python3 -c 'import json,sys; print(sum("id" in json.loads(l) for l in sys.stdin))')" "0" +assert_eq "импорт модуля не выгружает" "$(python3 -c "import sys; sys.argv=['x', '$T2']; sys.path.insert(0, '$ROOT/docs/retro/scripts'); import hook_blocks_export" 2>&1)" "" +``` + +- [ ] **Step 2: Запустить — тесты падают** + +Run: `bash tests/run.sh hook-export` +Expected: FAIL. `--since` и `--id-prefix` считаются каталогами (лишние записи, нет `id`), а импорт печатает выгрузку. + +- [ ] **Step 3: Переписать `docs/retro/scripts/hook_blocks_export.py`** + +```python +#!/usr/bin/env python3 +"""Выгрузка блокировок хуков из транскриптов Claude Code в JSONL — сырьё для регрессионных тестов хуков (B2). + +Запуск: python3 hook_blocks_export.py [--since ГГГГ-ММ-ДД] [--id-prefix <дата>] <каталог-транскриптов> [...] +Одна строка — одна блокировка: сессия, время, cwd и ветка на момент вызова, инструмент, команда или путь файла, +первая строка ответа хука. Поле expected не заполняется: вердикт (block/allow) проставляется при разметке. +Выгружаются только ошибки хуков (результат начинается с `PreToolUse:<инструмент> hook error`); вывод команд, +в котором встретилось слово BLOCKED (файлы памяти, исходники хуков), не попадает. +--since — только сессии с первой меткой времени не раньше даты; --id-prefix (по умолчанию — дата --since) — +поле id вида <префикс>-NNN (срез ретро, B12). Похожие на секреты подстроки заменяются на . +""" +import glob +import json +import os +import re +import sys + +HOOK_ERROR = re.compile(r"\s*PreToolUse:\S+ hook error") + +RE_SECRET = re.compile( + r'(?i)gh[pousr]_[A-Za-z0-9]{20,}|sk-[A-Za-z0-9_-]{20,}|\b[0-9a-f]{40}\b' + r'|(?P(?:token|password|passwd|secret|api[_-]?key)\s*[=:]\s*)\S+' + r'|(?P--(?:token|password)\s+)\S+|(?PAuthorization:\s*\S+\s+)\S+' +) + + +def redact(s): + def sub(m): + keep = m.group("kv") or m.group("flag") or m.group("auth") or "" + return keep + "" + return RE_SECRET.sub(sub, s) + + +def hook_of(txt, tool): + if "[guard-main]" in txt: + return "guard-main(Bash)" if tool == "Bash" else "guard-main(Edit/Write)" + if "[require-plan]" in txt: + return "require-plan" + if "shell write" in txt: # формулировки до 0.4.0 + return "guard-main(Bash)" + if "you are on" in txt: + return "guard-main(Edit/Write)" + if "without a plan" in txt: + return "require-plan" + if "commit/push в main" in txt or "ветке main" in txt or "reference-old-arm" in txt or "docs/source" in txt: + return "workspace-guard" + return "other" + + +def _records(path): + for line in open(path, encoding="utf-8", errors="replace"): + try: + j = json.loads(line) + except ValueError: + continue + if isinstance(j, dict): + yield j + + +def first_ts(path): + for j in _records(path): + if isinstance(j.get("timestamp"), str) and j["timestamp"]: + return j["timestamp"] + return "" + + +def export(dirs, since=None, prefix=None): + """Записи выгрузки по каталогам транскриптов, по порядку файлов и строк.""" + n = 0 + for d in dirs: + for f in sorted(glob.glob(os.path.join(os.path.expanduser(d), "*.jsonl"))): + if since and first_ts(f)[:10] < since: + continue + sid = os.path.basename(f)[:8] + pending = {} # tool_use_id -> (name, input, cwd, branch, ts) + for j in _records(f): + m = j.get("message") or {} + cont = m.get("content") + if j.get("type") == "assistant": + for b in cont or []: + if isinstance(b, dict) and b.get("type") == "tool_use": + pending[b.get("id")] = (b.get("name"), b.get("input") or {}, j.get("cwd", ""), + j.get("gitBranch", ""), j.get("timestamp", "")) + elif j.get("type") == "user" and isinstance(cont, list): + for b in cont: + if not (isinstance(b, dict) and b.get("type") == "tool_result"): + continue + txt = b.get("content", "") + if isinstance(txt, list): + txt = "\n".join(x.get("text", "") for x in txt if isinstance(x, dict)) + if not isinstance(txt, str) or not HOOK_ERROR.match(txt): + continue # обычный вывод команды, даже если в нём есть слово BLOCKED + name, inp, cwd, branch, ts = pending.get(b.get("tool_use_id"), ("?", {}, "", "", "")) + if name not in ("Bash", "Edit", "Write", "MultiEdit"): + continue + msg = next((l for l in txt.splitlines() if "BLOCKED" in l), "") + rec = { + "session": sid, "ts": ts, "cwd": cwd, "branch": branch, "tool": name, + "command": redact(inp.get("command", "")) if name == "Bash" else None, + "file_path": inp.get("file_path") if name != "Bash" else None, + "hook": hook_of(txt, name), "message": redact(msg[:300]), "expected": None, + } + if prefix: + n += 1 + rec = dict({"id": "%s-%03d" % (prefix, n)}, **rec) + yield rec + + +def main(argv): + args, dirs, since, prefix = argv[1:], [], None, None + i = 0 + while i < len(args): + if args[i] == "--since" and i + 1 < len(args): + since, i = args[i + 1], i + 2 + elif args[i] == "--id-prefix" and i + 1 < len(args): + prefix, i = args[i + 1], i + 2 + else: + dirs.append(args[i]) + i += 1 + for rec in export(dirs, since, prefix or since): + print(json.dumps(rec, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) +``` + +- [ ] **Step 4: Запустить — тесты выгрузки зелёные** + +Run: `bash tests/run.sh hook-export` +Expected: `passed 7, failed 0`. + +- [ ] **Step 5: Тесты сегментов, `known` и разметки ретро** + +В `tests/test-hook-corpus.sh` заменить цикл проверки записей: + +```bash +while IFS=$'\t' read -r id why exp got cmd; do + case "$id" in total*) continue ;; esac + assert_eq "корпус $id ($why): $cmd" "$got" "$exp" +done < <(python3 "$R" "$ROOT/hooks") +``` + +на: + +```bash +while IFS=$'\t' read -r id why exp got st cmd; do + case "$id" in total*) continue ;; esac + case "$st" in + ok|KNOWN) ok "корпус $id ($why)" ;; + *) fail "корпус $id ($why): $cmd" "ожидалось $exp, получено $got ($st)" ;; + esac +done < <(python3 "$R" "$ROOT/hooks") +``` + +и дописать в конец файла: + +```bash +# сегменты и known (#29): синтетический корпус из двух сегментов с разными home +S="$TMP/seg"; mkdir -p "$S" +cat > "$S/c.jsonl" <<'EOF' +{"home": "/h1", "layout": [{"path": "m", "branch": "main"}]} +{"id": "a1", "why": "main", "tool": "Write", "file_path": "/h1/m/a.md", "cwd": "/h1/m", "expected": "block"} +{"id": "a2", "why": "known-x", "tool": "Write", "file_path": "/h1/m/b.md", "cwd": "/h1/m", "expected": "allow", "known": "RT-21"} +{"home": "/h2", "layout": [{"path": "r", "branch": "feat/x"}]} +{"id": "b1", "why": "branch", "tool": "Write", "file_path": "/h2/r/a.md", "cwd": "/h2/r", "expected": "allow"} +EOF +out=$(python3 "$R" "$ROOT/hooks" --summary --fixtures "$S/c.jsonl"); rc=$? +assert_eq "два сегмента и известная ошибка: exit 0" "$rc" "0" +assert_eq "итог с известными ошибками" "$out" "корпус хуков: 3/3, известных ошибок 1" +sed 's/"expected": "allow", "known"/"expected": "block", "known"/' "$S/c.jsonl" > "$S/f.jsonl" +out=$(python3 "$R" "$ROOT/hooks" --summary --fixtures "$S/f.jsonl"); rc=$? +assert_eq "known совпал с вердиктом: exit 1" "$rc" "1" +assert_grep "KNOWN-FIXED с id записи" '^KNOWN-FIXED a2' <(printf '%s' "$out") +# разметка ретро (#29): hook-labels-<дата>.json + hook-blocks-<дата>.jsonl → новый сегмент корпуса +B="$ROOT/docs/retro/scripts/hook_cases_build.py" +FX="$TMP/fx"; mkdir -p "$FX"; cp "$ROOT/docs/retro/fixtures/hook-blocks.jsonl" "$FX/" +cat > "$FX/hook-blocks-2026-11-01.jsonl" <<'EOF' +{"id": "2026-11-01-001", "session": "s", "ts": "2026-11-02T10:00:00Z", "cwd": "/h3/r", "branch": "main", "tool": "Bash", "command": "ls x 2>/dev/null", "file_path": null, "hook": "guard-main(Bash)", "message": "BLOCKED", "expected": null} +{"id": "2026-11-01-002", "session": "s", "ts": "2026-11-02T10:01:00Z", "cwd": "/h3/r", "branch": "main", "tool": "Bash", "command": "echo x > f", "file_path": null, "hook": "guard-main(Bash)", "message": "BLOCKED", "expected": null} +{"id": "2026-11-01-003", "session": "s", "ts": "2026-11-02T10:02:00Z", "cwd": "/h3/r", "branch": "main", "tool": "Bash", "command": "cat $X", "file_path": null, "hook": "guard-main(Bash)", "message": "BLOCKED", "expected": null} +EOF +cat > "$FX/hook-labels-2026-11-01.json" <<'EOF' +{"home": "/h3", "layout": [{"path": "r", "branch": "main"}], + "cases": {"2026-11-01-001": {"expected": "allow", "why": "devnull"}, + "2026-11-01-002": {"expected": "block", "why": "main-redirect"}, + "2026-11-01-003": {"skip": "цель в переменной, состояние неизвестно"}}} +EOF +out=$(python3 "$B" --fixtures-dir "$FX"); rc=$? +assert_eq "сборка с разметкой ретро: exit 0" "$rc" "0" +assert_eq "в корпусе два сегмента" "$(printf '%s\n' "$out" | grep -c '"layout"')" "2" +assert_eq "сегмент ретро: две записи" "$(printf '%s\n' "$out" | grep -c '"2026-11-01-00[12]"')" "2" +assert_eq "запись со skip не в корпусе" "$(printf '%s\n' "$out" | grep -c '2026-11-01-003')" "0" +printf '%s\n' "$out" > "$FX/cases.jsonl" +assert_eq "корпус с сегментом ретро проходит хуки" "$(python3 "$R" "$ROOT/hooks" --summary --fixtures "$FX/cases.jsonl" | tail -1)" "корпус хуков: 131/131" +python3 - "$FX/hook-labels-2026-11-01.json" <<'PY' +import json, sys +p = sys.argv[1]; d = json.load(open(p)); del d["cases"]["2026-11-01-002"]; d["cases"]["2026-11-01-009"] = {"skip": "x"} +json.dump(d, open(p, "w")) +PY +out=$(python3 "$B" --fixtures-dir "$FX" 2>&1 >/dev/null); rc=$? +assert_eq "разметка не совпадает с выгрузкой: exit 1" "$rc" "1" +assert_grep "сообщение называет недостающий и лишний id" '2026-11-01-002.*2026-11-01-009' <(printf '%s' "$out") +``` + +- [ ] **Step 6: Запустить — тесты падают** + +Run: `bash tests/run.sh hook-corpus` +Expected: FAIL. Раннер выдаёт 5 колонок (status пустой — FAIL по записям корпуса), второй сегмент не собирается, `--fixtures-dir` не принимается. + +- [ ] **Step 7: Переписать `skills/harness-doctor/scripts/hook_corpus.py`** + +```python +#!/usr/bin/env python3 +"""Регрессионный корпус хуков (B2): прогон fixtures/hook-cases.jsonl через guard-main и require-plan в песочнице. + +Запуск: python3 hook_corpus.py <каталог хуков> [--summary] [--fixtures ] +Корпус — сегменты: строка с ключом layout ({home, layout}) открывает сегмент, следующие строки — его записи. +Для каждого сегмента строится своя песочница; префикс home записей заменяется на её home, HOME хуков — туда же. +Bash идёт через guard-main, Edit/Write — через guard-main и require-plan (блок, если блокирует любой). +Запись с known — известная ошибка хуков: расхождение — KNOWN (не ошибка прогона), совпадение — KNOWN-FIXED (ошибка: +снять known). Вывод: строка «idwhyожидалосьполученостатускоманда» и итог «total N/M»; +с --summary — только MISMATCH и KNOWN-FIXED и «корпус хуков: N/M[, известных ошибок K]». Код 0 — нет ошибок. +""" +import json +import os +import shutil +import subprocess +import sys +import tempfile + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +def _git(*args): + subprocess.run(["git", "-c", "user.name=corpus", "-c", "user.email=corpus@example.invalid"] + list(args), + check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def build(home, layout): + for e in layout: + p = os.path.join(home, e["path"]) + if "worktree_of" in e: + _git("-C", os.path.join(home, e["worktree_of"]), "worktree", "add", "-q", p, "-b", e["branch"]) + elif "branch" in e: + os.makedirs(p, exist_ok=True) + _git("init", "-q", p) + _git("-C", p, "symbolic-ref", "HEAD", "refs/heads/" + e["branch"]) + _git("-C", p, "commit", "-q", "--allow-empty", "-m", "init") + else: + os.makedirs(p, exist_ok=True) + + +def verdict(hooks, case, prefix, home, env): + swap = lambda s: s.replace(prefix, home) if isinstance(s, str) else s + tool = case["tool"] + ti = {"command": swap(case.get("command"))} if tool == "Bash" else {"file_path": swap(case.get("file_path"))} + payload = json.dumps({"cwd": swap(case.get("cwd")), "tool_name": tool, "tool_input": ti}) + for name in ["guard-main"] + ([] if tool == "Bash" else ["require-plan"]): + r = subprocess.run(["bash", os.path.join(hooks, name + ".sh")], input=payload, universal_newlines=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, timeout=30) + if r.returncode == 2: + return "block" + if r.returncode != 0: + return "error" + return "allow" + + +def segments(lines): + """[(шапка, [записи])]: строка с ключом layout открывает сегмент.""" + segs = [] + for obj in lines: + if "layout" in obj: + segs.append((obj, [])) + elif segs: + segs[-1][1].append(obj) + return segs + + +def status(case, got): + if got == case["expected"]: + return "KNOWN-FIXED" if case.get("known") else "ok" + return "KNOWN" if case.get("known") else "MISMATCH" + + +def main(argv): + args = argv[1:] + summary = "--summary" in args + fx = os.path.join(HERE, "..", "fixtures", "hook-cases.jsonl") + if "--fixtures" in args: + fx = args[args.index("--fixtures") + 1] + pos = [a for k, a in enumerate(args) if not a.startswith("--") and (k == 0 or args[k - 1] != "--fixtures")] + if not pos: + print(__doc__.strip().splitlines()[2]) + return 1 + hooks = os.path.abspath(pos[0]) + lines = [json.loads(l) for l in open(fx, encoding="utf-8") if l.strip()] + env0 = {k: v for k, v in os.environ.items() if k not in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE")} + tmp = tempfile.mkdtemp(prefix="hook-corpus-") + try: + total = good = known = 0 + for n, (head, cases) in enumerate(segments(lines)): + home = os.path.join(tmp, "seg-%d" % n, "home") + try: + build(home, head["layout"]) + except Exception as e: + print("корпус хуков: песочница сегмента %d не собрана (%s)" % (n + 1, e)) + return 1 + env = dict(env0, HOME=home) + for c in cases: + got = verdict(hooks, c, head["home"], home, env) + st = status(c, got) + total += 1 + good += st in ("ok", "KNOWN") + known += st == "KNOWN" + body = (c.get("command") or c.get("file_path") or "").replace(head["home"], "~") + row = "\t".join([c["id"], c["why"], c["expected"], got, st, " ".join(body.split())[:70]]) + if not summary: + print(row) + elif st in ("MISMATCH", "KNOWN-FIXED"): + print(st + " " + row) + tail = ", известных ошибок %d" % known if known else "" + print(("корпус хуков: %d/%d%s" if summary else "total %d/%d%s") % (good, total, tail)) + return 0 if good == total else 1 + finally: + shutil.rmtree(tmp, ignore_errors=True) + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) +``` + +- [ ] **Step 8: Изменить `docs/retro/scripts/hook_cases_build.py`** + +Строки шапки: + +```python +Запуск из корня репозитория: + python3 docs/retro/scripts/hook_cases_build.py > skills/harness-doctor/fixtures/hook-cases.jsonl +Вход: docs/retro/fixtures/hook-blocks.jsonl (hook_blocks_export.py, 12.10.2026, 126 записей). +Выход: первая строка — {"home": …, "layout": […]} (раскладка песочницы), дальше по записи на случай: +поля выгрузки + id, expected (allow|block), why (класс). Вердикт размечен вручную по спеке +openspec/specs/hook-guards/spec.md, а не прогоном хука. +""" +import json +``` + +заменить на: + +```python +Запуск из корня репозитория: + python3 docs/retro/scripts/hook_cases_build.py [--fixtures-dir <каталог>] > skills/harness-doctor/fixtures/hook-cases.jsonl +Вход: /hook-blocks.jsonl (hook_blocks_export.py, 12.10.2026, 126 записей) и пары ретро +hook-blocks-<дата>.jsonl + hook-labels-<дата>.json (B12, #29). +Выход — сегменты: строка {"home": …, "layout": […]} (раскладка песочницы), дальше по записи на случай: +поля выгрузки + id, expected (allow|block), why (класс)[, known]. Первый сегмент — пилот 12.10 и живые случаи, +дальше по сегменту на пару ретро; записи со skip не входят. Вердикт размечен вручную по спеке +openspec/specs/hook-guards/spec.md, а не прогоном хука. +""" +import glob +import json +``` + +Строку `RAW = os.path.join(HERE, "..", "fixtures", "hook-blocks.jsonl")` заменить на: + +```python +FIX = os.path.join(HERE, "..", "fixtures") +``` + +Функцию `main` и блок `if __name__ == "__main__":` целиком заменить на: + +```python +def label_segments(fix): + """Сегменты ретро: пары hook-blocks-<дата>.jsonl + hook-labels-<дата>.json. Разметка должна совпадать с выгрузкой.""" + for lp in sorted(glob.glob(os.path.join(fix, "hook-labels-*.json"))): + date = os.path.basename(lp)[len("hook-labels-"):-len(".json")] + lab = json.load(open(lp, encoding="utf-8")) + cases = lab.get("cases", {}) + recs = [json.loads(l) for l in open(os.path.join(fix, "hook-blocks-%s.jsonl" % date), encoding="utf-8") + if l.strip()] + ids = [r.get("id") for r in recs] + missing, extra = [i for i in ids if i not in cases], sorted(set(cases) - set(ids)) + if missing or extra: + raise SystemExit("hook-labels-%s.json: нет разметки для %s; лишняя разметка %s" + % (date, ", ".join(missing) or "—", ", ".join(extra) or "—")) + yield {"home": lab["home"], "layout": lab["layout"]} + for r in recs: + c = cases[r["id"]] + if "skip" in c: + continue + r.update({"expected": c["expected"], "why": c["why"]}) + if c.get("known"): + r["known"] = c["known"] + yield r + + +def main(argv): + fix = argv[argv.index("--fixtures-dir") + 1] if "--fixtures-dir" in argv else FIX + print(json.dumps({"home": HOME, "layout": LAYOUT}, ensure_ascii=False)) + raw = os.path.join(fix, "hook-blocks.jsonl") + for n, line in enumerate(l for l in open(raw, encoding="utf-8") if l.strip()): + r = json.loads(line) + cid = "c%03d" % (n + 1) + r.update({"id": cid, "expected": "block" if cid in BLOCK else "allow", "why": why_of(cid, r)}) + print(json.dumps(r, ensure_ascii=False)) + for cid, why, cmd in LIVE: + print(json.dumps({"id": cid, "session": "live-1015", "cwd": HOME + "/" + CH, "branch": "main", + "tool": "Bash", "command": cmd, "file_path": None, "hook": "guard-main(Bash)", + "message": "", "expected": "allow", "why": why}, ensure_ascii=False)) + for obj in label_segments(fix): + print(json.dumps(obj, ensure_ascii=False)) + + +if __name__ == "__main__": + main(sys.argv) +``` + +- [ ] **Step 9: Корпус собирается без изменений** + +Run: `python3 docs/retro/scripts/hook_cases_build.py | cmp - skills/harness-doctor/fixtures/hook-cases.jsonl && echo same` +Expected: `same`. Пар ретро пока нет, вывод тот же байт в байт. + +- [ ] **Step 10: Строка расхождений в `doctor.sh`** + +В `skills/harness-doctor/scripts/doctor.sh` строку + +```bash + printf '%s\n' "$out" | grep '^MISMATCH' | sed 's/^MISMATCH / /' +``` + +заменить на + +```bash + printf '%s\n' "$out" | grep -E '^(MISMATCH|KNOWN-FIXED) ' | sed 's/^/ /' +``` + +- [ ] **Step 11: Запустить тесты задачи и весь набор** + +Run: `bash tests/run.sh hook-corpus && bash tests/run.sh hook-export && bash tests/run.sh doctor && bash tests/run.sh` +Expected: везде `failed 0`. В `hook-corpus` по одной строке `ok` на запись корпуса (129) плюс 11 новых проверок. + +- [ ] **Step 12: Commit** + +```bash +git add docs/retro/scripts/hook_blocks_export.py docs/retro/scripts/hook_cases_build.py skills/harness-doctor/scripts/hook_corpus.py skills/harness-doctor/scripts/doctor.sh tests/test-hook-export.sh tests/test-hook-corpus.sh +git commit -m "feat(retro): корпус хуков по сегментам и known, выгрузка блокировок за период (#29)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 2: `retro_metrics.py` — срез метрик, кандидаты в поправки, сравнение + +**Files:** +- Create: `docs/retro/scripts/retro_metrics.py` +- Test: `tests/test-retro-metrics.sh` + +**Interfaces:** +- Consumes: `hook_blocks_export.HOOK_ERROR`, `hook_of(txt, tool)`, `redact(s)` (задача 1). +- Produces: + - CLI `retro_metrics.py <проект> [--since] [--harness] [--projects-root] [--labels] [--out] [--markdown] [--prev] [--corrections]`; + - функции `slug(path)`, `to_markdown(doc, prev) -> str`; + - ключи `metrics` (по ним задача 4 заполняет `2026-10-11.metrics.json`): `sessions`, `prompts`, `interrupts`, `turns`, `tool_calls`, `tools`, `roles`, `subagents`, `subagent_models`, `skills`, `tokens.{output,cache_read,cache_write}`, `compactions`, `start_context.{median,min,max}`, `hook_blocks`, `hook_blocks_by_hook`, `false_blocks.{labelled,skipped,false,share,fixed,known}`, `classifier_denials`, `classifier_by_reason`, `owner_denials`, `codegraph.{init,explore,code_reads,explore_share}`, `corrections`, `recall`, `memory.{index_bytes,files,bytes}`, `cd_main`. + - Значение метрики в прошлом файле: число, словарь, `{"retired": "<причина>"}` или `{"value": <число>, "note": "<как считалось>"}`. + +- [ ] **Step 1: Тест на синтетических транскриптах** + +Создать `tests/test-retro-metrics.sh`: + +```bash +# retro_metrics.py: срез метрик по синтетическим транскриптам (спека harness-retro, #29). +M="$ROOT/docs/retro/scripts/retro_metrics.py" +P="$TMP/proj"; CR="$TMP/cc/projects"; mkdir -p "$P/.git" "$P/.claude" "$P/hooks" "$CR" +echo 'hooks/' > "$P/.claude/code-paths" +cat > "$TMP/gen.py" <<'PY' +import json, os, re, sys +P, CR = sys.argv[1], sys.argv[2] +slug = lambda p: re.sub(r"[^A-Za-z0-9]", "-", p) +def w(d, name, recs): + os.makedirs(os.path.join(CR, d), exist_ok=True) + with open(os.path.join(CR, d, name), "w", encoding="utf-8") as fh: + for r in recs: + fh.write((r if isinstance(r, str) else json.dumps(r, ensure_ascii=False)) + "\n") +def u(text, ts, cwd=P, **kw): + return dict({"type": "user", "timestamp": ts, "cwd": cwd, "message": {"role": "user", "content": text}}, **kw) +def tr(tid, text, ts): + return {"type": "user", "timestamp": ts, "cwd": P, + "message": {"content": [{"type": "tool_result", "tool_use_id": tid, "content": text}]}} +def a(mid, content, ts, cwd=P, usage=None): + m = {"id": mid, "role": "assistant", "content": content} + if usage: + m["usage"] = usage + return {"type": "assistant", "timestamp": ts, "cwd": cwd, "message": m} +def tu(tid, name, **inp): + return {"type": "tool_use", "id": tid, "name": name, "input": inp} +A = slug(P) +T = "2026-11-02T10:%02d:00Z" +U1 = {"input_tokens": 100, "cache_creation_input_tokens": 1000, "cache_read_input_tokens": 50000, "output_tokens": 10} +w(A, "s1.jsonl", [ + u("сделай X", T % 0), + a("m1", [{"type": "text", "text": "Начинаю."}], T % 1, usage=U1), + a("m1", [tu("t0", "Skill", skill="role", args="dev")], T % 1, usage=U1), + a("m2", [tu("t1", "Read", file_path=P + "/hooks/a.py"), tu("t2", "Read", file_path=P + "/docs/x.md")], T % 2), + a("m3", [tu("t3", "mcp__codegraph__codegraph_explore", query="x"), tu("t4", "mcp__codegraph__codegraph_explore", query="y"), + tu("t5", "Bash", command="grep -rn x hooks/"), tu("t6", "Bash", command="codegraph init"), + tu("t7", "Agent", model="sonnet", prompt="p"), tu("t8", "Agent", prompt="q"), + tu("t9", "Bash", command="echo x > f"), tu("t10", "Bash", command="cat hooks/require-plan.sh")], T % 3), + tr("t9", 'PreToolUse:Bash hook error: ["$HOME/.claude/hooks/guard-main.sh"]: BLOCKED [guard-main]: запись в f', T % 3), + tr("t10", 'echo "BLOCKED: editing without a plan" >&2', T % 3), + tr("t6", "Permission for this action was denied by the Claude Code auto mode classifier. Reason: [Self-Modification]. If", T % 3), + tr("t5", "The user doesn't want to proceed with this tool use. The tool use was rejected", T % 3), + u("/clear", T % 4), + u("служебное", T % 4, isMeta=True), + u("[Request interrupted by user]", T % 4), + a("m4", [{"type": "text", "text": "Вхожу в worktree."}, tu("t11", "EnterWorktree", name="x")], T % 5), + u("почему мне нужно это каждый раз подтверждать?", T % 6), + u("перечитай память", T % 7), + a("m5", [tu("t12", "Bash", command="git branch -m feat/x")], T % 8), + u("нет, tea login add --token 0123456789abcdef0123456789abcdef01234567 не так", T % 9), + {"type": "system", "subtype": "compact_boundary", "timestamp": T % 9}, + "not json", + "[]", +]) +W = P + "/.claude/worktrees/x" +w(A + "--claude-worktrees-x", "s2.jsonl", [ + u("ок", "2026-11-03T09:00:00Z", cwd=W), + a("n1", [tu("x1", "Bash", command='grep "unbalanced hooks/a.py')], "2026-11-03T09:01:00Z", cwd=W, + usage={"input_tokens": 0, "cache_creation_input_tokens": 6000, "cache_read_input_tokens": 50000, "output_tokens": 0}), +]) +s5 = [u("да", "2026-11-04T09:00:00Z", cwd=P + ".wt/y"), + a("k1", [tu("y1", "Grep", pattern="x")], "2026-11-04T09:01:00Z", cwd=P + ".wt/y", + usage={"input_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 61000, "output_tokens": 0})] +w(slug(P + ".wt/y"), "s5.jsonl", s5) +w(A, "s5.jsonl", s5[:1]) # усечённая копия той же сессии — берётся больший файл +w(A, "s0.jsonl", [u("старое", "2026-10-01T09:00:00Z")]) +w(A + "-tools", "s3.jsonl", [u("чужое", "2026-11-02T09:00:00Z", cwd=P + "-tools")]) +os.makedirs(os.path.join(CR, A, "memory"), exist_ok=True) +open(os.path.join(CR, A, "memory", "MEMORY.md"), "w").write("x" * 100) +open(os.path.join(CR, A, "memory", "note.md"), "w").write("y" * 50) +PY +python3 "$TMP/gen.py" "$P" "$CR" +J="$TMP/m.json" +python3 "$M" "$P/" --since 2026-11-01 --projects-root "$CR" --out "$J"; rc=$? +assert_eq "срез: exit 0" "$rc" "0" +q(){ python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); m=d["metrics"]; print(eval(sys.argv[2]))' "$J" "$1"; } +assert_eq "схема 1" "$(q 'd["schema"]')" "1" +assert_eq "сессии: s1, s2 (worktree), s5 (.wt) — без старой и чужой" "$(q 'm["sessions"]')" "3" +assert_eq "каталоги транскриптов среза" "$(q 'len(d["sources"]["dirs"])')" "3" +assert_eq "битые строки" "$(q 'd["sources"]["bad_lines"]')" "1" +assert_eq "промпты владельца без служебных и прерываний" "$(q 'm["prompts"]')" "6" +assert_eq "прерывания" "$(q 'm["interrupts"]')" "1" +assert_eq "ходы — по message.id" "$(q 'm["turns"]')" "7" +assert_eq "вызовы инструментов" "$(q 'm["tool_calls"]')" "15" +assert_eq "токены вывода один раз на сообщение" "$(q 'm["tokens"]["output"]')" "10" +assert_eq "стартовый контекст" "$(q 'sorted(m["start_context"].items())')" "[('max', 61000), ('median', 56000), ('min', 51100)]" +assert_eq "блокировки только по ошибке хука" "$(q 'm["hook_blocks"], m["hook_blocks_by_hook"]')" "(1, {'guard-main(Bash)': 1})" +assert_eq "отказы классификатора по причине" "$(q 'm["classifier_by_reason"]')" "{'Self-Modification': 1}" +assert_eq "отказы владельца" "$(q 'm["owner_denials"]')" "1" +assert_eq "codegraph против чтения кода" "$(q 'sorted(m["codegraph"].items())')" "[('code_reads', 5), ('explore', 2), ('explore_share', 0.29), ('init', 1)]" +assert_eq "роли" "$(q 'sorted(m["roles"].items())')" "[('dev', 1), ('не выбрана', 2)]" +assert_eq "сабагенты по моделям" "$(q 'sorted(m["subagent_models"].items())')" "[('sonnet', 1), ('не указана', 1)]" +assert_eq "skills" "$(q 'm["skills"]')" "{'role': 1}" +assert_eq "компакции, перечитай" "$(q 'm["compactions"], m["recall"]')" "(1, 1)" +assert_eq "кандидаты в поправки" "$(q 'm["corrections"]')" "4" +assert_eq "память" "$(q 'sorted(m["memory"].items())')" "[('bytes', 150), ('files', 2), ('index_bytes', 100)]" +assert_eq "памяти нет — нули" "$(python3 -c "import sys; sys.path.insert(0, '$ROOT/docs/retro/scripts'); import retro_metrics; print(sorted(retro_metrics.memory_stats('$TMP/none', '$P').items()))")" "[('bytes', 0), ('files', 0), ('index_bytes', 0)]" +# кандидаты в поправки: контекст и redact +C="$TMP/corr.jsonl" +python3 "$M" "$P" --since 2026-11-01 --projects-root "$CR" --corrections > "$C" +assert_eq "кандидатов 4" "$(grep -c . "$C")" "4" +assert_eq "«почему» после EnterWorktree" "$(python3 -c 'import json,sys; print([c["action"] for c in map(json.loads, open(sys.argv[1])) if "почему" in c["text"]])' "$C")" "['EnterWorktree']" +assert_eq "последнее действие Bash" "$(python3 -c 'import json,sys; print([c["action"] for c in map(json.loads, open(sys.argv[1])) if c["text"].startswith("нет")])' "$C")" "['Bash: git branch -m feat/x']" +assert_eq "токен замазан" "$(grep -c '0123456789abcdef' "$C")" "0" +assert_grep "токен заменён на " '' "$C" +assert_eq "виды кандидатов" "$(python3 -c 'import json,sys; print(sorted(set(c["kind"] for c in map(json.loads, open(sys.argv[1])))))' "$C")" "['denial', 'interrupt', 'prompt']" +# сравнение с прошлым ретро +cat > "$TMP/prev.json" <<'EOF' +{"schema": 1, "metrics": {"sessions": 44, "turns": {"value": 12121, "note": "по строкам"}, "cd_main": {"retired": "хуки 0.4.0"}}} +EOF +md=$(python3 "$M" "$P" --since 2026-11-01 --projects-root "$CR" --markdown --prev "$TMP/prev.json") +assert_grep "строка с разницей" '^\| Сессий \| 3 \| 44 \| -41 \|$' <(printf '%s' "$md") +assert_grep "новая метрика — прочерки" '^\| Кандидатов в поправки владельца \| 4 \| — \| — \|$' <(printf '%s' "$md") +assert_grep "снятая метрика" '^\| cd в основной checkout на main \| — \| — \| не считается: хуки 0.4.0 \|$' <(printf '%s' "$md") +assert_grep "метрика с другим способом подсчёта" '^\| Ходов модели \| 7 \| 12 121 \(по строкам\) \| — \|$' <(printf '%s' "$md") +# ложные блокировки по разметке +cat > "$TMP/labels.json" <<'EOF' +{"home": "/h", "layout": [], "cases": {"x-001": {"expected": "allow", "why": "a"}, "x-002": {"expected": "allow", "why": "b"}, + "x-003": {"expected": "allow", "why": "c", "known": "RT-21"}, "x-004": {"expected": "block", "why": "d"}, "x-005": {"skip": "s"}}} +EOF +python3 "$M" "$P" --since 2026-11-01 --projects-root "$CR" --labels "$TMP/labels.json" --out "$J" +assert_eq "ложные блокировки" "$(q 'sorted(m["false_blocks"].items())')" "[('false', 3), ('fixed', 2), ('known', 1), ('labelled', 4), ('share', 0.75), ('skipped', 1)]" +# период по умолчанию — от даты последнего ретро +H="$TMP/harness"; mkdir -p "$H/docs/retro"; touch "$H/docs/retro/2026-11-03.md" "$H/docs/retro/2026-10-11.md" +python3 "$M" "$P" --harness "$H" --projects-root "$CR" --out "$J" +assert_eq "период с последнего ретро" "$(q 'd["period"]["since"], m["sessions"]')" "('2026-11-03', 2)" +# нет сессий +err=$(python3 "$M" "$P" --since 2030-01-01 --projects-root "$CR" 2>&1 >/dev/null); rc=$? +assert_eq "нет сессий: exit 1" "$rc" "1" +assert_grep "сообщение со slug" 'proj' <(printf '%s' "$err") +``` + +- [ ] **Step 2: Запустить — тест падает** + +Run: `bash tests/run.sh retro-metrics` +Expected: FAIL — `retro_metrics.py` нет. + +- [ ] **Step 3: Создать `docs/retro/scripts/retro_metrics.py`** + +```python +#!/usr/bin/env python3 +"""Срез метрик обвязки по транскриптам и памяти одного проекта или workspace (B12, #29). + +Запуск: python3 retro_metrics.py <папка проекта> [--since ГГГГ-ММ-ДД] [--harness <репозиторий обвязки>] + [--projects-root <каталог>] [--labels ] [--out ] + [--markdown [--prev <прошлый.metrics.json>]] [--corrections] +Без флагов вывода печатает JSON метрик (схема 1); --out пишет его в файл; --markdown печатает таблицу «Цифры» +со сравнением с --prev; --corrections — кандидаты в поправки владельца, по JSON-строке на кандидата. +Только чтение транскриптов и памяти. Спека: openspec/specs/harness-retro/spec.md. +""" +import argparse +import collections +import glob +import json +import os +import re +import shlex +import statistics +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +from hook_blocks_export import HOOK_ERROR, hook_of, redact # noqa: E402 + +INTERRUPT = "[Request interrupted by user" +OWNER_DENY = "The user doesn't want to proceed with this tool use" +CLASSIFIER = re.compile(r"denied by the Claude Code auto mode classifier\. Reason: \[([^\]]+)\]") +RECALL = re.compile(r"памят|перечитай|сверься|контекст|вспомни|прочитай .*md", re.I) +MARKERS = re.compile(r"(? os.path.getsize(best[sid]): + best[sid] = f + files = [] + for f in sorted(best.values()): + cwd, ts = first_meta(f) + if not cwd or not (inside(cwd, project) or inside(cwd, project + ".wt")): + continue + if since and (not ts or ts[:10] < since): + continue + files.append(f) + return sorted(set(os.path.basename(os.path.dirname(f)) for f in files)), files + + +def blocks(rec): + c = (rec.get("message") or {}).get("content") + if isinstance(c, str): + return [{"type": "text", "text": c}] + return [b for b in c if isinstance(b, dict)] if isinstance(c, list) else [] + + +def result_text(b): + c = b.get("content", "") + if isinstance(c, list): + return "\n".join(x.get("text", "") for x in c if isinstance(x, dict)) + return c if isinstance(c, str) else "" + + +def prompt_text(rec): + """Текст промпта владельца или None: служебное, tool_result, прерывание, сабагент.""" + if rec.get("type") != "user" or rec.get("isMeta") or rec.get("isSidechain"): + return None + if rec.get("userType", "external") != "external": + return None + bs = blocks(rec) + if any(b.get("type") == "tool_result" for b in bs): + return None + txt = "\n".join(b.get("text") or "" for b in bs if b.get("type") == "text").strip() + if not txt or txt.startswith("<") or txt.startswith(INTERRUPT): + return None + return txt + + +def is_interrupt(rec): + return rec.get("type") == "user" and any( + b.get("type") == "text" and (b.get("text") or "").startswith(INTERRUPT) for b in blocks(rec)) + + +def split_repo(path): + """(основной репозиторий, путь от корня worktree или репозитория) или (None, None).""" + p = os.path.normpath(path) if path else "" + m = re.match(r"^(.+?)/\.claude/worktrees/[^/]+/(.*)$", p) + if m: + return m.group(1), m.group(2) + m = re.match(r"^(.+)/\.wt/[^/]+/([^/]+)/(.*)$", p) + if m: + return m.group(1) + "/" + m.group(2), m.group(3) + m = re.match(r"^(.*[^/])\.wt/[^/]+/(.*)$", p) + if m: + return m.group(1), m.group(2) + d = os.path.dirname(p) + while d and d != os.path.dirname(d): + if os.path.exists(os.path.join(d, ".git")): + return d, os.path.relpath(p, d) + d = os.path.dirname(d) + return None, None + + +def code_prefixes(repo, cache): + if repo not in cache: + cp = os.path.join(repo, ".claude", "code-paths") + if os.path.isfile(cp): + pre = [l.split("#", 1)[0].strip().rstrip("/") for l in open(cp, encoding="utf-8", errors="replace")] + cache[repo] = [x for x in pre if x] + else: + cache[repo] = None + return cache[repo] + + +def is_code(path, cache): + repo, rel = split_repo(path) + if not repo: + return False + pre = code_prefixes(repo, cache) + if pre is None: + return "/src/" in "/" + rel + return any(rel == x or rel.startswith(x + "/") for x in pre) + + +def bash_reads_code(cmd, cwd, cache): + for part in SEPARATORS.split(cmd or ""): + try: + words = shlex.split(part) + except ValueError: + words = part.split() + if not words or not (words[0] in READERS or (words[0] == "sed" and "-n" in words[1:2])): + continue + for w in words[1:]: + if w.startswith("-") or not (os.path.isabs(w) or cwd): + continue + if is_code(w if os.path.isabs(w) else os.path.join(cwd, w), cache): + return True + return False + + +def action_of(b): + name, inp = b.get("name") or "?", b.get("input") or {} + if name == "Bash": + return "Bash: " + " ".join((inp.get("command") or "").split()) + if name in FILE_TOOLS: + return "%s: %s" % (name, inp.get("file_path") or inp.get("notebook_path") or "") + return name + + +def count_tool(s, b, cwd, cache): + name, inp = b.get("name") or "?", b.get("input") or {} + s["tools"][name] += 1 + if name in ("Agent", "Task"): + s["subagents"][inp.get("model") or "не указана"] += 1 + elif name == "Skill": + sk = inp.get("skill") or "?" + s["skills"][sk] += 1 + if s["role"] is None and (sk == "role" or sk.endswith(":role")): + s["role"] = (inp.get("args") or "").strip() or "?" + if "codegraph_explore" in name: + s["cg_explore"] += 1 + elif name == "Read": + s["code_reads"] += is_code(inp.get("file_path") or "", cache) + elif name in ("Grep", "Glob"): + s["code_reads"] += 1 + elif name == "Bash": + cmd = inp.get("command") or "" + s["cg_init"] += "codegraph init" in cmd + s["code_reads"] += bash_reads_code(cmd, cwd, cache) + + +def session_stats(path, cache, bad): + s = {"prompts": 0, "interrupts": 0, "turns": 0, "tools": collections.Counter(), + "subagents": collections.Counter(), "skills": collections.Counter(), "role": None, + "tokens": collections.Counter(), "compactions": 0, "start": None, "blocks": collections.Counter(), + "classifier": collections.Counter(), "owner_denials": 0, "cg_init": 0, "cg_explore": 0, + "code_reads": 0, "recall": 0, "last_ts": ""} + seen, pending = set(), {} + for rec in read_session(path, bad): + ts = rec.get("timestamp") + if isinstance(ts, str) and ts > s["last_ts"]: + s["last_ts"] = ts + t = rec.get("type") + if t == "system" and rec.get("subtype") == "compact_boundary": + s["compactions"] += 1 + elif t == "assistant": + msg = rec.get("message") or {} + mid = msg.get("id") + if mid is None or mid not in seen: + if mid is not None: + seen.add(mid) + s["turns"] += 1 + u = msg.get("usage") or {} + if u: + n = lambda k: u.get(k, 0) or 0 + if s["start"] is None: + s["start"] = n("input_tokens") + n("cache_creation_input_tokens") + n("cache_read_input_tokens") + s["tokens"]["output"] += n("output_tokens") + s["tokens"]["cache_read"] += n("cache_read_input_tokens") + s["tokens"]["cache_write"] += n("cache_creation_input_tokens") + for b in blocks(rec): + if b.get("type") == "tool_use": + pending[b.get("id")] = b.get("name") or "?" + count_tool(s, b, rec.get("cwd"), cache) + elif t == "user": + txt = prompt_text(rec) + if txt is not None: + s["prompts"] += 1 + s["recall"] += bool(RECALL.search(txt)) + elif is_interrupt(rec): + s["interrupts"] += 1 + for b in blocks(rec): + if b.get("type") != "tool_result": + continue + rt = result_text(b) + if HOOK_ERROR.match(rt) and "BLOCKED" in rt: + s["blocks"][hook_of(rt, pending.get(b.get("tool_use_id"), "?"))] += 1 + m = CLASSIFIER.search(rt) + if m: + s["classifier"][m.group(1)] += 1 + s["owner_denials"] += OWNER_DENY in rt + return s + + +def aggregate(stats): + total = lambda k: sum(s[k] for s in stats) + merged = lambda k: sum((s[k] for s in stats), collections.Counter()) + tools, blk, cls, sub = merged("tools"), merged("blocks"), merged("classifier"), merged("subagents") + tok = merged("tokens") + starts = [s["start"] for s in stats if s["start"] is not None] + explore, reads = total("cg_explore"), total("code_reads") + return { + "sessions": len(stats), "prompts": total("prompts"), "interrupts": total("interrupts"), + "turns": total("turns"), "tool_calls": sum(tools.values()), "tools": dict(tools.most_common()), + "roles": dict(collections.Counter(s["role"] or "не выбрана" for s in stats).most_common()), + "subagents": sum(sub.values()), "subagent_models": dict(sub.most_common()), + "skills": dict(merged("skills").most_common()), + "tokens": {k: tok.get(k, 0) for k in ("output", "cache_read", "cache_write")}, + "compactions": total("compactions"), + "start_context": {"median": int(statistics.median(starts)), "min": min(starts), "max": max(starts)} + if starts else None, + "hook_blocks": sum(blk.values()), "hook_blocks_by_hook": dict(blk.most_common()), + "classifier_denials": sum(cls.values()), "classifier_by_reason": dict(cls.most_common()), + "owner_denials": total("owner_denials"), + "codegraph": {"init": total("cg_init"), "explore": explore, "code_reads": reads, + "explore_share": round(explore / (explore + reads), 2) if explore + reads else None}, + "recall": total("recall"), + } + + +def memory_stats(root, project): + d = os.path.join(root, slug(project), "memory") + files = [f for f in glob.glob(os.path.join(d, "**", "*"), recursive=True) if os.path.isfile(f)] + idx = os.path.join(d, "MEMORY.md") + return {"index_bytes": os.path.getsize(idx) if os.path.isfile(idx) else 0, "files": len(files), + "bytes": sum(os.path.getsize(f) for f in files)} + + +def false_blocks(labels): + cases = labels.get("cases", {}) + lab = [c for c in cases.values() if "skip" not in c] + allow = [c for c in lab if c.get("expected") == "allow"] + known = [c for c in allow if c.get("known")] + return {"labelled": len(lab), "skipped": len(cases) - len(lab), "false": len(allow), + "share": round(len(allow) / len(lab), 2) if lab else None, "fixed": len(allow) - len(known), + "known": len(known)} + + +def corrections(path): + """Кандидаты в поправки владельца с последним действием и хвостом ответа агента перед ними.""" + out, act, reply, sid = [], "", "", os.path.basename(path)[:8] + for rec in read_session(path, {}): + t = rec.get("type") + if t == "assistant": + for b in blocks(rec): + if b.get("type") == "tool_use": + act = action_of(b) + elif b.get("type") == "text" and (b.get("text") or "").strip(): + reply = b["text"] + continue + if t != "user": + continue + txt, kind = prompt_text(rec), None + if txt is not None: + if len(txt) <= 2000 and MARKERS.search(txt[:200]): + kind = "prompt" + elif is_interrupt(rec): + kind, txt = "interrupt", "[Request interrupted by user]" + elif any(b.get("type") == "tool_result" and OWNER_DENY in result_text(b) for b in blocks(rec)): + kind, txt = "denial", "отказ владельца в разрешении" + if kind: + out.append({"session": sid, "ts": rec.get("timestamp") or "", "kind": kind, "text": redact(txt[:300]), + "action": redact(act[:300]), "reply_tail": redact(" ".join(reply.split())[-200:])}) + return out + + +ROWS = [ + ("sessions", "Сессий"), ("prompts", "Промптов владельца"), ("interrupts", "Прерываний"), + ("turns", "Ходов модели"), ("tool_calls", "Вызовов инструментов"), ("subagents", "Запусков сабагентов"), + ("tokens.output", "Токенов вывода"), ("tokens.cache_read", "Чтение из кэша, токенов"), + ("tokens.cache_write", "Запись в кэш, токенов"), ("compactions", "Компакций контекста"), + ("start_context.median", "Стартовый контекст, медиана"), ("start_context.min", "Стартовый контекст, мин"), + ("start_context.max", "Стартовый контекст, макс"), ("hook_blocks", "Блокировок хуков"), + ("false_blocks.false", "Ложных блокировок"), ("false_blocks.share", "Доля ложных блокировок"), + ("false_blocks.fixed", "Ложных, уже исправлено"), ("false_blocks.known", "Ложных, ещё не исправлено"), + ("classifier_denials", "Отказов классификатора"), ("owner_denials", "Отказов владельца"), + ("codegraph.init", "codegraph init"), ("codegraph.explore", "Вызовов codegraph_explore"), + ("codegraph.code_reads", "Чтений кода (Read/Grep/Glob/Bash)"), + ("codegraph.explore_share", "Доля codegraph_explore"), + ("corrections", "Кандидатов в поправки владельца"), ("recall", "Промптов «перечитай память/контекст»"), + ("memory.index_bytes", "MEMORY.md, байт"), ("memory.files", "Файлов памяти"), ("memory.bytes", "Память, байт"), + ("cd_main", "cd в основной checkout на main"), +] +BREAKDOWNS = [("tools", "Инструменты"), ("roles", "Роли"), ("subagent_models", "Сабагенты по моделям"), + ("skills", "Skills"), ("hook_blocks_by_hook", "Блокировки по хукам"), + ("classifier_by_reason", "Отказы классификатора")] + + +def get(m, key): + for k in key.split("."): + if not isinstance(m, dict) or k not in m: + return None + m = m[k] + return m + + +def _num(v): + return isinstance(v, (int, float)) and not isinstance(v, bool) + + +def fmt(v): + if v is None: + return "—" + if isinstance(v, float): + return "%.2f" % v + if _num(v): + return "{:,}".format(v).replace(",", " ") + return str(v) + + +def cell(label, cur, old): + if isinstance(cur, dict): + cur = None + if isinstance(old, dict) and "retired" in old: + return "| %s | %s | — | не считается: %s |" % (label, fmt(cur), old["retired"]) + if isinstance(old, dict) and "value" in old: + return "| %s | %s | %s (%s) | — |" % (label, fmt(cur), fmt(old["value"]), old.get("note", "иначе считалось")) + d = "—" + if _num(cur) and _num(old): + diff = cur - old + d = ("%+.2f" % diff) if isinstance(diff, float) else "{:+,}".format(diff).replace(",", " ") + return "| %s | %s | %s | %s |" % (label, fmt(cur), fmt(old), d) + + +def to_markdown(doc, prev): + m, pm = doc["metrics"], (prev or {}).get("metrics") or {} + out = ["| Показатель | Сейчас | Прошлое ретро | Δ |", "|---|---|---|---|"] + for key, label in ROWS: + cur, old = get(m, key), get(pm, key) + if cur is None and old is None: + continue + out.append(cell(label, cur, old)) + out.append("") + for key, label in BREAKDOWNS: + d = m.get(key) or {} + if d: + out.append("- %s: %s" % (label, ", ".join("%s %s" % (k, fmt(v)) for k, v in list(d.items())[:10]))) + src, per = doc.get("sources") or {}, doc.get("period") or {} + out += ["", "Период %s — %s; сессий %s; каталоги транскриптов: %s; битых строк %s." % ( + per.get("since") or "начало", per.get("until") or "—", src.get("sessions"), + ", ".join(src.get("dirs") or []) or "—", src.get("bad_lines"))] + return "\n".join(out) + + +def default_since(harness): + names = [os.path.basename(f)[:10] for f in glob.glob(os.path.join(harness, "docs", "retro", "*.md"))] + names = [n for n in names if re.match(r"^\d{4}-\d{2}-\d{2}$", n)] + return max(names) if names else None + + +def parse_args(argv): + cc = os.environ.get("CLAUDE_CONFIG_DIR") or os.path.expanduser("~/.claude") + p = argparse.ArgumentParser(prog="retro_metrics.py", description="Срез метрик обвязки (B12)") + p.add_argument("project") + p.add_argument("--since") + p.add_argument("--harness", default=os.path.normpath(os.path.join(HERE, "..", "..", ".."))) + p.add_argument("--projects-root", default=os.path.join(cc, "projects")) + p.add_argument("--labels") + p.add_argument("--out") + p.add_argument("--markdown", action="store_true") + p.add_argument("--prev") + p.add_argument("--corrections", action="store_true") + return p.parse_args(argv[1:]) + + +def main(argv): + a = parse_args(argv) + project = os.path.abspath(a.project) + since = a.since or default_since(a.harness) + dirs, files = project_sessions(project, a.projects_root, since) + bad, cache = {"n": 0}, {} + stats = [s for s in (session_stats(f, cache, bad) for f in files) if s["prompts"] or s["turns"]] + if not stats: + sys.stderr.write("retro_metrics: нет сессий проекта %s (slug %s) в %s с %s\n" + % (project, slug(project), a.projects_root, since or "начала")) + return 1 + cands = [c for f in files for c in corrections(f)] + if a.corrections: + for c in cands: + print(json.dumps(c, ensure_ascii=False)) + return 0 + m = aggregate(stats) + m["corrections"] = len(cands) + m["memory"] = memory_stats(a.projects_root, project) + if a.labels: + m["false_blocks"] = false_blocks(json.load(open(a.labels, encoding="utf-8"))) + doc = {"schema": 1, "period": {"since": since, "until": max(s["last_ts"] for s in stats)[:10]}, + "project": project, + "sources": {"dirs": dirs, "sessions": len(stats), "bytes": sum(os.path.getsize(f) for f in files), + "bad_lines": bad["n"]}, + "metrics": m} + if a.out: + with open(a.out, "w", encoding="utf-8") as fh: + json.dump(doc, fh, ensure_ascii=False, indent=2) + fh.write("\n") + if a.markdown: + print(to_markdown(doc, json.load(open(a.prev, encoding="utf-8")) if a.prev else None)) + elif not a.out: + print(json.dumps(doc, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) +``` + +- [ ] **Step 4: Запустить — тест зелёный** + +Run: `bash tests/run.sh retro-metrics` +Expected: `passed 36, failed 0`. + +- [ ] **Step 5: Прогон на этой машине (живая проверка, только чтение)** + +Run: `python3 docs/retro/scripts/retro_metrics.py /Users/andrejepifancev/Projects/cc-harness --since 2026-10-15 --markdown | head -40` +Expected: таблица «Цифры» с ненулевыми сессиями, промптами и вызовами `codegraph_explore` (эта сессия), без исключений. Числа не проверяются: важно, что формат транскриптов этой машины разбирается. + +- [ ] **Step 6: Весь набор и коммит** + +Run: `bash tests/run.sh` +Expected: `failed 0`. + +```bash +git add docs/retro/scripts/retro_metrics.py tests/test-retro-metrics.sh +git commit -m "feat(retro): retro_metrics.py — срез метрик, кандидаты в поправки владельца, сравнение с прошлым ретро (#29)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3: `retro_leftovers.py` — хвосты чеклиста закрытия + +**Files:** +- Create: `docs/retro/scripts/retro_leftovers.py` +- Test: `tests/test-retro-leftovers.sh` + +**Interfaces:** +- Consumes: — +- Produces: + - CLI `retro_leftovers.py <проект или workspace> [--fetch] [--markdown]`; + - JSON `{"repos": [...], "items": [{"kind", "repo", "item", "hint"}], "totals": {kind: n}}`; + - kind ∈ `remote-branch|local-branch|worktree|sdd-workspace|openspec-change|main-behind|main-dirty|no-origin-main`. + +- [ ] **Step 1: Тест на песочнице репозиториев** + +Создать `tests/test-retro-leftovers.sh`: + +```bash +# retro_leftovers.py: хвосты чеклиста закрытия по состоянию репозиториев (спека harness-retro, #29). +L="$ROOT/docs/retro/scripts/retro_leftovers.py" +G="$TMP/lo"; mkdir -p "$G" +git init -q --bare -b main "$G/o.git" +git clone -q "$G/o.git" "$G/c" 2>/dev/null; git -C "$G/c" symbolic-ref HEAD refs/heads/main +printf '.superpowers/\n.claude/worktrees/\n' > "$G/c/.gitignore" +git -C "$G/c" add .gitignore && git -C "$G/c" commit -q -m init && git -C "$G/c" push -q origin main 2>/dev/null +git -C "$G/c" switch -q -c feat/1 && git -C "$G/c" commit -q --allow-empty -m f1 && git -C "$G/c" push -q -u origin feat/1 2>/dev/null +git -C "$G/c" switch -q main && git -C "$G/c" merge -q --no-ff feat/1 -m "merge feat/1" +mkdir -p "$G/c/openspec/changes/foo" "$G/c/openspec/changes/archive/2026-01-01-bar" +echo x > "$G/c/openspec/changes/foo/proposal.md"; echo y > "$G/c/openspec/changes/archive/2026-01-01-bar/p.md" +git -C "$G/c" add openspec && git -C "$G/c" commit -q -m spec && git -C "$G/c" push -q origin main 2>/dev/null +git -C "$G/c" worktree add -q "$G/c/.claude/worktrees/w1" feat/1 +mkdir -p "$G/c/.superpowers/sdd/plan-x" && touch "$G/c/.superpowers/sdd/plan-x/progress.md" +git clone -q "$G/o.git" "$G/c2" 2>/dev/null && git -C "$G/c2" commit -q --allow-empty -m ahead && git -C "$G/c2" push -q origin main 2>/dev/null +git -C "$G/c" fetch -q origin +echo x > "$G/c/untracked.txt" +J="$TMP/lo.json" +python3 "$L" "$G/c" > "$J"; rc=$? +assert_eq "хвосты: exit 0" "$rc" "0" +k(){ python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(eval(sys.argv[2]))' "$J" "$1"; } +assert_eq "виды хвостов" "$(k 'sorted(d["totals"])')" "['local-branch', 'main-behind', 'main-dirty', 'openspec-change', 'remote-branch', 'sdd-workspace', 'worktree']" +assert_eq "ветка на сервере и подсказка" "$(k '[(i["item"], i["hint"]) for i in d["items"] if i["kind"] == "remote-branch"]')" "[('origin/feat/1', 'git push origin --delete feat/1')]" +assert_eq "локальная ветка" "$(k '[i["item"] for i in d["items"] if i["kind"] == "local-branch"]')" "['feat/1']" +assert_eq "worktree на слитой ветке" "$(k '[i["item"].endswith("/.claude/worktrees/w1") for i in d["items"] if i["kind"] == "worktree"]')" "[True]" +assert_eq "SDD workspace" "$(k '[i["item"].endswith("/sdd/plan-x") for i in d["items"] if i["kind"] == "sdd-workspace"]')" "[True]" +assert_eq "неархивный change — без archive" "$(k '[i["item"] for i in d["items"] if i["kind"] == "openspec-change"]')" "['openspec/changes/foo']" +assert_grep "отставание от origin/main" 'позади origin/main на 1' "$J" +assert_grep "грязное дерево с путём" 'untracked.txt' "$J" +# только чтение: без --fetch ссылки и дерево не меняются +git -C "$G/c2" commit -q --allow-empty -m ahead2 && git -C "$G/c2" push -q origin main 2>/dev/null +before="$(git -C "$G/c" for-each-ref)$(git -C "$G/c" status --porcelain)" +python3 "$L" "$G/c" > /dev/null +assert_eq "без --fetch ничего не меняется" "$(git -C "$G/c" for-each-ref)$(git -C "$G/c" status --porcelain)" "$before" +python3 "$L" "$G/c" --fetch > "$J" +assert_grep "--fetch видит новые коммиты" 'позади origin/main на 2' "$J" +md=$(python3 "$L" "$G/c" --markdown) +assert_grep "таблица для черновика" '^\| remote-branch \| c \| `origin/feat/1` \| `git push origin --delete feat/1` \|$' <(printf '%s' "$md") +# чистый репозиторий: свежая ветка на вершине origin/main — не хвост +git init -q --bare -b main "$G/k.git"; git clone -q "$G/k.git" "$G/k" 2>/dev/null; git -C "$G/k" symbolic-ref HEAD refs/heads/main +git -C "$G/k" commit -q --allow-empty -m init && git -C "$G/k" push -q origin main 2>/dev/null && git -C "$G/k" branch feat/new +python3 "$L" "$G/k" > "$J" +assert_eq "чистый репозиторий — хвостов нет" "$(k 'd["items"]')" "[]" +assert_eq "чистый репозиторий — markdown" "$(python3 "$L" "$G/k" --markdown)" "Хвостов закрытия нет." +# без origin +git init -q -b main "$G/n" && git -C "$G/n" commit -q --allow-empty -m init +python3 "$L" "$G/n" > "$J"; rc=$? +assert_eq "без origin: exit 0 и no-origin-main" "$rc $(k '[i["kind"] for i in d["items"]]')" "0 ['no-origin-main']" +# workspace: репозитории из таблицы CLAUDE.md +W="$TMP/ws"; mkdir -p "$W"; git clone -q "$G/o.git" "$W/r" 2>/dev/null +printf '# w\n\n| Каталог | Репозиторий | Роль |\n|---|---|---|\n| r/ | o/r | dev |\n| - | x | qa |\n' > "$W/CLAUDE.md" +python3 "$L" "$W" > "$J" +assert_eq "workspace: репозитории из таблицы" "$(k '[r.endswith("/ws/r") for r in d["repos"]]')" "[True]" +assert_eq "workspace: ветка на сервере видна" "$(k '[i["item"] for i in d["items"] if i["kind"] == "remote-branch"]')" "['origin/feat/1']" +# не проект и не workspace +mkdir -p "$TMP/empty"; python3 "$L" "$TMP/empty" > /dev/null 2>&1; rc=$? +assert_eq "не репозиторий и не workspace: exit 1" "$rc" "1" +``` + +- [ ] **Step 2: Запустить — тест падает** + +Run: `bash tests/run.sh retro-leftovers` +Expected: FAIL — `retro_leftovers.py` нет. + +- [ ] **Step 3: Создать `docs/retro/scripts/retro_leftovers.py`** + +```python +#!/usr/bin/env python3 +"""Хвосты чеклиста закрытия по текущему состоянию репозиториев проекта или workspace (B12, #29). + +Запуск: python3 retro_leftovers.py <папка проекта или workspace> [--fetch] [--markdown] +Репозитории: сам git-проект или каталоги из таблицы «| Каталог | Репозиторий | Роль |» в CLAUDE.md workspace. +Хвост: слитая в origin/main ветка на сервере или локально, worktree на ней или prunable, .superpowers/sdd/*, +неархивный openspec/changes// в origin/main, основной checkout позади origin/main или с изменениями. +Ничего не меняет; --fetch делает только git fetch --prune origin. Спека: openspec/specs/harness-retro/spec.md. +""" +import collections +import glob +import json +import os +import re +import subprocess +import sys + +TABLE = re.compile(r"^\|\s*Каталог\s*\|\s*Репозиторий\s*\|\s*Роль\s*\|") +PROTECTED = ("main", "master") + + +def git(repo, *args): + env = {k: v for k, v in os.environ.items() if k not in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE")} + r = subprocess.run(["git", "-C", repo] + list(args), stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + universal_newlines=True, env=env) + return r.stdout if r.returncode == 0 else None + + +def lines(s): + return [l.strip() for l in (s or "").splitlines() if l.strip()] + + +def repos_of(project): + project = os.path.abspath(project) + if os.path.exists(os.path.join(project, ".git")): + return [project] + md, out, on = os.path.join(project, "CLAUDE.md"), [], False + if not os.path.isfile(md): + return out + for line in open(md, encoding="utf-8", errors="replace"): + if TABLE.match(line): + on = True + continue + if not on: + continue + if not line.startswith("|"): + break + d = line.strip().strip("|").split("|")[0].strip() + if not d or set(d) <= set("-: "): + continue + p = os.path.join(project, d.rstrip("/")) + if os.path.exists(os.path.join(p, ".git")): + out.append(p) + return out + + +def worktrees(repo): + out, cur = [], {} + for l in (git(repo, "worktree", "list", "--porcelain") or "").splitlines() + [""]: + if not l.strip(): + if cur: + out.append(cur) + cur = {} + continue + k, _, v = l.partition(" ") + cur[k] = v or True + return out + + +def leftovers(repo, fetch=False): + items = [] + + def add(kind, item, hint): + items.append({"kind": kind, "repo": repo, "item": item, "hint": hint}) + + if fetch: + git(repo, "fetch", "--prune", "-q", "origin") + sha = lambda ref: (git(repo, "rev-parse", "-q", "--verify", ref) or "").strip() + tip = sha("origin/main") + if not tip: + add("no-origin-main", "origin/main", "нет origin/main: git fetch origin или проверить remote") + return items + for b in lines(git(repo, "branch", "-r", "--merged", "origin/main")): + if "->" in b or not b.startswith("origin/") or b[len("origin/"):] in PROTECTED + ("HEAD",): + continue + if sha(b) != tip: + add("remote-branch", b, "git push origin --delete " + b[len("origin/"):]) + stale = set() + for b in lines(git(repo, "branch", "--merged", "origin/main", "--format=%(refname:short)")): + if b not in PROTECTED and sha("refs/heads/" + b) != tip: + stale.add(b) + for l in lines(git(repo, "for-each-ref", "--format=%(refname:short) %(upstream:track)", "refs/heads")): + name, _, track = l.partition(" ") + if "gone" in track and name not in PROTECTED: + stale.add(name) + for b in sorted(stale): + add("local-branch", b, "git branch -D " + b) + wts = worktrees(repo) + main_wt = wts[0]["worktree"] if wts else repo + for w in wts[1:]: + br = str(w.get("branch") or "") + if br.startswith("refs/heads/"): + br = br[len("refs/heads/"):] + if w.get("prunable") or br in stale: + add("worktree", w["worktree"], "ExitWorktree, затем git worktree remove " + w["worktree"]) + for w in wts or [{"worktree": repo}]: + for d in sorted(glob.glob(os.path.join(w["worktree"], ".superpowers", "sdd", "*"))): + if os.path.isdir(d): + add("sdd-workspace", d, "rm -rf " + d) + for c in lines(git(repo, "ls-tree", "-d", "--name-only", "origin/main", "openspec/changes/")): + name = c.rstrip("/").rsplit("/", 1)[-1] + if name != "archive": + add("openspec-change", c, "openspec archive %s — отдельным PR" % name) + behind = (git(main_wt, "rev-list", "--count", "HEAD..origin/main") or "0").strip() + if behind not in ("", "0"): + add("main-behind", main_wt, "git pull --ff-only (позади origin/main на %s)" % behind) + dirty = [l for l in (git(main_wt, "status", "--porcelain") or "").splitlines() if l.strip()] + if dirty: + add("main-dirty", main_wt, "не трогать, перечислить: " + ", ".join(l[3:] for l in dirty[:5])) + return items + + +def to_markdown(items): + if not items: + return "Хвостов закрытия нет." + out = ["| Вид | Репозиторий | Что | Чем убрать |", "|---|---|---|---|"] + for i in items: + out.append("| %s | %s | `%s` | `%s` |" % (i["kind"], os.path.basename(i["repo"]), i["item"], i["hint"])) + return "\n".join(out) + + +def main(argv): + args = argv[1:] + pos = [a for a in args if not a.startswith("--")] + if not pos: + print(__doc__.strip().splitlines()[2]) + return 1 + repos = repos_of(pos[0]) + if not repos: + sys.stderr.write("retro_leftovers: в %s нет git-репозитория и таблицы репозиториев workspace\n" % pos[0]) + return 1 + items = [i for r in repos for i in leftovers(r, "--fetch" in args)] + if "--markdown" in args: + print(to_markdown(items)) + else: + print(json.dumps({"repos": repos, "items": items, + "totals": dict(collections.Counter(i["kind"] for i in items))}, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) +``` + +- [ ] **Step 4: Запустить — тест зелёный** + +Run: `bash tests/run.sh retro-leftovers` +Expected: `passed 18, failed 0`. + +- [ ] **Step 5: Прогон на этом репозитории (живая проверка, только чтение)** + +Run: `python3 docs/retro/scripts/retro_leftovers.py /Users/andrejepifancev/Projects/cc-harness --markdown` +Expected: таблица или «Хвостов закрытия нет.». Ветка `feat/29-harness-retro` хвостом не считается: её нет на сервере, и она не слита. + +- [ ] **Step 6: Весь набор и коммит** + +Run: `bash tests/run.sh` +Expected: `failed 0`. + +```bash +git add docs/retro/scripts/retro_leftovers.py tests/test-retro-leftovers.sh +git commit -m "feat(retro): retro_leftovers.py — хвосты чеклиста закрытия по состоянию репозиториев (#29)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 4: Skill `/harness-retro`, шаблон, метрики 11.10, документация + +**Files:** +- Create: `.claude/skills/harness-retro/SKILL.md`, `.claude/skills/harness-retro/template.md` +- Create: `docs/retro/2026-10-11.metrics.json` +- Delete: `docs/retro/scripts/retro_stats.py`, `docs/retro/scripts/retro_blocks.py` +- Modify: `CLAUDE.md`, `docs/HARNESS.md`, `README.md`, `CHANGELOG.md` +- Test: `tests/test-retro-skill.sh` + +**Interfaces:** +- Consumes: + - CLI задач 1–3; + - `retro_metrics.to_markdown(doc, prev)`; + - ключи `metrics` и формат `{"retired"}`/`{"value","note"}` (задача 2). +- Produces: — + +- [ ] **Step 1: Тест** + +Создать `tests/test-retro-skill.sh`: + +```bash +# /harness-retro: проектный skill, шаблон, метрики 11.10, удалённые скрипты (#29). +S="$ROOT/.claude/skills/harness-retro" +assert_file "SKILL.md" "$S/SKILL.md" +assert_grep "SKILL.md: name" '^name: harness-retro$' "$S/SKILL.md" +for step in 'retro_metrics\.py' 'hook_blocks_export\.py' 'hook_cases_build\.py' 'retro_leftovers\.py' '--corrections' 'EnterWorktree' 'tea pulls create'; do + assert_grep "SKILL.md упоминает $step" "$step" "$S/SKILL.md" +done +assert_file "template.md" "$S/template.md" +for sec in "## 1. Цифры" "## 2. Находки" "## 3. Поправки владельца" "## 4. Что сработало" "## 5. Бэклог" "## 6. Решения владельца" "## 7. Что не измерялось"; do + assert_grep "шаблон: $sec" "^$sec" "$S/template.md" +done +J="$ROOT/docs/retro/2026-10-11.metrics.json" +assert_eq "метрики 11.10: схема, cd снят, ложные" "$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); m=d["metrics"]; print(d["schema"], "retired" in m["cd_main"], m["false_blocks"]["false"], m["hook_blocks"])' "$J")" "1 True 72 76" +out=$(python3 - "$ROOT/docs/retro/scripts" "$J" <<'PY' +import json, sys +sys.path.insert(0, sys.argv[1]) +import retro_metrics +prev = json.load(open(sys.argv[2])) +doc = {"metrics": {"sessions": 3, "turns": 7, "hook_blocks": 1}, "period": {"since": "2026-10-11", "until": "2026-10-24"}, + "sources": {"sessions": 3, "dirs": [], "bad_lines": 0}} +print(retro_metrics.to_markdown(doc, prev)) +PY +) +assert_grep "сравнение с 11.10: сессии" '^\| Сессий \| 3 \| 44 \| -41 \|$' <(printf '%s' "$out") +assert_grep "сравнение с 11.10: блокировки" '^\| Блокировок хуков \| 1 \| 76 \| -75 \|$' <(printf '%s' "$out") +assert_grep "сравнение с 11.10: cd снят" '^\| cd в основной checkout на main \| — \| — \| не считается' <(printf '%s' "$out") +assert_grep "сравнение с 11.10: ходы считались по строкам" '^\| Ходов модели \| 7 \| 12 121 \(по строкам' <(printf '%s' "$out") +assert_eq "retro_stats.py удалён" "$([ -e "$ROOT/docs/retro/scripts/retro_stats.py" ] && echo есть || echo нет)" "нет" +assert_eq "retro_blocks.py удалён" "$([ -e "$ROOT/docs/retro/scripts/retro_blocks.py" ] && echo есть || echo нет)" "нет" +assert_eq "CLAUDE.md без ссылок на удалённые скрипты" "$(grep -c 'retro_stats\|retro_blocks' "$ROOT/CLAUDE.md")" "0" +assert_grep "CLAUDE.md: retro_metrics в командах" 'retro_metrics\.py' "$ROOT/CLAUDE.md" +assert_grep "HARNESS.md: раздел Ретро" '^## Ретро' "$ROOT/docs/HARNESS.md" +``` + +- [ ] **Step 2: Запустить — тест падает** + +Run: `bash tests/run.sh retro-skill` +Expected: FAIL — нет skill, шаблона и метрик 11.10, скрипты ещё на месте. + +- [ ] **Step 3: Создать `.claude/skills/harness-retro/SKILL.md`** + +~~~markdown +--- +name: harness-retro +description: Ретро обвязки claude-harness по транскриптам, памяти и репозиториям одного проекта или workspace — срез метрик, разметка блокировок хуков в корпус, хвосты закрытия, поправки владельца, черновик docs/retro/<дата>.md и PR. Запускать в сессии в репозитории обвязки по команде «/harness-retro <папка> [--since ГГГГ-ММ-ДД]» после среза. +--- + +# /harness-retro + +Аргумент — папка проекта или workspace, который изучаем. Сессия — в репозитории обвязки, роль dev. Дата ретро `` — сегодня, `ГГГГ-ММ-ДД`. Скрипты — `docs/retro/scripts/`, все команды — из корня worktree ретро. Спека — `openspec/specs/harness-retro/spec.md`. + +## Шаги + +1. **Задача и worktree.** Issue «Ретро : <проект>» (`tea issues create`). Затем `EnterWorktree` с именем `retro-`, ветка `git branch -m retro/`, `codegraph init` в worktree. +2. **Срез.** Выполнить `python3 docs/retro/scripts/retro_metrics.py <папка> [--since …] --out docs/retro/.metrics.json`. + - Exit 1 — сессий нет. Сообщить владельцу slug и период и остановиться. + - Битых строк больше 1 % — отметить в «Что не измерялось». +3. **Блокировки.** Сначала выгрузить: `python3 docs/retro/scripts/hook_blocks_export.py --since --id-prefix <каталоги из sources.dirs> > docs/retro/fixtures/hook-blocks-.jsonl`. Каталоги — `~/.claude/projects/<имя>`. + - Разметить каждую запись в `docs/retro/fixtures/hook-labels-.json`: + - `expected` (`allow|block`) и `why` по спеке `hook-guards`; + - раскладку песочницы — по `gitBranch` сессий и выводу `git worktree list` и `git branch` в транскриптах; + - если не восстановить — `skip` с причиной. + - Собрать корпус: `python3 docs/retro/scripts/hook_cases_build.py > skills/harness-doctor/fixtures/hook-cases.jsonl` и прогнать `bash tests/run.sh hook-corpus`. Если текущий хук даёт не тот вердикт — проставить записи `"known": "RT-n"` и завести находку. + - Пересчитать срез с `--labels docs/retro/fixtures/hook-labels-.json --out docs/retro/.metrics.json`. +4. **Хвосты закрытия.** `python3 docs/retro/scripts/retro_leftovers.py <папка> --fetch --markdown`. Ничего не удалять: таблица идёт в черновик, уборка — по решению владельца. +5. **Поправки владельца.** `python3 docs/retro/scripts/retro_metrics.py <папка> [--since …] --corrections`. + - Отобрать только поправки по процессу: пропущенный шаг, лишний вопрос, неверный совет, обход правила, расширение задачи. Вопросы и просьбы по содержанию выбросить. + - Сгруппировать по видам. + - Вид, который повторяется (2 раза и больше) или стоил владельцу заметного времени, — находка `RT-n` и пункт бэклога. +6. **Качественная часть.** Прочитать память проекта целиком (`~/.claude/projects//memory/`) и примеры из выгрузок. Находки `RT-n` — сквозная нумерация: максимум в прошлых `docs/retro/*.md` плюс 1. Приоритет: P1 — теряет время каждую неделю или ломает процесс; P2 — трение; P3 — наблюдение. +7. **Черновик.** Скопировать `.claude/skills/harness-retro/template.md` в `docs/retro/.md` и заполнить. + - «Цифры»: `python3 docs/retro/scripts/retro_metrics.py <папка> [--since …] --labels … --markdown --prev docs/retro/<прошлое>.metrics.json`. + - Секретов в черновике быть не должно. Цитаты брать только из вывода скриптов: он уже прошёл `redact`. +8. **PR.** `bash tests/run.sh` должен быть зелёным. Коммит: черновик, `metrics.json`, выгрузка, разметка, корпус. Затем `git push -u origin retro/` и `tea pulls create` со ссылкой на issue. Merge — только владелец. + +## Не делать + +- Не удалять хвосты и не чинить хуки в PR ретро — это находки и бэклог. +- Не заполнять раздел «Решения владельца»: он пишется на ревью PR. +- Не вставлять в черновик сырые куски транскриптов мимо скриптов. +~~~ + +- [ ] **Step 4: Создать `.claude/skills/harness-retro/template.md`** + +~~~markdown +# Ретроспектива обвязки claude-harness — <ДД.ММ.ГГГГ> + +Период: –, полигон — <проект или workspace>. +Предыдущая точка сравнения — `docs/retro/<прошлое>.md`. + +Источники: +- транскриптов сессий (<каталоги из sources.dirs>, <объём>); +- файлов auto-memory (<объём>); +- состояние репозиториев (`retro_leftovers.py`); +- блокировки среза, размеченные в корпус: `docs/retro/fixtures/hook-labels-.json`. + +Количественную часть считал `docs/retro/scripts/retro_metrics.py`, качественную — по прочтению памяти целиком и кандидатов в поправки. + +Маркировка находок: `RT-n`, продолжение нумерации прошлых ретро. Приоритет: P1 — теряет время каждую неделю или ломает процесс; P2 — трение; P3 — наблюдение. + +## 1. Цифры + +<таблица из retro_metrics.py --markdown --prev> + +Хвосты чеклиста закрытия: + +<таблица из retro_leftovers.py --markdown> + +## 2. Находки + +**RT- (P). <Одна фраза — что происходит>.** <Факты: где, сколько раз, чем стоило>. Источники: <сессии, память, метрика>. + +## 3. Поправки владельца + +| Вид | Сколько | Пример (цитата, что было до неё) | Что вышло | Находка | +|---|---|---|---|---| + +## 4. Что сработало (сохранить) + +- <…> + +## 5. Бэклог улучшений (по приоритету) + +| # | Что | Находки | Оценка | +|---|---|---|---| + +## 6. Решения владельца (<дата>) + +Заполняется на ревью PR. + +## 7. Что не измерялось + +- Сторона Gitea чеклиста закрытия: открытые issue после слитого PR, пропущенные follow-up. +- <…> +~~~ + +- [ ] **Step 5: Создать `docs/retro/2026-10-11.metrics.json`** + +```json +{ + "schema": 1, + "period": {"since": "2026-10-02", "until": "2026-10-11"}, + "project": "/Users/andrey.epifancev/projects/awp-ca", + "sources": { + "dirs": ["-Users-andrey-epifancev-projects-awp-ca"], + "sessions": 44, + "bytes": 881852416, + "bad_lines": null, + "note": "перенесено вручную из таблицы «Цифры» docs/retro/2026-10-11.md (#29); 47 транскриптов, 841 МБ" + }, + "metrics": { + "sessions": 44, + "prompts": 613, + "interrupts": null, + "turns": {"value": 12121, "note": "по строкам транскрипта, не по сообщениям"}, + "tool_calls": 6583, + "tools": {"Bash": 4302, "Playwright": 1100, "Read": 346, "Write": 271, "Edit": 233}, + "roles": {"dev": 32, "admin": 7, "qa": 5}, + "subagents": 61, + "subagent_models": {"sonnet": 47, "opus": 9, "не указана": 5}, + "skills": {"brainstorming": 19, "writing-plans": 14, "systematic-debugging": 8, "subagent-driven-development": 4, + "executing-plans": 3, "using-git-worktrees": 3, "test-driven-development": 1, + "finishing-a-development-branch": 1}, + "tokens": { + "output": {"value": 18300000, "note": "по строкам транскрипта"}, + "cache_read": {"value": 3820000000, "note": "по строкам транскрипта"}, + "cache_write": {"value": 145000000, "note": "по строкам транскрипта"} + }, + "compactions": 4, + "start_context": {"median": 56000, "min": 52000, "max": 61000}, + "hook_blocks": 76, + "hook_blocks_by_hook": null, + "false_blocks": {"labelled": 76, "skipped": 0, "false": 72, "share": 0.95, "fixed": 72, "known": 0}, + "classifier_denials": 6, + "classifier_by_reason": {"Git Destructive": 2, "Auto-Mode Bypass": 2, "Interfere With Workloads": 1, "safety-check": 1}, + "owner_denials": 6, + "codegraph": {"init": 43, "explore": 15, "code_reads": null, "explore_share": null}, + "corrections": null, + "recall": 9, + "memory": {"index_bytes": null, "files": 47, "bytes": 183296}, + "cd_main": {"retired": "хуки 0.4.0 не зависят от cwd (RT-2); считалось по именам репозиториев awp-ca, было 84"} + } +} +``` + +- [ ] **Step 6: Удалить старые скрипты** + +Run: `git rm -q docs/retro/scripts/retro_stats.py docs/retro/scripts/retro_blocks.py` +Expected: файлы удалены из индекса и с диска. + +- [ ] **Step 7: Документация** + +**`CLAUDE.md`, раздел «Архитектура»**, в строке про `docs/HARNESS.md` фразу + +`` `docs/retro/` — ретроспектива 11.10.2026 и скрипты подсчёта по транскриптам. `` + +заменить на + +`` `docs/retro/` — ретроспективы, их метрики (`<дата>.metrics.json`) и скрипты: `retro_metrics.py` (срез метрик и поправки владельца), `retro_leftovers.py` (хвосты закрытия), `hook_blocks_export.py` и `hook_cases_build.py` (блокировки → корпус); проектный skill `/harness-retro` — `.claude/skills/harness-retro/`. `` + +**`CLAUDE.md`, раздел «Команды»**, строку + +`` - `python3 docs/retro/scripts/retro_stats.py <каталог транскриптов>` и `retro_blocks.py` — статистика сессий и блокировок хуков для ретро; `hook_blocks_export.py <каталог> [...]` — выгрузка блокировок в JSONL (сырьё для тестов хуков); `hook_cases_build.py > skills/harness-doctor/fixtures/hook-cases.jsonl` — сборка размеченного корпуса из выгрузки. `` + +заменить на + +`` - `/harness-retro <папка проекта> [--since ГГГГ-ММ-ДД]` — ретро обвязки (в сессии в этом репозитории). Под ним: `python3 docs/retro/scripts/retro_metrics.py <папка> [--since …] [--out …] [--markdown --prev …] [--corrections]` — срез метрик; `retro_leftovers.py <папка> [--fetch] [--markdown]` — хвосты закрытия; `hook_blocks_export.py [--since …] [--id-prefix …] <каталог> [...]` — выгрузка блокировок в JSONL; `hook_cases_build.py > skills/harness-doctor/fixtures/hook-cases.jsonl` — сборка корпуса из разметки. `` + +**`CLAUDE.md`, раздел «Тесты»**, в строке «Хуки: …» фразу `` `test-hook-export` — экстрактор. `` заменить на + +`` `test-hook-export` — экстрактор. Ретро: `test-retro-metrics` (синтетические транскрипты), `test-retro-leftovers` (песочница репозиториев), `test-retro-skill`. `` + +**`CLAUDE.md`, раздел «Текущая работа»**, строку `- Дальше: B12.` заменить на + +```markdown +- Сделано: B12 — `/harness-retro`: срез метрик, поправки владельца, хвосты закрытия, корпус по сегментам (issue #29, 0.6.0), спека — `openspec/specs/harness-retro/`. +- Дальше: первое ретро `/harness-retro` по полигону — на машине с awp-ca; остальные пункты бэклога — по мере надобности. +``` + +**`docs/HARNESS.md`**: перед строкой `## Стоимость` вставить + +```markdown +## Ретро + +Регулярное ретро — `/harness-retro <папка проекта или workspace> [--since ГГГГ-ММ-ДД]`, проектный skill этого репозитория (`.claude/skills/harness-retro/`). Запускается в сессии в репозитории обвязки по команде владельца после среза и идёт по циклу фичи: issue, worktree, PR. Результат — черновик `docs/retro/<дата>.md` по шаблону и `docs/retro/<дата>.metrics.json`. + +- `retro_metrics.py` — срез метрик: + - сессии, промпты, инструменты, роли, сабагенты, токены, стартовый контекст; + - блокировки и отказы; + - codegraph против чтения кода; + - память. + + Сравнение с прошлым ретро — таблица «Цифры». Кандидаты в поправки владельца по процессу — `--corrections`. +- Блокировки среза агент размечает по спеке `hook-guards` в `docs/retro/fixtures/hook-labels-<дата>.json`, и они становятся сегментом регрессионного корпуса. Запись с `known` — известная ошибка хуков; doctor показывает их число. +- `retro_leftovers.py` — хвосты чеклиста закрытия по состоянию репозиториев. Он только подсказывает, как убрать; убирает владелец. + +``` + +**`README.md`**, в блоке «Файлы» после строки `tests/run.sh тесты пакета (bash): bash tests/run.sh` добавить + +``` +.claude/skills/harness-retro/ проектный skill /harness-retro: ретро обвязки по транскриптам проекта (запуск из этого репозитория) +docs/retro/scripts/ retro_metrics.py (срез метрик), retro_leftovers.py (хвосты закрытия), выгрузка и разметка блокировок +``` + +**`CHANGELOG.md`**: после строки `# Changelog` и пустой строки вставить + +```markdown +## 0.6.0 — 2026-10-24 +- **`/harness-retro`** (B12, #29) — проектный skill регулярного ретро обвязки. Работает по циклу фичи: issue, worktree, PR. Результат — черновик `docs/retro/<дата>.md` по шаблону и `<дата>.metrics.json` со сравнением с прошлым ретро. +- `docs/retro/scripts/retro_metrics.py` считает срез метрик проекта по транскриптам и памяти: + - сессии, промпты, инструменты, роли, сабагенты, skills, токены (по `message.id`), компакции, стартовый контекст; + - блокировки хуков, отказы классификатора и владельца; + - codegraph против чтения кода (решение владельца 3); + - «перечитай», память; + - кандидаты в поправки владельца по процессу (`--corrections`). + + Заменяет `retro_stats.py` и `retro_blocks.py`. +- `docs/retro/scripts/retro_leftovers.py` ищет хвосты чеклиста закрытия по состоянию репозиториев: слитые ветки на сервере и локально, worktree, SDD workspace, неархивные changes, отставший или грязный main. +- Корпус хуков разбит на сегменты со своей раскладкой; записи `known` — известные ошибки хуков (`KNOWN-FIXED` — пора снять пометку), doctor показывает их число. `hook_blocks_export.py --since --id-prefix` выгружает блокировки среза, `hook_cases_build.py` собирает корпус из `hook-labels-<дата>.json`. +- `docs/retro/2026-10-11.metrics.json` — метрики ретро 11.10, перенесённые из таблицы. Блокировки — 76 ошибок хука, из них 72 ложные (O-1). Ходы и токены 11.10 считались по строкам и помечены как несравнимые. + +``` + +- [ ] **Step 8: Запустить тест задачи и весь набор** + +Run: `bash tests/run.sh retro-skill && bash tests/run.sh` +Expected: везде `failed 0`. + +- [ ] **Step 9: Commit** + +```bash +git add .claude/skills/harness-retro docs/retro/2026-10-11.metrics.json CLAUDE.md docs/HARNESS.md README.md CHANGELOG.md tests/test-retro-skill.sh +git commit -m "feat(retro): skill /harness-retro, шаблон ретро, метрики 11.10; документация 0.6.0 (#29)" -m "Co-Authored-By: Claude Opus 5.5 " +``` diff --git a/openspec/changes/harness-retro/design.md b/openspec/changes/harness-retro/design.md index 7a5ebfa..913aa58 100644 --- a/openspec/changes/harness-retro/design.md +++ b/openspec/changes/harness-retro/design.md @@ -83,7 +83,9 @@ Bash-команда считается чтением кода, если хот ### 5. Блокировки, отказы, роли - Блокировка: результат инструмента совпадает с `^\s*PreToolUse:\S+ hook error` и содержит `BLOCKED`. Хук определяется так же, как в `hook_blocks_export.hook_of`, функция импортируется. - Отказ классификатора: `denied by the Claude Code auto mode classifier. Reason: \[([^\]]+)\]`. -- Отказ владельца: результат инструмента начинается с `The user doesn't want to proceed with this tool use` или содержит `[Request interrupted by user for tool use]`. +- Отказ владельца: результат инструмента содержит `The user doesn't want to proceed with this tool use`. Прерывание (`[Request interrupted by user…`) считается отдельной метрикой, а не отказом. +- Ход модели — уникальный `message.id` ассистента. Claude Code пишет одно сообщение несколькими строками с общим `id` и одинаковым `usage` (проверено на транскриптах этой машины: 538 id на 1381 строку). Поэтому токены считаются один раз на `id`. Строки без `id` считаются по одной. +- Сессия — файл `.jsonl`. Транскрипт переезжает в каталог worktree (запись `relocated`); если один id встретился в нескольких каталогах, берётся самый большой файл. - Роль: первый `tool_use` с `name: Skill` и `input.skill` = `role` (или `…:role`), аргумент — `input.args`. ### 6. Кандидаты в поправки @@ -171,4 +173,6 @@ Bash-команда считается чтением кода, если хот | 2026-10-24 | Метрика `cd` в main — `retired` | RT-2 снят хуками 0.4.0; счёт был по именам репозиториев awp-ca | Потеря одного ряда сравнения | | 2026-10-24 | Блокировки 11.10 в `metrics.json` — 76 ошибок хука, 72 ложных (по корпусу), а не 103 из таблицы | 103 завышено выводами со словом `BLOCKED` (O-1) | Сравнение с таблицей 11.10 по этой строке расходится с документом | | 2026-10-24 | `retro_stats.py` и `retro_blocks.py` удаляются | Их заменяет `retro_metrics.py`; в ретро 11.10 они упомянуты как история | Чтобы воспроизвести 11.10 буква в букву, нужен git history | +| 2026-10-24 | Ходы и токены — по уникальному `message.id`, а не по строкам транскрипта | Строки одного сообщения повторяют `usage`; подсчёт по строкам завышает токены в 2–3 раза | Ходы и токены 11.10 (по строкам) несравнимы; в `2026-10-11.metrics.json` они помечены | +| 2026-10-24 | Свежая ветка, совпадающая с `origin/main`, — не хвост | `--merged` считает слитой и ветку без коммитов, например только что созданный worktree | Брошенная пустая ветка не попадёт в хвосты | | 2026-10-24 | Сторона Gitea в хвостах не проверяется | Хрупко (формат описаний PR и issue); агент видит это при чтении | Незакрытая issue после merge не попадёт в метрику | From 4d52ef773daa9daa0bd52aecc7cb00e9269c0b54 Mon Sep 17 00:00:00 2001 From: "andrey.epifantsev" Date: Sat, 24 Oct 2026 01:17:22 +0400 Subject: [PATCH 03/10] =?UTF-8?q?feat(retro):=20=D0=BA=D0=BE=D1=80=D0=BF?= =?UTF-8?q?=D1=83=D1=81=20=D1=85=D1=83=D0=BA=D0=BE=D0=B2=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D1=81=D0=B5=D0=B3=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D0=BC=20=D0=B8?= =?UTF-8?q?=20known,=20=D0=B2=D1=8B=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8=D1=80=D0=BE=D0=B2=D0=BE=D0=BA=20?= =?UTF-8?q?=D0=B7=D0=B0=20=D0=BF=D0=B5=D1=80=D0=B8=D0=BE=D0=B4=20(#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/retro/scripts/hook_blocks_export.py | 121 +++++++++++++------ docs/retro/scripts/hook_cases_build.py | 47 +++++-- skills/harness-doctor/scripts/doctor.sh | 2 +- skills/harness-doctor/scripts/hook_corpus.py | 74 ++++++++---- tests/test-hook-corpus.sh | 52 +++++++- tests/test-hook-export.sh | 10 ++ 6 files changed, 232 insertions(+), 74 deletions(-) diff --git a/docs/retro/scripts/hook_blocks_export.py b/docs/retro/scripts/hook_blocks_export.py index 96b47ef..3183d54 100644 --- a/docs/retro/scripts/hook_blocks_export.py +++ b/docs/retro/scripts/hook_blocks_export.py @@ -1,14 +1,19 @@ #!/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 (файлы памяти, исходники хуков), не попадает. -Похожие на секреты подстроки заменяются на . +--since — только сессии с первой меткой времени не раньше даты; --id-prefix (по умолчанию — дата --since) — +поле id вида <префикс>-NNN (срез ретро, B12). Похожие на секреты подстроки заменяются на . """ -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") @@ -42,38 +47,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: +def _records(path): + for line in open(path, encoding="utf-8", errors="replace"): + try: + j = json.loads(line) + except ValueError: + continue + if isinstance(j, dict): + yield j + + +def first_ts(path): + for j in _records(path): + if isinstance(j.get("timestamp"), str) and j["timestamp"]: + return j["timestamp"] + return "" + + +def export(dirs, since=None, prefix=None): + """Записи выгрузки по каталогам транскриптов, по порядку файлов и строк.""" + n = 0 + for d in dirs: + for f in sorted(glob.glob(os.path.join(os.path.expanduser(d), "*.jsonl"))): + if since and first_ts(f)[:10] < since: continue - 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)) + sid = os.path.basename(f)[:8] + pending = {} # tool_use_id -> (name, input, cwd, branch, ts) + for j in _records(f): + m = j.get("message") or {} + cont = m.get("content") + if j.get("type") == "assistant": + for b in cont or []: + if isinstance(b, dict) and b.get("type") == "tool_use": + pending[b.get("id")] = (b.get("name"), b.get("input") or {}, j.get("cwd", ""), + j.get("gitBranch", ""), j.get("timestamp", "")) + elif j.get("type") == "user" and isinstance(cont, list): + for b in cont: + if not (isinstance(b, dict) and b.get("type") == "tool_result"): + continue + txt = b.get("content", "") + if isinstance(txt, list): + txt = "\n".join(x.get("text", "") for x in txt if isinstance(x, dict)) + if not isinstance(txt, str) or not HOOK_ERROR.match(txt): + continue # обычный вывод команды, даже если в нём есть слово BLOCKED + name, inp, cwd, branch, ts = pending.get(b.get("tool_use_id"), ("?", {}, "", "", "")) + if name not in ("Bash", "Edit", "Write", "MultiEdit"): + continue + msg = next((l for l in txt.splitlines() if "BLOCKED" in l), "") + rec = { + "session": sid, "ts": ts, "cwd": cwd, "branch": branch, "tool": name, + "command": redact(inp.get("command", "")) if name == "Bash" else None, + "file_path": inp.get("file_path") if name != "Bash" else None, + "hook": hook_of(txt, name), "message": redact(msg[:300]), "expected": None, + } + if prefix: + n += 1 + rec = dict({"id": "%s-%03d" % (prefix, n)}, **rec) + yield rec + + +def main(argv): + args, dirs, since, prefix = argv[1:], [], None, None + i = 0 + while i < len(args): + if args[i] == "--since" and i + 1 < len(args): + since, i = args[i + 1], i + 2 + elif args[i] == "--id-prefix" and i + 1 < len(args): + prefix, i = args[i + 1], i + 2 + else: + dirs.append(args[i]) + i += 1 + for rec in export(dirs, since, prefix or since): + print(json.dumps(rec, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/docs/retro/scripts/hook_cases_build.py b/docs/retro/scripts/hook_cases_build.py index 392ad2c..e63cf0a 100644 --- a/docs/retro/scripts/hook_cases_build.py +++ b/docs/retro/scripts/hook_cases_build.py @@ -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 +Вход: /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,35 @@ 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 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 +117,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) diff --git a/skills/harness-doctor/scripts/doctor.sh b/skills/harness-doctor/scripts/doctor.sh index a9d8da5..baedccb 100755 --- a/skills/harness-doctor/scripts/doctor.sh +++ b/skills/harness-doctor/scripts/doctor.sh @@ -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 diff --git a/skills/harness-doctor/scripts/hook_corpus.py b/skills/harness-doctor/scripts/hook_corpus.py index 5457ed5..35e728d 100644 --- a/skills/harness-doctor/scripts/hook_corpus.py +++ b/skills/harness-doctor/scripts/hook_corpus.py @@ -2,11 +2,12 @@ """Регрессионный корпус хуков (B2): прогон fixtures/hook-cases.jsonl через guard-main и require-plan в песочнице. Запуск: python3 hook_corpus.py <каталог хуков> [--summary] [--fixtures ] -Песочница строится во временном каталоге по раскладке из первой строки корпуса; префикс домашнего каталога записей -заменяется на её home, HOME хуков подменяется туда же. Bash идёт через guard-main, Edit/Write — через guard-main и -require-plan (блок, если блокирует любой). -Вывод: строка на запись «idwhyожидалосьполученокоманда» и итог «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). Вывод: строка «idwhyожидалосьполученостатускоманда» и итог «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) diff --git a/tests/test-hook-corpus.sh b/tests/test-hook-corpus.sh index b3d5d0b..aee9b52 100644 --- a/tests/test-hook-corpus.sh +++ b/tests/test-hook-corpus.sh @@ -4,7 +4,55 @@ 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") diff --git a/tests/test-hook-export.sh b/tests/test-hook-export.sh index 46a7caa..4de0460 100644 --- a/tests/test-hook-export.sh +++ b/tests/test-hook-export.sh @@ -13,3 +13,13 @@ 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" +out=$(python3 "$X" --since 2026-11-01 --id-prefix 2026-11-05 "$T2") +assert_eq "--since: только сессия не раньше даты" "$(printf '%s\n' "$out" | grep -c .)" "1" +assert_eq "--id-prefix: id <дата>-NNN" "$(printf '%s\n' "$out" | python3 -c 'import json,sys; r=json.loads(sys.stdin.readline()); print(r["id"], r["command"])')" "2026-11-05-001 echo b > f" +assert_eq "без флагов поля id нет (выгрузка 12.10 не меняется)" "$(python3 "$X" "$T2" | python3 -c 'import json,sys; print(sum("id" in json.loads(l) for l in sys.stdin))')" "0" +assert_eq "импорт модуля не выгружает" "$(python3 -c "import sys; sys.argv=['x', '$T2']; sys.path.insert(0, '$ROOT/docs/retro/scripts'); import hook_blocks_export" 2>&1)" "" From dde575673d6c6a3d919e52c967096ddc53275a26 Mon Sep 17 00:00:00 2001 From: "andrey.epifantsev" Date: Sat, 24 Oct 2026 01:21:53 +0400 Subject: [PATCH 04/10] =?UTF-8?q?feat(retro):=20retro=5Fmetrics.py=20?= =?UTF-8?q?=E2=80=94=20=D1=81=D1=80=D0=B5=D0=B7=20=D0=BC=D0=B5=D1=82=D1=80?= =?UTF-8?q?=D0=B8=D0=BA,=20=D0=BA=D0=B0=D0=BD=D0=B4=D0=B8=D0=B4=D0=B0?= =?UTF-8?q?=D1=82=D1=8B=20=D0=B2=20=D0=BF=D0=BE=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=B2=D0=BB=D0=B0=D0=B4=D0=B5=D0=BB=D1=8C=D1=86?= =?UTF-8?q?=D0=B0,=20=D1=81=D1=80=D0=B0=D0=B2=D0=BD=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D1=81=20=D0=BF=D1=80=D0=BE=D1=88=D0=BB=D1=8B=D0=BC=20?= =?UTF-8?q?=D1=80=D0=B5=D1=82=D1=80=D0=BE=20(#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Синтетические сообщения об ошибке API (model ) не считаются ходом модели (O-18, живой прогон). Co-Authored-By: Claude Opus 5.5 --- docs/retro/scripts/retro_metrics.py | 482 +++++++++++++++++++++++ openspec/changes/harness-retro/design.md | 1 + tests/test-retro-metrics.sh | 132 +++++++ 3 files changed, 615 insertions(+) create mode 100644 docs/retro/scripts/retro_metrics.py create mode 100644 tests/test-retro-metrics.sh diff --git a/docs/retro/scripts/retro_metrics.py b/docs/retro/scripts/retro_metrics.py new file mode 100644 index 0000000..84cceea --- /dev/null +++ b/docs/retro/scripts/retro_metrics.py @@ -0,0 +1,482 @@ +#!/usr/bin/env python3 +"""Срез метрик обвязки по транскриптам и памяти одного проекта или workspace (B12, #29). + +Запуск: python3 retro_metrics.py <папка проекта> [--since ГГГГ-ММ-ДД] [--harness <репозиторий обвязки>] + [--projects-root <каталог>] [--labels ] [--out ] + [--markdown [--prev <прошлый.metrics.json>]] [--corrections] +Без флагов вывода печатает JSON метрик (схема 1); --out пишет его в файл; --markdown печатает таблицу «Цифры» +со сравнением с --prev; --corrections — кандидаты в поправки владельца, по JSON-строке на кандидата. +Только чтение транскриптов и памяти. Спека: openspec/specs/harness-retro/spec.md. +""" +import argparse +import collections +import glob +import json +import os +import re +import shlex +import statistics +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +from hook_blocks_export import HOOK_ERROR, hook_of, redact # noqa: E402 + +INTERRUPT = "[Request interrupted by user" +OWNER_DENY = "The user doesn't want to proceed with this tool use" +CLASSIFIER = re.compile(r"denied by the Claude Code auto mode classifier\. Reason: \[([^\]]+)\]") +RECALL = re.compile(r"памят|перечитай|сверься|контекст|вспомни|прочитай .*md", re.I) +MARKERS = re.compile(r"(? os.path.getsize(best[sid]): + best[sid] = f + files = [] + for f in sorted(best.values()): + cwd, ts = first_meta(f) + if not cwd or not (inside(cwd, project) or inside(cwd, project + ".wt")): + continue + if since and (not ts or ts[:10] < since): + continue + files.append(f) + return sorted(set(os.path.basename(os.path.dirname(f)) for f in files)), files + + +def blocks(rec): + c = (rec.get("message") or {}).get("content") + if isinstance(c, str): + return [{"type": "text", "text": c}] + return [b for b in c if isinstance(b, dict)] if isinstance(c, list) else [] + + +def result_text(b): + c = b.get("content", "") + if isinstance(c, list): + return "\n".join(x.get("text", "") for x in c if isinstance(x, dict)) + return c if isinstance(c, str) else "" + + +def prompt_text(rec): + """Текст промпта владельца или None: служебное, tool_result, прерывание, сабагент.""" + if rec.get("type") != "user" or rec.get("isMeta") or rec.get("isSidechain"): + return None + if rec.get("userType", "external") != "external": + return None + bs = blocks(rec) + if any(b.get("type") == "tool_result" for b in bs): + return None + txt = "\n".join(b.get("text") or "" for b in bs if b.get("type") == "text").strip() + if not txt or txt.startswith("<") or txt.startswith(INTERRUPT): + return None + return txt + + +def is_interrupt(rec): + return rec.get("type") == "user" and any( + b.get("type") == "text" and (b.get("text") or "").startswith(INTERRUPT) for b in blocks(rec)) + + +def split_repo(path): + """(основной репозиторий, путь от корня worktree или репозитория) или (None, None).""" + p = os.path.normpath(path) if path else "" + m = re.match(r"^(.+?)/\.claude/worktrees/[^/]+/(.*)$", p) + if m: + return m.group(1), m.group(2) + m = re.match(r"^(.+)/\.wt/[^/]+/([^/]+)/(.*)$", p) + if m: + return m.group(1) + "/" + m.group(2), m.group(3) + m = re.match(r"^(.*[^/])\.wt/[^/]+/(.*)$", p) + if m: + return m.group(1), m.group(2) + d = os.path.dirname(p) + while d and d != os.path.dirname(d): + if os.path.exists(os.path.join(d, ".git")): + return d, os.path.relpath(p, d) + d = os.path.dirname(d) + return None, None + + +def code_prefixes(repo, cache): + if repo not in cache: + cp = os.path.join(repo, ".claude", "code-paths") + if os.path.isfile(cp): + pre = [l.split("#", 1)[0].strip().rstrip("/") for l in open(cp, encoding="utf-8", errors="replace")] + cache[repo] = [x for x in pre if x] + else: + cache[repo] = None + return cache[repo] + + +def is_code(path, cache): + repo, rel = split_repo(path) + if not repo: + return False + pre = code_prefixes(repo, cache) + if pre is None: + return "/src/" in "/" + rel + return any(rel == x or rel.startswith(x + "/") for x in pre) + + +def bash_reads_code(cmd, cwd, cache): + for part in SEPARATORS.split(cmd or ""): + try: + words = shlex.split(part) + except ValueError: + words = part.split() + if not words or not (words[0] in READERS or (words[0] == "sed" and "-n" in words[1:2])): + continue + for w in words[1:]: + if w.startswith("-") or not (os.path.isabs(w) or cwd): + continue + if is_code(w if os.path.isabs(w) else os.path.join(cwd, w), cache): + return True + return False + + +def action_of(b): + name, inp = b.get("name") or "?", b.get("input") or {} + if name == "Bash": + return "Bash: " + " ".join((inp.get("command") or "").split()) + if name in FILE_TOOLS: + return "%s: %s" % (name, inp.get("file_path") or inp.get("notebook_path") or "") + return name + + +def count_tool(s, b, cwd, cache): + name, inp = b.get("name") or "?", b.get("input") or {} + s["tools"][name] += 1 + if name in ("Agent", "Task"): + s["subagents"][inp.get("model") or "не указана"] += 1 + elif name == "Skill": + sk = inp.get("skill") or "?" + s["skills"][sk] += 1 + if s["role"] is None and (sk == "role" or sk.endswith(":role")): + s["role"] = (inp.get("args") or "").strip() or "?" + if "codegraph_explore" in name: + s["cg_explore"] += 1 + elif name == "Read": + s["code_reads"] += is_code(inp.get("file_path") or "", cache) + elif name in ("Grep", "Glob"): + s["code_reads"] += 1 + elif name == "Bash": + cmd = inp.get("command") or "" + s["cg_init"] += "codegraph init" in cmd + s["code_reads"] += bash_reads_code(cmd, cwd, cache) + + +def session_stats(path, cache, bad): + s = {"prompts": 0, "interrupts": 0, "turns": 0, "tools": collections.Counter(), + "subagents": collections.Counter(), "skills": collections.Counter(), "role": None, + "tokens": collections.Counter(), "compactions": 0, "start": None, "blocks": collections.Counter(), + "classifier": collections.Counter(), "owner_denials": 0, "cg_init": 0, "cg_explore": 0, + "code_reads": 0, "recall": 0, "last_ts": ""} + seen, pending = set(), {} + for rec in read_session(path, bad): + ts = rec.get("timestamp") + if isinstance(ts, str) and ts > s["last_ts"]: + s["last_ts"] = ts + t = rec.get("type") + if t == "system" and rec.get("subtype") == "compact_boundary": + s["compactions"] += 1 + elif t == "assistant": + msg = rec.get("message") or {} + if rec.get("isApiErrorMessage") or msg.get("model") == "": + 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) + s["turns"] += 1 + u = msg.get("usage") or {} + if u: + n = lambda k: u.get(k, 0) or 0 + if s["start"] is None: + s["start"] = n("input_tokens") + n("cache_creation_input_tokens") + n("cache_read_input_tokens") + s["tokens"]["output"] += n("output_tokens") + s["tokens"]["cache_read"] += n("cache_read_input_tokens") + s["tokens"]["cache_write"] += n("cache_creation_input_tokens") + for b in blocks(rec): + if b.get("type") == "tool_use": + pending[b.get("id")] = b.get("name") or "?" + count_tool(s, b, rec.get("cwd"), cache) + elif t == "user": + txt = prompt_text(rec) + if txt is not None: + s["prompts"] += 1 + s["recall"] += bool(RECALL.search(txt)) + elif is_interrupt(rec): + s["interrupts"] += 1 + for b in blocks(rec): + if b.get("type") != "tool_result": + continue + rt = result_text(b) + if HOOK_ERROR.match(rt) and "BLOCKED" in rt: + s["blocks"][hook_of(rt, pending.get(b.get("tool_use_id"), "?"))] += 1 + m = CLASSIFIER.search(rt) + if m: + s["classifier"][m.group(1)] += 1 + s["owner_denials"] += OWNER_DENY in rt + return s + + +def aggregate(stats): + total = lambda k: sum(s[k] for s in stats) + merged = lambda k: sum((s[k] for s in stats), collections.Counter()) + tools, blk, cls, sub = merged("tools"), merged("blocks"), merged("classifier"), merged("subagents") + tok = merged("tokens") + starts = [s["start"] for s in stats if s["start"] is not None] + explore, reads = total("cg_explore"), total("code_reads") + return { + "sessions": len(stats), "prompts": total("prompts"), "interrupts": total("interrupts"), + "turns": total("turns"), "tool_calls": sum(tools.values()), "tools": dict(tools.most_common()), + "roles": dict(collections.Counter(s["role"] or "не выбрана" for s in stats).most_common()), + "subagents": sum(sub.values()), "subagent_models": dict(sub.most_common()), + "skills": dict(merged("skills").most_common()), + "tokens": {k: tok.get(k, 0) for k in ("output", "cache_read", "cache_write")}, + "compactions": total("compactions"), + "start_context": {"median": int(statistics.median(starts)), "min": min(starts), "max": max(starts)} + if starts else None, + "hook_blocks": sum(blk.values()), "hook_blocks_by_hook": dict(blk.most_common()), + "classifier_denials": sum(cls.values()), "classifier_by_reason": dict(cls.most_common()), + "owner_denials": total("owner_denials"), + "codegraph": {"init": total("cg_init"), "explore": explore, "code_reads": reads, + "explore_share": round(explore / (explore + reads), 2) if explore + reads else None}, + "recall": total("recall"), + } + + +def memory_stats(root, project): + d = os.path.join(root, slug(project), "memory") + files = [f for f in glob.glob(os.path.join(d, "**", "*"), recursive=True) if os.path.isfile(f)] + idx = os.path.join(d, "MEMORY.md") + return {"index_bytes": os.path.getsize(idx) if os.path.isfile(idx) else 0, "files": len(files), + "bytes": sum(os.path.getsize(f) for f in files)} + + +def false_blocks(labels): + cases = labels.get("cases", {}) + lab = [c for c in cases.values() if "skip" not in c] + allow = [c for c in lab if c.get("expected") == "allow"] + known = [c for c in allow if c.get("known")] + return {"labelled": len(lab), "skipped": len(cases) - len(lab), "false": len(allow), + "share": round(len(allow) / len(lab), 2) if lab else None, "fixed": len(allow) - len(known), + "known": len(known)} + + +def corrections(path): + """Кандидаты в поправки владельца с последним действием и хвостом ответа агента перед ними.""" + out, act, reply, sid = [], "", "", os.path.basename(path)[:8] + for rec in read_session(path, {}): + t = rec.get("type") + if t == "assistant": + for b in blocks(rec): + if b.get("type") == "tool_use": + act = action_of(b) + elif b.get("type") == "text" and (b.get("text") or "").strip(): + reply = b["text"] + continue + if t != "user": + continue + txt, kind = prompt_text(rec), None + if txt is not None: + if len(txt) <= 2000 and MARKERS.search(txt[:200]): + kind = "prompt" + elif is_interrupt(rec): + kind, txt = "interrupt", "[Request interrupted by user]" + elif any(b.get("type") == "tool_result" and OWNER_DENY in result_text(b) for b in blocks(rec)): + kind, txt = "denial", "отказ владельца в разрешении" + if kind: + out.append({"session": sid, "ts": rec.get("timestamp") or "", "kind": kind, "text": redact(txt[:300]), + "action": redact(act[:300]), "reply_tail": redact(" ".join(reply.split())[-200:])}) + return out + + +ROWS = [ + ("sessions", "Сессий"), ("prompts", "Промптов владельца"), ("interrupts", "Прерываний"), + ("turns", "Ходов модели"), ("tool_calls", "Вызовов инструментов"), ("subagents", "Запусков сабагентов"), + ("tokens.output", "Токенов вывода"), ("tokens.cache_read", "Чтение из кэша, токенов"), + ("tokens.cache_write", "Запись в кэш, токенов"), ("compactions", "Компакций контекста"), + ("start_context.median", "Стартовый контекст, медиана"), ("start_context.min", "Стартовый контекст, мин"), + ("start_context.max", "Стартовый контекст, макс"), ("hook_blocks", "Блокировок хуков"), + ("false_blocks.false", "Ложных блокировок"), ("false_blocks.share", "Доля ложных блокировок"), + ("false_blocks.fixed", "Ложных, уже исправлено"), ("false_blocks.known", "Ложных, ещё не исправлено"), + ("classifier_denials", "Отказов классификатора"), ("owner_denials", "Отказов владельца"), + ("codegraph.init", "codegraph init"), ("codegraph.explore", "Вызовов codegraph_explore"), + ("codegraph.code_reads", "Чтений кода (Read/Grep/Glob/Bash)"), + ("codegraph.explore_share", "Доля codegraph_explore"), + ("corrections", "Кандидатов в поправки владельца"), ("recall", "Промптов «перечитай память/контекст»"), + ("memory.index_bytes", "MEMORY.md, байт"), ("memory.files", "Файлов памяти"), ("memory.bytes", "Память, байт"), + ("cd_main", "cd в основной checkout на main"), +] +BREAKDOWNS = [("tools", "Инструменты"), ("roles", "Роли"), ("subagent_models", "Сабагенты по моделям"), + ("skills", "Skills"), ("hook_blocks_by_hook", "Блокировки по хукам"), + ("classifier_by_reason", "Отказы классификатора")] + + +def get(m, key): + for k in key.split("."): + if not isinstance(m, dict) or k not in m: + return None + m = m[k] + return m + + +def _num(v): + return isinstance(v, (int, float)) and not isinstance(v, bool) + + +def fmt(v): + if v is None: + return "—" + if isinstance(v, float): + return "%.2f" % v + if _num(v): + return "{:,}".format(v).replace(",", " ") + return str(v) + + +def cell(label, cur, old): + if isinstance(cur, dict): + cur = None + if isinstance(old, dict) and "retired" in old: + return "| %s | %s | — | не считается: %s |" % (label, fmt(cur), old["retired"]) + if isinstance(old, dict) and "value" in old: + return "| %s | %s | %s (%s) | — |" % (label, fmt(cur), fmt(old["value"]), old.get("note", "иначе считалось")) + d = "—" + if _num(cur) and _num(old): + diff = cur - old + d = ("%+.2f" % diff) if isinstance(diff, float) else "{:+,}".format(diff).replace(",", " ") + return "| %s | %s | %s | %s |" % (label, fmt(cur), fmt(old), d) + + +def to_markdown(doc, prev): + m, pm = doc["metrics"], (prev or {}).get("metrics") or {} + out = ["| Показатель | Сейчас | Прошлое ретро | Δ |", "|---|---|---|---|"] + for key, label in ROWS: + cur, old = get(m, key), get(pm, key) + if cur is None and old is None: + continue + out.append(cell(label, cur, old)) + out.append("") + for key, label in BREAKDOWNS: + d = m.get(key) or {} + if d: + out.append("- %s: %s" % (label, ", ".join("%s %s" % (k, fmt(v)) for k, v in list(d.items())[:10]))) + src, per = doc.get("sources") or {}, doc.get("period") or {} + out += ["", "Период %s — %s; сессий %s; каталоги транскриптов: %s; битых строк %s." % ( + per.get("since") or "начало", per.get("until") or "—", src.get("sessions"), + ", ".join(src.get("dirs") or []) or "—", src.get("bad_lines"))] + return "\n".join(out) + + +def default_since(harness): + names = [os.path.basename(f)[:10] for f in glob.glob(os.path.join(harness, "docs", "retro", "*.md"))] + names = [n for n in names if re.match(r"^\d{4}-\d{2}-\d{2}$", n)] + return max(names) if names else None + + +def parse_args(argv): + cc = os.environ.get("CLAUDE_CONFIG_DIR") or os.path.expanduser("~/.claude") + p = argparse.ArgumentParser(prog="retro_metrics.py", description="Срез метрик обвязки (B12)") + p.add_argument("project") + p.add_argument("--since") + p.add_argument("--harness", default=os.path.normpath(os.path.join(HERE, "..", "..", ".."))) + p.add_argument("--projects-root", default=os.path.join(cc, "projects")) + p.add_argument("--labels") + p.add_argument("--out") + p.add_argument("--markdown", action="store_true") + p.add_argument("--prev") + p.add_argument("--corrections", action="store_true") + return p.parse_args(argv[1:]) + + +def main(argv): + a = parse_args(argv) + project = os.path.abspath(a.project) + since = a.since or default_since(a.harness) + dirs, files = project_sessions(project, a.projects_root, since) + bad, cache = {"n": 0}, {} + stats = [s for s in (session_stats(f, cache, bad) for f in files) if s["prompts"] or s["turns"]] + if not stats: + sys.stderr.write("retro_metrics: нет сессий проекта %s (slug %s) в %s с %s\n" + % (project, slug(project), a.projects_root, since or "начала")) + return 1 + cands = [c for f in files for c in corrections(f)] + if a.corrections: + for c in cands: + print(json.dumps(c, ensure_ascii=False)) + return 0 + m = aggregate(stats) + m["corrections"] = len(cands) + m["memory"] = memory_stats(a.projects_root, project) + if a.labels: + m["false_blocks"] = false_blocks(json.load(open(a.labels, encoding="utf-8"))) + doc = {"schema": 1, "period": {"since": since, "until": max(s["last_ts"] for s in stats)[:10]}, + "project": project, + "sources": {"dirs": dirs, "sessions": len(stats), "bytes": sum(os.path.getsize(f) for f in files), + "bad_lines": bad["n"]}, + "metrics": m} + if a.out: + with open(a.out, "w", encoding="utf-8") as fh: + json.dump(doc, fh, ensure_ascii=False, indent=2) + fh.write("\n") + if a.markdown: + print(to_markdown(doc, json.load(open(a.prev, encoding="utf-8")) if a.prev else None)) + elif not a.out: + print(json.dumps(doc, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/openspec/changes/harness-retro/design.md b/openspec/changes/harness-retro/design.md index 913aa58..f444c5a 100644 --- a/openspec/changes/harness-retro/design.md +++ b/openspec/changes/harness-retro/design.md @@ -174,5 +174,6 @@ Bash-команда считается чтением кода, если хот | 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: ` — не ход модели: не считаются в ходах, токенах и стартовом контексте | Это сообщения Claude Code об ошибке API с нулевым `usage`; на живых транскриптах давали стартовый контекст 0 (O-18) | Если Claude Code пометит так настоящий ход, он выпадет из подсчёта | | 2026-10-24 | Свежая ветка, совпадающая с `origin/main`, — не хвост | `--merged` считает слитой и ветку без коммитов, например только что созданный worktree | Брошенная пустая ветка не попадёт в хвосты | | 2026-10-24 | Сторона Gitea в хвостах не проверяется | Хрупко (формат описаний PR и issue); агент видит это при чтении | Незакрытая issue после merge не попадёт в метрику | diff --git a/tests/test-retro-metrics.sh b/tests/test-retro-metrics.sh new file mode 100644 index 0000000..3a16525 --- /dev/null +++ b/tests/test-retro-metrics.sh @@ -0,0 +1,132 @@ +# 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("/clear", T % 4), + u("служебное", T % 4, isMeta=True), + u("[Request interrupted by user]", T % 4), + a("m4", [{"type": "text", "text": "Вхожу в worktree."}, tu("t11", "EnterWorktree", name="x")], T % 5), + u("почему мне нужно это каждый раз подтверждать?", T % 6), + u("перечитай память", T % 7), + a("m5", [tu("t12", "Bash", command="git branch -m feat/x")], T % 8), + u("нет, tea login add --token 0123456789abcdef0123456789abcdef01234567 не так", T % 9), + {"type": "system", "subtype": "compact_boundary", "timestamp": T % 9}, + "not json", + "[]", +]) +W = P + "/.claude/worktrees/x" +w(A + "--claude-worktrees-x", "s2.jsonl", [ + u("ок", "2026-11-03T09:00:00Z", cwd=W), + {"type": "assistant", "timestamp": "2026-11-03T09:00:30Z", "cwd": W, "isApiErrorMessage": True, # не ход модели + "message": {"id": "syn-1", "model": "", "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) — без старой и чужой" "$(q 'm["sessions"]')" "3" +assert_eq "каталоги транскриптов среза" "$(q 'len(d["sources"]["dirs"])')" "3" +assert_eq "битые строки" "$(q 'd["sources"]["bad_lines"]')" "1" +assert_eq "промпты владельца без служебных и прерываний" "$(q 'm["prompts"]')" "6" +assert_eq "прерывания" "$(q 'm["interrupts"]')" "1" +assert_eq "ходы — по message.id" "$(q 'm["turns"]')" "7" +assert_eq "вызовы инструментов" "$(q 'm["tool_calls"]')" "15" +assert_eq "токены вывода один раз на сообщение" "$(q 'm["tokens"]["output"]')" "10" +assert_eq "стартовый контекст" "$(q 'sorted(m["start_context"].items())')" "[('max', 61000), ('median', 56000), ('min', 51100)]" +assert_eq "блокировки только по ошибке хука" "$(q 'm["hook_blocks"], m["hook_blocks_by_hook"]')" "(1, {'guard-main(Bash)': 1})" +assert_eq "отказы классификатора по причине" "$(q 'm["classifier_by_reason"]')" "{'Self-Modification': 1}" +assert_eq "отказы владельца" "$(q 'm["owner_denials"]')" "1" +assert_eq "codegraph против чтения кода" "$(q 'sorted(m["codegraph"].items())')" "[('code_reads', 5), ('explore', 2), ('explore_share', 0.29), ('init', 1)]" +assert_eq "роли" "$(q 'sorted(m["roles"].items())')" "[('dev', 1), ('не выбрана', 2)]" +assert_eq "сабагенты по моделям" "$(q 'sorted(m["subagent_models"].items())')" "[('sonnet', 1), ('не указана', 1)]" +assert_eq "skills" "$(q 'm["skills"]')" "{'role': 1}" +assert_eq "компакции, перечитай" "$(q 'm["compactions"], m["recall"]')" "(1, 1)" +assert_eq "кандидаты в поправки" "$(q 'm["corrections"]')" "4" +assert_eq "память" "$(q 'sorted(m["memory"].items())')" "[('bytes', 150), ('files', 2), ('index_bytes', 100)]" +assert_eq "памяти нет — нули" "$(python3 -c "import sys; sys.path.insert(0, '$ROOT/docs/retro/scripts'); import retro_metrics; print(sorted(retro_metrics.memory_stats('$TMP/none', '$P').items()))")" "[('bytes', 0), ('files', 0), ('index_bytes', 0)]" +# кандидаты в поправки: контекст и redact +C="$TMP/corr.jsonl" +python3 "$M" "$P" --since 2026-11-01 --projects-root "$CR" --corrections > "$C" +assert_eq "кандидатов 4" "$(grep -c . "$C")" "4" +assert_eq "«почему» после EnterWorktree" "$(python3 -c 'import json,sys; print([c["action"] for c in map(json.loads, open(sys.argv[1])) if "почему" in c["text"]])' "$C")" "['EnterWorktree']" +assert_eq "последнее действие Bash" "$(python3 -c 'import json,sys; print([c["action"] for c in map(json.loads, open(sys.argv[1])) if c["text"].startswith("нет")])' "$C")" "['Bash: git branch -m feat/x']" +assert_eq "токен замазан" "$(grep -c '0123456789abcdef' "$C")" "0" +assert_grep "токен заменён на " '' "$C" +assert_eq "виды кандидатов" "$(python3 -c 'import json,sys; print(sorted(set(c["kind"] for c in map(json.loads, open(sys.argv[1])))))' "$C")" "['denial', 'interrupt', 'prompt']" +# сравнение с прошлым ретро +cat > "$TMP/prev.json" <<'EOF' +{"schema": 1, "metrics": {"sessions": 44, "turns": {"value": 12121, "note": "по строкам"}, "cd_main": {"retired": "хуки 0.4.0"}}} +EOF +md=$(python3 "$M" "$P" --since 2026-11-01 --projects-root "$CR" --markdown --prev "$TMP/prev.json") +assert_grep "строка с разницей" '^\| Сессий \| 3 \| 44 \| -41 \|$' <(printf '%s' "$md") +assert_grep "новая метрика — прочерки" '^\| Кандидатов в поправки владельца \| 4 \| — \| — \|$' <(printf '%s' "$md") +assert_grep "снятая метрика" '^\| cd в основной checkout на main \| — \| — \| не считается: хуки 0.4.0 \|$' <(printf '%s' "$md") +assert_grep "метрика с другим способом подсчёта" '^\| Ходов модели \| 7 \| 12 121 \(по строкам\) \| — \|$' <(printf '%s' "$md") +# ложные блокировки по разметке +cat > "$TMP/labels.json" <<'EOF' +{"home": "/h", "layout": [], "cases": {"x-001": {"expected": "allow", "why": "a"}, "x-002": {"expected": "allow", "why": "b"}, + "x-003": {"expected": "allow", "why": "c", "known": "RT-21"}, "x-004": {"expected": "block", "why": "d"}, "x-005": {"skip": "s"}}} +EOF +python3 "$M" "$P" --since 2026-11-01 --projects-root "$CR" --labels "$TMP/labels.json" --out "$J" +assert_eq "ложные блокировки" "$(q 'sorted(m["false_blocks"].items())')" "[('false', 3), ('fixed', 2), ('known', 1), ('labelled', 4), ('share', 0.75), ('skipped', 1)]" +# период по умолчанию — от даты последнего ретро +H="$TMP/harness"; mkdir -p "$H/docs/retro"; touch "$H/docs/retro/2026-11-03.md" "$H/docs/retro/2026-10-11.md" +python3 "$M" "$P" --harness "$H" --projects-root "$CR" --out "$J" +assert_eq "период с последнего ретро" "$(q 'd["period"]["since"], m["sessions"]')" "('2026-11-03', 2)" +# нет сессий +err=$(python3 "$M" "$P" --since 2030-01-01 --projects-root "$CR" 2>&1 >/dev/null); rc=$? +assert_eq "нет сессий: exit 1" "$rc" "1" +assert_grep "сообщение со slug" 'proj' <(printf '%s' "$err") From 4bbf6ebb3a4ff6615d4ff372506c108cc7215f64 Mon Sep 17 00:00:00 2001 From: "andrey.epifantsev" Date: Sat, 24 Oct 2026 01:25:14 +0400 Subject: [PATCH 05/10] =?UTF-8?q?feat(retro):=20retro=5Fleftovers.py=20?= =?UTF-8?q?=E2=80=94=20=D1=85=D0=B2=D0=BE=D1=81=D1=82=D1=8B=20=D1=87=D0=B5?= =?UTF-8?q?=D0=BA=D0=BB=D0=B8=D1=81=D1=82=D0=B0=20=D0=B7=D0=B0=D0=BA=D1=80?= =?UTF-8?q?=D1=8B=D1=82=D0=B8=D1=8F=20=D0=BF=D0=BE=20=D1=81=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D1=8E=20=D1=80=D0=B5=D0=BF=D0=BE?= =?UTF-8?q?=D0=B7=D0=B8=D1=82=D0=BE=D1=80=D0=B8=D0=B5=D0=B2=20(#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SDD workspace в worktree активной задачи хвостом не считается (O-19, живой прогон). Co-Authored-By: Claude Opus 5.5 --- docs/retro/scripts/retro_leftovers.py | 154 ++++++++++++++++++ openspec/changes/harness-retro/design.md | 3 +- .../harness-retro/specs/harness-retro/spec.md | 2 +- tests/test-retro-leftovers.sh | 60 +++++++ 4 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 docs/retro/scripts/retro_leftovers.py create mode 100644 tests/test-retro-leftovers.sh diff --git a/docs/retro/scripts/retro_leftovers.py b/docs/retro/scripts/retro_leftovers.py new file mode 100644 index 0000000..d9cfd60 --- /dev/null +++ b/docs/retro/scripts/retro_leftovers.py @@ -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// в 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)) diff --git a/openspec/changes/harness-retro/design.md b/openspec/changes/harness-retro/design.md index f444c5a..a160171 100644 --- a/openspec/changes/harness-retro/design.md +++ b/openspec/changes/harness-retro/design.md @@ -100,7 +100,7 @@ Bash-команда считается чтением кода, если хот - `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; +- `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. @@ -175,5 +175,6 @@ Bash-команда считается чтением кода, если хот | 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: ` — не ход модели: не считаются в ходах, токенах и стартовом контексте | Это сообщения Claude Code об ошибке API с нулевым `usage`; на живых транскриптах давали стартовый контекст 0 (O-18) | Если Claude Code пометит так настоящий ход, он выпадет из подсчёта | +| 2026-10-24 | SDD workspace в worktree неслитой ветки — не хвост | Живой прогон показал рабочий каталог текущего плана как хвост (O-19); это работа в процессе | Брошенная неслитая задача со своим SDD workspace не попадёт в хвосты (её видно по worktree) | | 2026-10-24 | Свежая ветка, совпадающая с `origin/main`, — не хвост | `--merged` считает слитой и ветку без коммитов, например только что созданный worktree | Брошенная пустая ветка не попадёт в хвосты | | 2026-10-24 | Сторона Gitea в хвостах не проверяется | Хрупко (формат описаний PR и issue); агент видит это при чтении | Незакрытая issue после merge не попадёт в метрику | diff --git a/openspec/changes/harness-retro/specs/harness-retro/spec.md b/openspec/changes/harness-retro/specs/harness-retro/spec.md index 02e2085..50adbc5 100644 --- a/openspec/changes/harness-retro/specs/harness-retro/spec.md +++ b/openspec/changes/harness-retro/specs/harness-retro/spec.md @@ -137,7 +137,7 @@ - ветка `origin/<ветка>`, слитая в `origin/main`; - локальная ветка, слитая или с upstream `gone`; - worktree на такой ветке или `prunable`; -- `.superpowers/sdd/*`; +- `.superpowers/sdd/*` в основном checkout или в worktree такой ветки (в worktree активной задачи — не хвост); - неархивный `openspec/changes//` в `origin/main`; - отставший или грязный основной checkout. diff --git a/tests/test-retro-leftovers.sh b/tests/test-retro-leftovers.sh new file mode 100644 index 0000000..4c21b6e --- /dev/null +++ b/tests/test-retro-leftovers.sh @@ -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" From e5c00a5442e15a881664dbc548e4fbb69482d405 Mon Sep 17 00:00:00 2001 From: "andrey.epifantsev" Date: Sat, 24 Oct 2026 01:25:48 +0400 Subject: [PATCH 06/10] =?UTF-8?q?docs(spec):=20=D1=81=D1=86=D0=B5=D0=BD?= =?UTF-8?q?=D0=B0=D1=80=D0=B8=D0=B9=20=C2=ABSDD=20workspace=20=D0=B0=D0=BA?= =?UTF-8?q?=D1=82=D0=B8=D0=B2=D0=BD=D0=BE=D0=B9=20=D0=B7=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D1=87=D0=B8=C2=BB,=20=D1=82=D1=80=D0=B5=D0=B1=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5=20=D1=85=D0=B2=D0=BE=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=20=E2=89=A4=20500=20=D1=81=D0=B8=D0=BC=D0=B2=D0=BE=D0=BB?= =?UTF-8?q?=D0=BE=D0=B2=20(#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../changes/harness-retro/specs/harness-retro/spec.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/openspec/changes/harness-retro/specs/harness-retro/spec.md b/openspec/changes/harness-retro/specs/harness-retro/spec.md index 50adbc5..18882be 100644 --- a/openspec/changes/harness-retro/specs/harness-retro/spec.md +++ b/openspec/changes/harness-retro/specs/harness-retro/spec.md @@ -133,11 +133,11 @@ - **THEN** в выводе `--corrections` вместо токена стоит `` ### Requirement: Хвосты чеклиста закрытия -`retro_leftovers.py <папка проекта>` SHALL искать хвосты по текущему состоянию каждого репозитория проекта (для workspace — из таблицы `| Каталог | Репозиторий | Роль |` в `CLAUDE.md`). Хвост — это: +`retro_leftovers.py <папка проекта>` SHALL искать хвосты по текущему состоянию каждого репозитория проекта (для workspace — из таблицы репозиториев в `CLAUDE.md`). Хвост — это: - ветка `origin/<ветка>`, слитая в `origin/main`; - локальная ветка, слитая или с upstream `gone`; - worktree на такой ветке или `prunable`; -- `.superpowers/sdd/*` в основном checkout или в worktree такой ветки (в worktree активной задачи — не хвост); +- `.superpowers/sdd/*` в основном checkout или в worktree такой ветки; - неархивный `openspec/changes//` в `origin/main`; - отставший или грязный основной checkout. @@ -145,6 +145,10 @@ - **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** список хвостов пуст From cc697c030c8bf76ad10da26fccad77ba0342e4d2 Mon Sep 17 00:00:00 2001 From: "andrey.epifantsev" Date: Sat, 24 Oct 2026 01:28:56 +0400 Subject: [PATCH 07/10] =?UTF-8?q?feat(retro):=20skill=20/harness-retro,=20?= =?UTF-8?q?=D1=88=D0=B0=D0=B1=D0=BB=D0=BE=D0=BD=20=D1=80=D0=B5=D1=82=D1=80?= =?UTF-8?q?=D0=BE,=20=D0=BC=D0=B5=D1=82=D1=80=D0=B8=D0=BA=D0=B8=2011.10;?= =?UTF-8?q?=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=200.6.0=20(#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .claude/skills/harness-retro/SKILL.md | 38 ++++++ .claude/skills/harness-retro/template.md | 49 +++++++ CHANGELOG.md | 14 ++ CLAUDE.md | 9 +- README.md | 2 + docs/HARNESS.md | 14 ++ docs/retro/2026-10-11.metrics.json | 44 +++++++ docs/retro/scripts/retro_blocks.py | 140 -------------------- docs/retro/scripts/retro_stats.py | 159 ----------------------- tests/test-retro-skill.sh | 32 +++++ 10 files changed, 198 insertions(+), 303 deletions(-) create mode 100644 .claude/skills/harness-retro/SKILL.md create mode 100644 .claude/skills/harness-retro/template.md create mode 100644 docs/retro/2026-10-11.metrics.json delete mode 100644 docs/retro/scripts/retro_blocks.py delete mode 100644 docs/retro/scripts/retro_stats.py create mode 100644 tests/test-retro-skill.sh diff --git a/.claude/skills/harness-retro/SKILL.md b/.claude/skills/harness-retro/SKILL.md new file mode 100644 index 0000000..61bfbc3 --- /dev/null +++ b/.claude/skills/harness-retro/SKILL.md @@ -0,0 +1,38 @@ +--- +name: harness-retro +description: Ретро обвязки claude-harness по транскриптам, памяти и репозиториям одного проекта или workspace — срез метрик, разметка блокировок хуков в корпус, хвосты закрытия, поправки владельца, черновик docs/retro/<дата>.md и PR. Запускать в сессии в репозитории обвязки по команде «/harness-retro <папка> [--since ГГГГ-ММ-ДД]» после среза. +--- + +# /harness-retro + +Аргумент — папка проекта или workspace, который изучаем. Сессия — в репозитории обвязки, роль dev. Дата ретро `` — сегодня, `ГГГГ-ММ-ДД`. Скрипты — `docs/retro/scripts/`, все команды — из корня worktree ретро. Спека — `openspec/specs/harness-retro/spec.md`. + +## Шаги + +1. **Задача и worktree.** Issue «Ретро : <проект>» (`tea issues create`). Затем `EnterWorktree` с именем `retro-`, ветка `git branch -m retro/`, `codegraph init` в worktree. +2. **Срез.** Выполнить `python3 docs/retro/scripts/retro_metrics.py <папка> [--since …] --out docs/retro/.metrics.json`. + - Exit 1 — сессий нет. Сообщить владельцу slug и период и остановиться. + - Битых строк больше 1 % — отметить в «Что не измерялось». +3. **Блокировки.** Сначала выгрузить: `python3 docs/retro/scripts/hook_blocks_export.py --since --id-prefix <каталоги из sources.dirs> > docs/retro/fixtures/hook-blocks-.jsonl`. Каталоги — `~/.claude/projects/<имя>`. + - Разметить каждую запись в `docs/retro/fixtures/hook-labels-.json`: + - `expected` (`allow|block`) и `why` по спеке `hook-guards`; + - раскладку песочницы — по `gitBranch` сессий и выводу `git worktree list` и `git branch` в транскриптах; + - если не восстановить — `skip` с причиной. + - Собрать корпус: `python3 docs/retro/scripts/hook_cases_build.py > skills/harness-doctor/fixtures/hook-cases.jsonl` и прогнать `bash tests/run.sh hook-corpus`. Если текущий хук даёт не тот вердикт — проставить записи `"known": "RT-n"` и завести находку. + - Пересчитать срез с `--labels docs/retro/fixtures/hook-labels-.json --out docs/retro/.metrics.json`. +4. **Хвосты закрытия.** `python3 docs/retro/scripts/retro_leftovers.py <папка> --fetch --markdown`. Ничего не удалять: таблица идёт в черновик, уборка — по решению владельца. +5. **Поправки владельца.** `python3 docs/retro/scripts/retro_metrics.py <папка> [--since …] --corrections`. + - Отобрать только поправки по процессу: пропущенный шаг, лишний вопрос, неверный совет, обход правила, расширение задачи. Вопросы и просьбы по содержанию выбросить. + - Сгруппировать по видам. + - Вид, который повторяется (2 раза и больше) или стоил владельцу заметного времени, — находка `RT-n` и пункт бэклога. +6. **Качественная часть.** Прочитать память проекта целиком (`~/.claude/projects//memory/`) и примеры из выгрузок. Находки `RT-n` — сквозная нумерация: максимум в прошлых `docs/retro/*.md` плюс 1. Приоритет: P1 — теряет время каждую неделю или ломает процесс; P2 — трение; P3 — наблюдение. +7. **Черновик.** Скопировать `.claude/skills/harness-retro/template.md` в `docs/retro/.md` и заполнить. + - «Цифры»: `python3 docs/retro/scripts/retro_metrics.py <папка> [--since …] --labels … --markdown --prev docs/retro/<прошлое>.metrics.json`. + - Секретов в черновике быть не должно. Цитаты брать только из вывода скриптов: он уже прошёл `redact`. +8. **PR.** `bash tests/run.sh` должен быть зелёным. Коммит: черновик, `metrics.json`, выгрузка, разметка, корпус. Затем `git push -u origin retro/` и `tea pulls create` со ссылкой на issue. Merge — только владелец. + +## Не делать + +- Не удалять хвосты и не чинить хуки в PR ретро — это находки и бэклог. +- Не заполнять раздел «Решения владельца»: он пишется на ревью PR. +- Не вставлять в черновик сырые куски транскриптов мимо скриптов. diff --git a/.claude/skills/harness-retro/template.md b/.claude/skills/harness-retro/template.md new file mode 100644 index 0000000..94e9694 --- /dev/null +++ b/.claude/skills/harness-retro/template.md @@ -0,0 +1,49 @@ +# Ретроспектива обвязки claude-harness — <ДД.ММ.ГГГГ> + +Период: –, полигон — <проект или workspace>. +Предыдущая точка сравнения — `docs/retro/<прошлое>.md`. + +Источники: +- транскриптов сессий (<каталоги из sources.dirs>, <объём>); +- файлов auto-memory (<объём>); +- состояние репозиториев (`retro_leftovers.py`); +- блокировки среза, размеченные в корпус: `docs/retro/fixtures/hook-labels-.json`. + +Количественную часть считал `docs/retro/scripts/retro_metrics.py`, качественную — по прочтению памяти целиком и кандидатов в поправки. + +Маркировка находок: `RT-n`, продолжение нумерации прошлых ретро. Приоритет: P1 — теряет время каждую неделю или ломает процесс; P2 — трение; P3 — наблюдение. + +## 1. Цифры + +<таблица из retro_metrics.py --markdown --prev> + +Хвосты чеклиста закрытия: + +<таблица из retro_leftovers.py --markdown> + +## 2. Находки + +**RT- (P). <Одна фраза — что происходит>.** <Факты: где, сколько раз, чем стоило>. Источники: <сессии, память, метрика>. + +## 3. Поправки владельца + +| Вид | Сколько | Пример (цитата, что было до неё) | Что вышло | Находка | +|---|---|---|---|---| + +## 4. Что сработало (сохранить) + +- <…> + +## 5. Бэклог улучшений (по приоритету) + +| # | Что | Находки | Оценка | +|---|---|---|---| + +## 6. Решения владельца (<дата>) + +Заполняется на ревью PR. + +## 7. Что не измерялось + +- Сторона Gitea чеклиста закрытия: открытые issue после слитого PR, пропущенные follow-up. +- <…> diff --git a/CHANGELOG.md b/CHANGELOG.md index 470a978..c391cf5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`). + + Заменяет `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 живут в `/.claude/worktrees/`, а не в соседнем каталоге: их создаёт `EnterWorktree` с именем, ветка `worktree-` переименовывается в `feat/-` (#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`. diff --git a/CLAUDE.md b/CLAUDE.md index d832615..f73933c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` в `/.claude/worktrees/`, вход без подтверждения (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 сами по себе не запускать. diff --git a/README.md b/README.md index 5743a3d..f9438de 100644 --- a/README.md +++ b/README.md @@ -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 результаты пилота и находки ``` diff --git a/docs/HARNESS.md b/docs/HARNESS.md index d4281ca..0bab5c0 100644 --- a/docs/HARNESS.md +++ b/docs/HARNESS.md @@ -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): один контекст, одно ревью в конце. diff --git a/docs/retro/2026-10-11.metrics.json b/docs/retro/2026-10-11.metrics.json new file mode 100644 index 0000000..a3e7354 --- /dev/null +++ b/docs/retro/2026-10-11.metrics.json @@ -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": 6583, + "tools": {"Bash": 4302, "Playwright": 1100, "Read": 346, "Write": 271, "Edit": 233}, + "roles": {"dev": 32, "admin": 7, "qa": 5}, + "subagents": 61, + "subagent_models": {"sonnet": 47, "opus": 9, "не указана": 5}, + "skills": {"brainstorming": 19, "writing-plans": 14, "systematic-debugging": 8, "subagent-driven-development": 4, + "executing-plans": 3, "using-git-worktrees": 3, "test-driven-development": 1, + "finishing-a-development-branch": 1}, + "tokens": { + "output": {"value": 18300000, "note": "по строкам транскрипта"}, + "cache_read": {"value": 3820000000, "note": "по строкам транскрипта"}, + "cache_write": {"value": 145000000, "note": "по строкам транскрипта"} + }, + "compactions": 4, + "start_context": {"median": 56000, "min": 52000, "max": 61000}, + "hook_blocks": 76, + "hook_blocks_by_hook": null, + "false_blocks": {"labelled": 76, "skipped": 0, "false": 72, "share": 0.95, "fixed": 72, "known": 0}, + "classifier_denials": 6, + "classifier_by_reason": {"Git Destructive": 2, "Auto-Mode Bypass": 2, "Interfere With Workloads": 1, "safety-check": 1}, + "owner_denials": 6, + "codegraph": {"init": 43, "explore": 15, "code_reads": null, "explore_share": null}, + "corrections": null, + "recall": 9, + "memory": {"index_bytes": null, "files": 47, "bytes": 183296}, + "cd_main": {"retired": "хуки 0.4.0 не зависят от cwd (RT-2); считалось по именам репозиториев awp-ca, было 84"} + } +} diff --git a/docs/retro/scripts/retro_blocks.py b/docs/retro/scripts/retro_blocks.py deleted file mode 100644 index 9c453d7..0000000 --- a/docs/retro/scripts/retro_blocks.py +++ /dev/null @@ -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}") diff --git a/docs/retro/scripts/retro_stats.py b/docs/retro/scripts/retro_stats.py deleted file mode 100644 index b9989c1..0000000 --- a/docs/retro/scripts/retro_stats.py +++ /dev/null @@ -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}") diff --git a/tests/test-retro-skill.sh b/tests/test-retro-skill.sh new file mode 100644 index 0000000..43346db --- /dev/null +++ b/tests/test-retro-skill.sh @@ -0,0 +1,32 @@ +# /harness-retro: проектный skill, шаблон, метрики 11.10, удалённые скрипты (#29). +S="$ROOT/.claude/skills/harness-retro" +assert_file "SKILL.md" "$S/SKILL.md" +assert_grep "SKILL.md: name" '^name: harness-retro$' "$S/SKILL.md" +for step in 'retro_metrics\.py' 'hook_blocks_export\.py' 'hook_cases_build\.py' 'retro_leftovers\.py' '--corrections' 'EnterWorktree' 'tea pulls create'; do + assert_grep "SKILL.md упоминает $step" "$step" "$S/SKILL.md" +done +assert_file "template.md" "$S/template.md" +for sec in "## 1. Цифры" "## 2. Находки" "## 3. Поправки владельца" "## 4. Что сработало" "## 5. Бэклог" "## 6. Решения владельца" "## 7. Что не измерялось"; do + assert_grep "шаблон: $sec" "^$sec" "$S/template.md" +done +J="$ROOT/docs/retro/2026-10-11.metrics.json" +assert_eq "метрики 11.10: схема, cd снят, ложные" "$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); m=d["metrics"]; print(d["schema"], "retired" in m["cd_main"], m["false_blocks"]["false"], m["hook_blocks"])' "$J")" "1 True 72 76" +out=$(python3 - "$ROOT/docs/retro/scripts" "$J" <<'PY' +import json, sys +sys.path.insert(0, sys.argv[1]) +import retro_metrics +prev = json.load(open(sys.argv[2])) +doc = {"metrics": {"sessions": 3, "turns": 7, "hook_blocks": 1}, "period": {"since": "2026-10-11", "until": "2026-10-24"}, + "sources": {"sessions": 3, "dirs": [], "bad_lines": 0}} +print(retro_metrics.to_markdown(doc, prev)) +PY +) +assert_grep "сравнение с 11.10: сессии" '^\| Сессий \| 3 \| 44 \| -41 \|$' <(printf '%s' "$out") +assert_grep "сравнение с 11.10: блокировки" '^\| Блокировок хуков \| 1 \| 76 \| -75 \|$' <(printf '%s' "$out") +assert_grep "сравнение с 11.10: cd снят" '^\| cd в основной checkout на main \| — \| — \| не считается' <(printf '%s' "$out") +assert_grep "сравнение с 11.10: ходы считались по строкам" '^\| Ходов модели \| 7 \| 12 121 \(по строкам' <(printf '%s' "$out") +assert_eq "retro_stats.py удалён" "$([ -e "$ROOT/docs/retro/scripts/retro_stats.py" ] && echo есть || echo нет)" "нет" +assert_eq "retro_blocks.py удалён" "$([ -e "$ROOT/docs/retro/scripts/retro_blocks.py" ] && echo есть || echo нет)" "нет" +assert_eq "CLAUDE.md без ссылок на удалённые скрипты" "$(grep -c 'retro_stats\|retro_blocks' "$ROOT/CLAUDE.md")" "0" +assert_grep "CLAUDE.md: retro_metrics в командах" 'retro_metrics\.py' "$ROOT/CLAUDE.md" +assert_grep "HARNESS.md: раздел Ретро" '^## Ретро' "$ROOT/docs/HARNESS.md" From 3d2ce3f41d4d6e75f8e2e5989ae90a6e84c2b8ce Mon Sep 17 00:00:00 2001 From: "andrey.epifantsev" Date: Sat, 24 Oct 2026 01:59:41 +0400 Subject: [PATCH 08/10] =?UTF-8?q?fix(retro):=20=D1=84=D0=B8=D0=BD=D0=B0?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D0=BE=D0=B5=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E?= =?UTF-8?q?=20=E2=80=94=20=D1=81=D0=B0=D0=B1=D0=B0=D0=B3=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D1=8B,=20=D0=BF=D0=B5=D1=80=D0=B8=D0=BE=D0=B4=20=D0=BF=D0=BE?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D1=8F=D0=BC,=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D0=B1=D0=BE=D1=80=20Bash,=20redact=20=D0=B4=D0=BE=20?= =?UTF-8?q?=D0=BE=D0=B1=D1=80=D0=B5=D0=B7=D0=BA=D0=B8,=20--from=20(#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - C-1: транскрипты сабагентов (<сессия>/subagents/*.jsonl) вливаются в сессию-родителя — ходы, токены, инструменты, codegraph, блокировки, отказы; промпты, роль, стартовый контекст — только из основного. hook_blocks_export.py выгружает их блокировки с сессией-родителем. - I-7: период режется по меткам времени записей; сессия на стыке делится между ретро, стартовый контекст — если первый ход в периоде. - I-1: простые команды Bash — shlex с punctuation_chars, «|» в кавычках команду не делит. I-2: правило src/ ловит и сам каталог src. - I-3: clip() — redact до обрезки (кандидаты, выгрузка), роль — redact. - I-4: без --since период берётся из существующего --out; --from рисует «Цифры» из сохранённого среза. SKILL.md передаёт период явно. - I-5: выгрузка ретро без разметки — сборка корпуса падает. - I-6: сводка компакции — не промпт; «перечитай» — побуждение перечитать. - 2026-10-11.metrics.json: несравнимое помечено (без сабагентов, широкий «перечитай»). Спека и design.md (Decisions) обновлены. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/harness-retro/SKILL.md | 6 +- docs/retro/2026-10-11.metrics.json | 10 +- docs/retro/scripts/hook_blocks_export.py | 89 ++++++----- docs/retro/scripts/hook_cases_build.py | 5 + docs/retro/scripts/retro_metrics.py | 146 +++++++++++++----- openspec/changes/harness-retro/design.md | 16 +- .../harness-retro/specs/harness-retro/spec.md | 51 ++++-- tests/test-hook-corpus.sh | 6 + tests/test-hook-export.sh | 14 +- tests/test-retro-metrics.sh | 76 +++++++-- tests/test-retro-skill.sh | 5 +- 11 files changed, 299 insertions(+), 125 deletions(-) diff --git a/.claude/skills/harness-retro/SKILL.md b/.claude/skills/harness-retro/SKILL.md index 61bfbc3..105d420 100644 --- a/.claude/skills/harness-retro/SKILL.md +++ b/.claude/skills/harness-retro/SKILL.md @@ -11,6 +11,8 @@ description: Ретро обвязки claude-harness по транскрипт 1. **Задача и worktree.** Issue «Ретро : <проект>» (`tea issues create`). Затем `EnterWorktree` с именем `retro-`, ветка `git branch -m retro/`, `codegraph init` в worktree. 2. **Срез.** Выполнить `python3 docs/retro/scripts/retro_metrics.py <папка> [--since …] --out docs/retro/.metrics.json`. + - Период `` — `period.since` из `docs/retro/.metrics.json`. На шагах 3 и 5 передавать его явно (`--since `). Повторный запуск с тем же `--out` берёт период из него, поэтому черновик `.md` период не сдвигает. + - В срез входят записи периода, в том числе из длинных сессий, начатых раньше. Транскрипты сабагентов (`<сессия>/subagents/`) входят в сессию-родителя. - Exit 1 — сессий нет. Сообщить владельцу slug и период и остановиться. - Битых строк больше 1 % — отметить в «Что не измерялось». 3. **Блокировки.** Сначала выгрузить: `python3 docs/retro/scripts/hook_blocks_export.py --since --id-prefix <каталоги из sources.dirs> > docs/retro/fixtures/hook-blocks-.jsonl`. Каталоги — `~/.claude/projects/<имя>`. @@ -21,13 +23,13 @@ description: Ретро обвязки claude-harness по транскрипт - Собрать корпус: `python3 docs/retro/scripts/hook_cases_build.py > skills/harness-doctor/fixtures/hook-cases.jsonl` и прогнать `bash tests/run.sh hook-corpus`. Если текущий хук даёт не тот вердикт — проставить записи `"known": "RT-n"` и завести находку. - Пересчитать срез с `--labels docs/retro/fixtures/hook-labels-.json --out docs/retro/.metrics.json`. 4. **Хвосты закрытия.** `python3 docs/retro/scripts/retro_leftovers.py <папка> --fetch --markdown`. Ничего не удалять: таблица идёт в черновик, уборка — по решению владельца. -5. **Поправки владельца.** `python3 docs/retro/scripts/retro_metrics.py <папка> [--since …] --corrections`. +5. **Поправки владельца.** `python3 docs/retro/scripts/retro_metrics.py <папка> --since --corrections`. - Отобрать только поправки по процессу: пропущенный шаг, лишний вопрос, неверный совет, обход правила, расширение задачи. Вопросы и просьбы по содержанию выбросить. - Сгруппировать по видам. - Вид, который повторяется (2 раза и больше) или стоил владельцу заметного времени, — находка `RT-n` и пункт бэклога. 6. **Качественная часть.** Прочитать память проекта целиком (`~/.claude/projects//memory/`) и примеры из выгрузок. Находки `RT-n` — сквозная нумерация: максимум в прошлых `docs/retro/*.md` плюс 1. Приоритет: P1 — теряет время каждую неделю или ломает процесс; P2 — трение; P3 — наблюдение. 7. **Черновик.** Скопировать `.claude/skills/harness-retro/template.md` в `docs/retro/.md` и заполнить. - - «Цифры»: `python3 docs/retro/scripts/retro_metrics.py <папка> [--since …] --labels … --markdown --prev docs/retro/<прошлое>.metrics.json`. + - «Цифры»: `python3 docs/retro/scripts/retro_metrics.py <папка> --from docs/retro/.metrics.json --markdown --prev docs/retro/<прошлое>.metrics.json` — таблица из сохранённого среза, без пересчёта. - Секретов в черновике быть не должно. Цитаты брать только из вывода скриптов: он уже прошёл `redact`. 8. **PR.** `bash tests/run.sh` должен быть зелёным. Коммит: черновик, `metrics.json`, выгрузка, разметка, корпус. Затем `git push -u origin retro/` и `tea pulls create` со ссылкой на issue. Merge — только владелец. diff --git a/docs/retro/2026-10-11.metrics.json b/docs/retro/2026-10-11.metrics.json index a3e7354..cda91e2 100644 --- a/docs/retro/2026-10-11.metrics.json +++ b/docs/retro/2026-10-11.metrics.json @@ -14,7 +14,7 @@ "prompts": 613, "interrupts": null, "turns": {"value": 12121, "note": "по строкам транскрипта, не по сообщениям"}, - "tool_calls": 6583, + "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, @@ -29,15 +29,15 @@ }, "compactions": 4, "start_context": {"median": 56000, "min": 52000, "max": 61000}, - "hook_blocks": 76, + "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": 6, + "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": 43, "explore": 15, "code_reads": null, "explore_share": null}, + "codegraph": {"init": {"value": 43, "note": "без сабагентов"}, "explore": {"value": 15, "note": "без сабагентов"}, "code_reads": null, "explore_share": null}, "corrections": null, - "recall": 9, + "recall": {"value": 9, "note": "широкий регэксп, со сводками компакции"}, "memory": {"index_bytes": null, "files": 47, "bytes": 183296}, "cd_main": {"retired": "хуки 0.4.0 не зависят от cwd (RT-2); считалось по именам репозиториев awp-ca, было 84"} } diff --git a/docs/retro/scripts/hook_blocks_export.py b/docs/retro/scripts/hook_blocks_export.py index 3183d54..161ba7b 100644 --- a/docs/retro/scripts/hook_blocks_export.py +++ b/docs/retro/scripts/hook_blocks_export.py @@ -6,8 +6,9 @@ первая строка ответа хука. Поле expected не заполняется: вердикт (block/allow) проставляется при разметке. Выгружаются только ошибки хуков (результат начинается с `PreToolUse:<инструмент> hook error`); вывод команд, в котором встретилось слово BLOCKED (файлы памяти, исходники хуков), не попадает. ---since — только сессии с первой меткой времени не раньше даты; --id-prefix (по умолчанию — дата --since) — -поле id вида <префикс>-NNN (срез ретро, B12). Похожие на секреты подстроки заменяются на . +Транскрипты сабагентов (<сессия>/subagents/*.jsonl) выгружаются с сессией-родителем. --since — только вызовы +с меткой времени не раньше даты; --id-prefix (по умолчанию — дата --since) — поле id вида <префикс>-NNN (срез +ретро, B12). Похожие на секреты подстроки заменяются на до обрезки. """ import glob import json @@ -31,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)" @@ -57,53 +64,53 @@ def _records(path): yield j -def first_ts(path): +def _blocks(path, sid, since): + """Блокировки одного транскрипта; с since — только вызовы с меткой времени не раньше даты.""" + pending = {} # tool_use_id -> (name, input, cwd, branch, ts) for j in _records(path): - if isinstance(j.get("timestamp"), str) and j["timestamp"]: - return j["timestamp"] - return "" + 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"))): - if since and first_ts(f)[:10] < since: - continue sid = os.path.basename(f)[:8] - pending = {} # tool_use_id -> (name, input, cwd, branch, ts) - for j in _records(f): - m = j.get("message") or {} - cont = m.get("content") - if j.get("type") == "assistant": - for b in cont or []: - if isinstance(b, dict) and b.get("type") == "tool_use": - pending[b.get("id")] = (b.get("name"), b.get("input") or {}, j.get("cwd", ""), - j.get("gitBranch", ""), j.get("timestamp", "")) - elif j.get("type") == "user" and isinstance(cont, list): - for b in cont: - if not (isinstance(b, dict) and b.get("type") == "tool_result"): - continue - txt = b.get("content", "") - if isinstance(txt, list): - txt = "\n".join(x.get("text", "") for x in txt if isinstance(x, dict)) - if not isinstance(txt, str) or not HOOK_ERROR.match(txt): - continue # обычный вывод команды, даже если в нём есть слово BLOCKED - name, inp, cwd, branch, ts = pending.get(b.get("tool_use_id"), ("?", {}, "", "", "")) - if name not in ("Bash", "Edit", "Write", "MultiEdit"): - continue - msg = next((l for l in txt.splitlines() if "BLOCKED" in l), "") - rec = { - "session": sid, "ts": ts, "cwd": cwd, "branch": branch, "tool": name, - "command": redact(inp.get("command", "")) if name == "Bash" else None, - "file_path": inp.get("file_path") if name != "Bash" else None, - "hook": hook_of(txt, name), "message": redact(msg[:300]), "expected": None, - } - if prefix: - n += 1 - rec = dict({"id": "%s-%03d" % (prefix, n)}, **rec) - yield rec + 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): diff --git a/docs/retro/scripts/hook_cases_build.py b/docs/retro/scripts/hook_cases_build.py index e63cf0a..8c91247 100644 --- a/docs/retro/scripts/hook_cases_build.py +++ b/docs/retro/scripts/hook_cases_build.py @@ -82,6 +82,11 @@ def why_of(cid, r): 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")) diff --git a/docs/retro/scripts/retro_metrics.py b/docs/retro/scripts/retro_metrics.py index 84cceea..07c99c8 100644 --- a/docs/retro/scripts/retro_metrics.py +++ b/docs/retro/scripts/retro_metrics.py @@ -20,17 +20,19 @@ import sys HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) -from hook_blocks_export import HOOK_ERROR, hook_of, redact # noqa: E402 +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"памят|перечитай|сверься|контекст|вспомни|прочитай .*md", re.I) +RECALL = re.compile(r"(перечитай|прочитай|сверься|вспомни|освежи)[^.?!\n]{0,40}?(памят|контекст|claude\.md|\.md\b)", + re.I) MARKERS = re.compile(r"(?/<сессия>/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.""" + """Транскрипты проекта: каталоги по префиксу slug, сессии по первому cwd. Период режется по записям (since).""" pre = slug(project) best = {} for d in sorted(glob.glob(os.path.join(root, "*"))): @@ -84,13 +95,10 @@ def project_sessions(project, root, since): best[sid] = f files = [] for f in sorted(best.values()): - cwd, ts = first_meta(f) - if not cwd or not (inside(cwd, project) or inside(cwd, project + ".wt")): - continue - if since and (not ts or ts[:10] < since): - continue - files.append(f) - return sorted(set(os.path.basename(os.path.dirname(f)) for f in files)), files + cwd, _ = first_meta(f) + if cwd and (inside(cwd, project) or inside(cwd, project + ".wt")): + files.append(f) + return files def blocks(rec): @@ -111,6 +119,8 @@ 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) @@ -164,17 +174,39 @@ def is_code(path, cache): return False pre = code_prefixes(repo, cache) if pre is None: - return "/src/" in "/" + rel + 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 part in SEPARATORS.split(cmd or ""): - try: - words = shlex.split(part) - except ValueError: - words = part.split() - if not words or not (words[0] in READERS or (words[0] == "sed" and "-n" in words[1:2])): + 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): @@ -202,7 +234,7 @@ def count_tool(s, b, cwd, cache): sk = inp.get("skill") or "?" s["skills"][sk] += 1 if s["role"] is None and (sk == "role" or sk.endswith(":role")): - s["role"] = (inp.get("args") or "").strip() or "?" + s["role"] = redact(str(inp.get("args") or "").strip()) or "?" if "codegraph_explore" in name: s["cg_explore"] += 1 elif name == "Read": @@ -215,20 +247,24 @@ def count_tool(s, b, cwd, cache): s["code_reads"] += bash_reads_code(cmd, cwd, cache) -def session_stats(path, cache, bad): +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 = set(), {} + seen, pending, cur, first_turn = set(), {}, "", True for rec in read_session(path, bad): ts = rec.get("timestamp") - if isinstance(ts, str) and ts > s["last_ts"]: - s["last_ts"] = ts + 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"] += 1 + s["compactions"] += live elif t == "assistant": msg = rec.get("message") or {} if rec.get("isApiErrorMessage") or msg.get("model") == "": @@ -237,20 +273,23 @@ def session_stats(path, cache, bad): if mid is None or mid not in seen: if mid is not None: seen.add(mid) - s["turns"] += 1 u = msg.get("usage") or {} - if u: - n = lambda k: u.get(k, 0) or 0 - if s["start"] is None: + 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 "?" - count_tool(s, b, rec.get("cwd"), cache) - elif t == "user": + 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 @@ -267,6 +306,11 @@ def session_stats(path, cache, bad): 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 @@ -314,10 +358,12 @@ def false_blocks(labels): "known": len(known)} -def corrections(path): - """Кандидаты в поправки владельца с последним действием и хвостом ответа агента перед ними.""" - out, act, reply, sid = [], "", "", os.path.basename(path)[:8] +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): @@ -326,7 +372,7 @@ def corrections(path): elif b.get("type") == "text" and (b.get("text") or "").strip(): reply = b["text"] continue - if t != "user": + if t != "user" or not in_period(cur, since): continue txt, kind = prompt_text(rec), None if txt is not None: @@ -337,8 +383,8 @@ def corrections(path): elif any(b.get("type") == "tool_result" and OWNER_DENY in result_text(b) for b in blocks(rec)): kind, txt = "denial", "отказ владельца в разрешении" if kind: - out.append({"session": sid, "ts": rec.get("timestamp") or "", "kind": kind, "text": redact(txt[:300]), - "action": redact(act[:300]), "reply_tail": redact(" ".join(reply.split())[-200:])}) + 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 @@ -437,22 +483,38 @@ def parse_args(argv): 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 or default_since(a.harness) - dirs, files = project_sessions(project, a.projects_root, since) - bad, cache = {"n": 0}, {} - stats = [s for s in (session_stats(f, cache, bad) for f in files) if s["prompts"] or s["turns"]] + 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 - cands = [c for f in files for c in corrections(f)] + 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)) @@ -472,7 +534,7 @@ def main(argv): json.dump(doc, fh, ensure_ascii=False, indent=2) fh.write("\n") if a.markdown: - print(to_markdown(doc, json.load(open(a.prev, encoding="utf-8")) if a.prev else None)) + print(to_markdown(doc, prev)) elif not a.out: print(json.dumps(doc, ensure_ascii=False, indent=2)) return 0 diff --git a/openspec/changes/harness-retro/design.md b/openspec/changes/harness-retro/design.md index a160171..4b6b1b6 100644 --- a/openspec/changes/harness-retro/design.md +++ b/openspec/changes/harness-retro/design.md @@ -67,7 +67,11 @@ retro_metrics.py <папка проекта> [--since ГГГГ-ММ-ДД] [--har Каждая метрика считается в одном месте, в `session_stats`, и проверяется отдельным тестом. ### 3. Сессии проекта -Каталоги-кандидаты — те, чьё имя начинается со slug проекта. В срез сессия входит по первому `cwd`: путь внутри проекта или внутри `<проект>.wt/` (старое место worktree). Префикс slug нужен, чтобы найти каталоги worktree; проверка `cwd` отсекает чужой проект `/p/repo-tools` при проекте `/p/repo`. +Каталоги-кандидаты — те, чьё имя начинается со slug проекта. Сессия принадлежит проекту по первому `cwd`: путь внутри проекта или внутри `<проект>.wt/` (старое место worktree). Префикс slug нужен, чтобы найти каталоги worktree; проверка `cwd` отсекает чужой проект `/p/repo-tools` при проекте `/p/repo`. + +Период режется по записям, а не по сессиям: запись входит, если её метка времени (у записи без метки — предыдущая) не раньше `since`. Сессия входит, если в ней есть ходы или промпты периода. Стартовый контекст — только если первый ход сессии в периоде. Так длинная сессия на стыке периодов (владелец держит одну сессию на много задач) делится между ретро, а не теряется. + +Транскрипты сабагентов `<каталог>/<сессия>/subagents/*.jsonl` (Claude Code пишет их отдельно, в основном транскрипте `isSidechain` нет) вливаются в сессию-родителя: ходы, токены, инструменты, codegraph, блокировки, отказы. Промпты, роль, стартовый контекст и кандидаты в поправки — только из основного транскрипта. ### 4. Что такое «код» Пути в транскриптах относятся к репозиториям, которых может уже не быть: worktree удалены. Относительный путь считается от корня worktree или репозитория. Корень определяется в таком порядке: @@ -78,7 +82,7 @@ retro_metrics.py <папка проекта> [--since ГГГГ-ММ-ДД] [--har Префиксы кода берутся из `.claude/code-paths` основного репозитория, без него — правило `src/` на любой глубине. Так же решает require-plan. -Bash-команда считается чтением кода, если хотя бы один её простой сегмент (разбиение по `&&`, `||`, `;`, `|`) начинается с `grep`, `rg`, `find`, `cat`, `sed -n` или `head` и хотя бы один аргумент после разбора `shlex` (при ошибке — `split`) указывает в каталог кода относительно `cwd`. +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`, функция импортируется. @@ -175,6 +179,12 @@ Bash-команда считается чтением кода, если хот | 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: ` — не ход модели: не считаются в ходах, токенах и стартовом контексте | Это сообщения Claude Code об ошибке API с нулевым `usage`; на живых транскриптах давали стартовый контекст 0 (O-18) | Если Claude Code пометит так настоящий ход, он выпадет из подсчёта | -| 2026-10-24 | SDD workspace в worktree неслитой ветки — не хвост | Живой прогон показал рабочий каталог текущего плана как хвост (O-19); это работа в процессе | Брошенная неслитая задача со своим SDD workspace не попадёт в хвосты (её видно по worktree) | +| 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/.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 не попадёт в метрику | diff --git a/openspec/changes/harness-retro/specs/harness-retro/spec.md b/openspec/changes/harness-retro/specs/harness-retro/spec.md index 18882be..b48adf3 100644 --- a/openspec/changes/harness-retro/specs/harness-retro/spec.md +++ b/openspec/changes/harness-retro/specs/harness-retro/spec.md @@ -25,12 +25,27 @@ - **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`. Без `--since` начало периода MUST браться из даты в имени последнего `docs/retro/ГГГГ-ММ-ДД*.md` репозитория обвязки. Если сессий нет, `retro_metrics.py` MUST завершаться с кодом 1 и сообщением, какой slug и период искались. +Запись SHALL входить в срез, если её метка времени (у записи без метки — предыдущая) не раньше `--since`; сессия — если в ней есть записи периода. Стартовый контекст сессии MUST считаться, только если её первый ход в периоде. Без `--since` начало периода MUST браться из `period.since` существующего `--out`, иначе из даты последнего `docs/retro/ГГГГ-ММ-ДД*.md`. Если сессий нет — код 1 и сообщение со slug и периодом. #### Scenario: Период по умолчанию -- **WHEN** последний ретро-документ — `docs/retro/2026-10-11.md` и `--since` не указан -- **THEN** в срез входят сессии с первой меткой времени 2026-10-11 и позже +- **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/.metrics.json`, создан черновик `docs/retro/.md`, и срез запускается снова с тем же `--out` без `--since` +- **THEN** период тот же, что в существующем `--out` #### Scenario: Нет транскриптов - **WHEN** ни одного каталога со slug проекта нет или в периоде нет ни одной сессии @@ -44,7 +59,7 @@ - **THEN** срез считается по остальным строкам, а `sources.bad_lines` равно 1 ### Requirement: Промпты владельца -Промпт владельца SHALL считаться так: сообщение пользователя без `tool_result`, не `isMeta`, не начинающееся с `<`. Прерывания `[Request interrupted by user]` MUST считаться отдельной метрикой, не промптами. +Промпт владельца SHALL считаться так: сообщение пользователя без `tool_result`, не `isMeta`, не сводка компакции (`isCompactSummary`), не начинающееся с `<`. Прерывания `[Request interrupted by user]` MUST считаться отдельной метрикой, не промптами. #### Scenario: Служебные сообщения не считаются промптами - **WHEN** в транскрипте есть сообщение пользователя с `tool_result`, сообщение с `isMeta` и строка `…` @@ -88,13 +103,25 @@ - **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//memory/MEMORY.md` в байтах, число и общий объём файлов в этом каталоге. Отсутствующий каталог MUST давать нули, а не ошибку. Отдельно SHALL считаться промпты «перечитай память/контекст». +Метрика памяти SHALL выводить размер `~/.claude/projects//memory/MEMORY.md` в байтах, число и общий объём файлов в этом каталоге. Отсутствующий каталог MUST давать нули, а не ошибку. Отдельно SHALL считаться промпты-побуждения перечитать: «перечитай», «прочитай», «сверься», «вспомни», «освежи» рядом с «память», «контекст» или `.md`. #### Scenario: Памяти нет - **WHEN** каталога памяти проекта нет - **THEN** метрики памяти равны нулю, срез завершается с кодом 0 +#### Scenario: Упоминание контекста — не побуждение +- **WHEN** промпт «я сейчас смотрю на размер контекста» и сводка компакции со словами «перечитай память» +- **THEN** ни то, ни другое не считается промптом «перечитай память/контекст» + ### Requirement: Кандидаты в поправки владельца `retro_metrics.py --corrections` SHALL выгружать кандидатов в поправки владельца по процессу. Кандидат — это: - промпт владельца со словом-признаком: «нет», «не так», «почему», «зачем», «опять», «снова», «я же», «стоп», «подожди», «не надо», «сначала», «верни», «откати»; @@ -119,14 +146,14 @@ - **THEN** у кандидата указано действие `Bash: git branch -m feat/x` ### Requirement: Сравнение с прошлым ретро -`retro_metrics.py --markdown --prev <прошлый>.metrics.json` SHALL печатать таблицу «Цифры» в три колонки: текущее значение, значение прошлого ретро, разница. Если метрики нет в прошлом файле или там `null`, MUST ставиться «—». Метрика, отмеченная в прошлом файле как больше не считаемая, MUST выводиться с этой пометкой. +`retro_metrics.py --markdown --prev <прошлый>.metrics.json` SHALL печатать таблицу «Цифры» в три колонки: текущее значение, значение прошлого ретро, разница. Если метрики нет в прошлом файле или там `null`, MUST ставиться «—»; снятая или иначе считавшаяся метрика — с пометкой. `--from ` MUST печатать таблицу из сохранённого среза без пересчёта. #### Scenario: Новая метрика - **WHEN** в прошлом файле нет метрики `corrections` - **THEN** строка поправок владельца показывает текущее значение и «—» в колонках прошлого и разницы ### Requirement: Секреты не попадают в черновик -Всё, что скрипты ретро выводят из транскриптов (промпты, команды, сообщения), SHALL проходить замену похожих на секреты подстрок на `` — той же функцией, что в `hook_blocks_export.py`. +Всё, что скрипты ретро выводят из транскриптов (промпты, команды, сообщения), SHALL проходить замену похожих на секреты подстрок на `` — той же функцией, что в `hook_blocks_export.py`, и MUST проходить её до обрезки, чтобы секрет на границе обрезки не утёк частично. #### Scenario: Токен в промпте - **WHEN** промпт-кандидат содержит `tea login add --token 0123456789abcdef0123456789abcdef01234567` @@ -161,18 +188,18 @@ - **THEN** ссылки `refs/remotes/` и рабочее дерево репозитория не меняются ### Requirement: Выгрузка блокировок среза -`hook_blocks_export.py --since <дата>` SHALL выгружать только блокировки сессий не раньше даты, с идентификаторами `<дата>-NNN`. Выгрузка среза MUST сохраняться в `docs/retro/fixtures/hook-blocks-<дата>.jsonl`; прежняя выгрузка 12.10 не меняется. +`hook_blocks_export.py --since <дата>` SHALL выгружать только блокировки вызовов не раньше даты, вместе с транскриптами сабагентов сессии, с идентификаторами `<дата>-NNN`. Выгрузка среза MUST сохраняться в `docs/retro/fixtures/hook-blocks-<дата>.jsonl`; прежняя выгрузка 12.10 не меняется. -#### Scenario: Ранняя сессия не выгружается -- **WHEN** у сессии первая метка времени раньше `--since` -- **THEN** её блокировки в выгрузку не попадают +#### 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` в корпус не входят. +`hook_cases_build.py` MUST добавлять по сегменту на каждую пару выгрузка + разметка и падать на выгрузке без разметки. Записи со `skip` в корпус не входят. #### Scenario: Размеченная выгрузка в корпусе - **WHEN** в `hook-labels-2026-11-01.json` три записи размечены и одна имеет `skip` diff --git a/tests/test-hook-corpus.sh b/tests/test-hook-corpus.sh index aee9b52..e2ccb8b 100644 --- a/tests/test-hook-corpus.sh +++ b/tests/test-hook-corpus.sh @@ -56,3 +56,9 @@ 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") diff --git a/tests/test-hook-export.sh b/tests/test-hook-export.sh index 4de0460..dbf41c6 100644 --- a/tests/test-hook-export.sh +++ b/tests/test-hook-export.sh @@ -18,8 +18,18 @@ 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: только сессия не раньше даты" "$(printf '%s\n' "$out" | grep -c .)" "1" -assert_eq "--id-prefix: id <дата>-NNN" "$(printf '%s\n' "$out" | python3 -c 'import json,sys; r=json.loads(sys.stdin.readline()); print(r["id"], r["command"])')" "2026-11-05-001 echo b > f" +assert_eq "--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)" "" diff --git a/tests/test-retro-metrics.sh b/tests/test-retro-metrics.sh index 3a16525..8861f8a 100644 --- a/tests/test-retro-metrics.sh +++ b/tests/test-retro-metrics.sh @@ -50,6 +50,35 @@ w(A, "s1.jsonl", [ {"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", [ @@ -77,44 +106,52 @@ python3 "$M" "$P/" --since 2026-11-01 --projects-root "$CR" --out "$J"; rc=$? assert_eq "срез: exit 0" "$rc" "0" q(){ python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); m=d["metrics"]; print(eval(sys.argv[2]))' "$J" "$1"; } assert_eq "схема 1" "$(q 'd["schema"]')" "1" -assert_eq "сессии: s1, s2 (worktree), s5 (.wt) — без старой и чужой" "$(q 'm["sessions"]')" "3" +assert_eq "сессии: 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"]')" "6" +assert_eq "промпты владельца без служебных, прерываний, сводки компакции, сабагента и записей до периода" "$(q 'm["prompts"]')" "9" assert_eq "прерывания" "$(q 'm["interrupts"]')" "1" -assert_eq "ходы — по message.id" "$(q 'm["turns"]')" "7" -assert_eq "вызовы инструментов" "$(q 'm["tool_calls"]')" "15" -assert_eq "токены вывода один раз на сообщение" "$(q 'm["tokens"]["output"]')" "10" -assert_eq "стартовый контекст" "$(q 'sorted(m["start_context"].items())')" "[('max', 61000), ('median', 56000), ('min', 51100)]" -assert_eq "блокировки только по ошибке хука" "$(q 'm["hook_blocks"], m["hook_blocks_by_hook"]')" "(1, {'guard-main(Bash)': 1})" -assert_eq "отказы классификатора по причине" "$(q 'm["classifier_by_reason"]')" "{'Self-Modification': 1}" +assert_eq "ходы — по 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 против чтения кода" "$(q 'sorted(m["codegraph"].items())')" "[('code_reads', 5), ('explore', 2), ('explore_share', 0.29), ('init', 1)]" -assert_eq "роли" "$(q 'sorted(m["roles"].items())')" "[('dev', 1), ('не выбрана', 2)]" +assert_eq "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"]')" "4" +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 "кандидатов 4" "$(grep -c . "$C")" "4" +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 "токен заменён на " '' "$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 "строка с разницей" '^\| Сессий \| 3 \| 44 \| -41 \|$' <(printf '%s' "$md") -assert_grep "новая метрика — прочерки" '^\| Кандидатов в поправки владельца \| 4 \| — \| — \|$' <(printf '%s' "$md") +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 "метрика с другим способом подсчёта" '^\| Ходов модели \| 7 \| 12 121 \(по строкам\) \| — \|$' <(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"}, @@ -124,8 +161,15 @@ python3 "$M" "$P" --since 2026-11-01 --projects-root "$CR" --labels "$TMP/labels 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" diff --git a/tests/test-retro-skill.sh b/tests/test-retro-skill.sh index 43346db..7ab7e6d 100644 --- a/tests/test-retro-skill.sh +++ b/tests/test-retro-skill.sh @@ -10,7 +10,8 @@ for sec in "## 1. Цифры" "## 2. Находки" "## 3. Поправки в assert_grep "шаблон: $sec" "^$sec" "$S/template.md" done J="$ROOT/docs/retro/2026-10-11.metrics.json" -assert_eq "метрики 11.10: схема, cd снят, ложные" "$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); m=d["metrics"]; print(d["schema"], "retired" in m["cd_main"], m["false_blocks"]["false"], m["hook_blocks"])' "$J")" "1 True 72 76" +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]) @@ -22,7 +23,7 @@ print(retro_metrics.to_markdown(doc, prev)) PY ) assert_grep "сравнение с 11.10: сессии" '^\| Сессий \| 3 \| 44 \| -41 \|$' <(printf '%s' "$out") -assert_grep "сравнение с 11.10: блокировки" '^\| Блокировок хуков \| 1 \| 76 \| -75 \|$' <(printf '%s' "$out") +assert_grep "сравнение с 11.10: блокировки считались без сабагентов" '^\| Блокировок хуков \| 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 нет)" "нет" From ced700e65f7140dc0cd3bae50b77079ad22a56f5 Mon Sep 17 00:00:00 2001 From: "andrey.epifantsev" Date: Sat, 24 Oct 2026 02:01:19 +0400 Subject: [PATCH 09/10] =?UTF-8?q?docs:=20CHANGELOG=200.6.0=20=E2=80=94=20?= =?UTF-8?q?=D1=81=D0=B0=D0=B1=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D1=8B,=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B8=D0=BE=D0=B4=20=D0=BF=D0=BE=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D0=B8=D1=81=D1=8F=D0=BC,=20--from=20(#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c391cf5..59c1cef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ - «перечитай», память; - кандидаты в поправки владельца по процессу (`--corrections`). - Заменяет `retro_stats.py` и `retro_blocks.py`. + Транскрипты сабагентов (`<сессия>/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 считались по строкам и помечены как несравнимые. From 132504520b80a42cb9da6e10f0729237bb356e03 Mon Sep 17 00:00:00 2001 From: "andrey.epifantsev" Date: Sat, 24 Oct 2026 10:07:26 +0400 Subject: [PATCH 10/10] =?UTF-8?q?feat(retro):=20=C2=AB=D1=81=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D0=B0=D0=B9=20=D1=81=D0=B0=D0=BC=C2=BB=20=E2=80=94=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=D0=B7=D0=BD=D0=B0=D0=BA=20=D0=BF=D0=BE=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20=D0=B2=D0=BB=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D1=86=D0=B0=20(H-1,=20PR=20#30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .claude/skills/harness-retro/SKILL.md | 2 +- docs/retro/scripts/retro_metrics.py | 4 ++-- openspec/changes/harness-retro/specs/harness-retro/spec.md | 2 +- tests/test-retro-metrics.sh | 2 ++ 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.claude/skills/harness-retro/SKILL.md b/.claude/skills/harness-retro/SKILL.md index 105d420..bedcc17 100644 --- a/.claude/skills/harness-retro/SKILL.md +++ b/.claude/skills/harness-retro/SKILL.md @@ -24,7 +24,7 @@ description: Ретро обвязки claude-harness по транскрипт - Пересчитать срез с `--labels docs/retro/fixtures/hook-labels-.json --out docs/retro/.metrics.json`. 4. **Хвосты закрытия.** `python3 docs/retro/scripts/retro_leftovers.py <папка> --fetch --markdown`. Ничего не удалять: таблица идёт в черновик, уборка — по решению владельца. 5. **Поправки владельца.** `python3 docs/retro/scripts/retro_metrics.py <папка> --since --corrections`. - - Отобрать только поправки по процессу: пропущенный шаг, лишний вопрос, неверный совет, обход правила, расширение задачи. Вопросы и просьбы по содержанию выбросить. + - Отобрать только поправки по процессу: пропущенный шаг, лишний вопрос, неверный совет, обход правила, расширение задачи, работа переложена на владельца («сделай сам»). Вопросы и просьбы по содержанию выбросить. - Сгруппировать по видам. - Вид, который повторяется (2 раза и больше) или стоил владельцу заметного времени, — находка `RT-n` и пункт бэклога. 6. **Качественная часть.** Прочитать память проекта целиком (`~/.claude/projects//memory/`) и примеры из выгрузок. Находки `RT-n` — сквозная нумерация: максимум в прошлых `docs/retro/*.md` плюс 1. Приоритет: P1 — теряет время каждую неделю или ломает процесс; P2 — трение; P3 — наблюдение. diff --git a/docs/retro/scripts/retro_metrics.py b/docs/retro/scripts/retro_metrics.py index 07c99c8..c3972e2 100644 --- a/docs/retro/scripts/retro_metrics.py +++ b/docs/retro/scripts/retro_metrics.py @@ -27,8 +27,8 @@ 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"(?&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]"