andrey.epifantsev 4bbf6ebb3a feat(retro): retro_leftovers.py — хвосты чеклиста закрытия по состоянию репозиториев (#29)
SDD workspace в worktree активной задачи хвостом не считается (O-19, живой прогон).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:25:14 +04:00

181 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

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