Merge pull request 'B12: /harness-retro — срез метрик, поправки владельца, хвосты закрытия, корпус по сегментам (#29, 0.6.0)' (#30) from feat/29-harness-retro into main
This commit is contained in:
commit
1697c77a83
40
.claude/skills/harness-retro/SKILL.md
Normal file
40
.claude/skills/harness-retro/SKILL.md
Normal file
@ -0,0 +1,40 @@
|
||||
---
|
||||
name: harness-retro
|
||||
description: Ретро обвязки claude-harness по транскриптам, памяти и репозиториям одного проекта или workspace — срез метрик, разметка блокировок хуков в корпус, хвосты закрытия, поправки владельца, черновик docs/retro/<дата>.md и PR. Запускать в сессии в репозитории обвязки по команде «/harness-retro <папка> [--since ГГГГ-ММ-ДД]» после среза.
|
||||
---
|
||||
|
||||
# /harness-retro
|
||||
|
||||
Аргумент — папка проекта или workspace, который изучаем. Сессия — в репозитории обвязки, роль dev. Дата ретро `<D>` — сегодня, `ГГГГ-ММ-ДД`. Скрипты — `docs/retro/scripts/`, все команды — из корня worktree ретро. Спека — `openspec/specs/harness-retro/spec.md`.
|
||||
|
||||
## Шаги
|
||||
|
||||
1. **Задача и worktree.** Issue «Ретро <D>: <проект>» (`tea issues create`). Затем `EnterWorktree` с именем `retro-<D>`, ветка `git branch -m retro/<D>`, `codegraph init` в worktree.
|
||||
2. **Срез.** Выполнить `python3 docs/retro/scripts/retro_metrics.py <папка> [--since …] --out docs/retro/<D>.metrics.json`.
|
||||
- Период `<since>` — `period.since` из `docs/retro/<D>.metrics.json`. На шагах 3 и 5 передавать его явно (`--since <since>`). Повторный запуск с тем же `--out` берёт период из него, поэтому черновик `<D>.md` период не сдвигает.
|
||||
- В срез входят записи периода, в том числе из длинных сессий, начатых раньше. Транскрипты сабагентов (`<сессия>/subagents/`) входят в сессию-родителя.
|
||||
- Exit 1 — сессий нет. Сообщить владельцу slug и период и остановиться.
|
||||
- Битых строк больше 1 % — отметить в «Что не измерялось».
|
||||
3. **Блокировки.** Сначала выгрузить: `python3 docs/retro/scripts/hook_blocks_export.py --since <since> --id-prefix <D> <каталоги из sources.dirs> > docs/retro/fixtures/hook-blocks-<D>.jsonl`. Каталоги — `~/.claude/projects/<имя>`.
|
||||
- Разметить каждую запись в `docs/retro/fixtures/hook-labels-<D>.json`:
|
||||
- `expected` (`allow|block`) и `why` по спеке `hook-guards`;
|
||||
- раскладку песочницы — по `gitBranch` сессий и выводу `git worktree list` и `git branch` в транскриптах;
|
||||
- если не восстановить — `skip` с причиной.
|
||||
- Собрать корпус: `python3 docs/retro/scripts/hook_cases_build.py > skills/harness-doctor/fixtures/hook-cases.jsonl` и прогнать `bash tests/run.sh hook-corpus`. Если текущий хук даёт не тот вердикт — проставить записи `"known": "RT-n"` и завести находку.
|
||||
- Пересчитать срез с `--labels docs/retro/fixtures/hook-labels-<D>.json --out docs/retro/<D>.metrics.json`.
|
||||
4. **Хвосты закрытия.** `python3 docs/retro/scripts/retro_leftovers.py <папка> --fetch --markdown`. Ничего не удалять: таблица идёт в черновик, уборка — по решению владельца.
|
||||
5. **Поправки владельца.** `python3 docs/retro/scripts/retro_metrics.py <папка> --since <since> --corrections`.
|
||||
- Отобрать только поправки по процессу: пропущенный шаг, лишний вопрос, неверный совет, обход правила, расширение задачи, работа переложена на владельца («сделай сам»). Вопросы и просьбы по содержанию выбросить.
|
||||
- Сгруппировать по видам.
|
||||
- Вид, который повторяется (2 раза и больше) или стоил владельцу заметного времени, — находка `RT-n` и пункт бэклога.
|
||||
6. **Качественная часть.** Прочитать память проекта целиком (`~/.claude/projects/<slug>/memory/`) и примеры из выгрузок. Находки `RT-n` — сквозная нумерация: максимум в прошлых `docs/retro/*.md` плюс 1. Приоритет: P1 — теряет время каждую неделю или ломает процесс; P2 — трение; P3 — наблюдение.
|
||||
7. **Черновик.** Скопировать `.claude/skills/harness-retro/template.md` в `docs/retro/<D>.md` и заполнить.
|
||||
- «Цифры»: `python3 docs/retro/scripts/retro_metrics.py <папка> --from docs/retro/<D>.metrics.json --markdown --prev docs/retro/<прошлое>.metrics.json` — таблица из сохранённого среза, без пересчёта.
|
||||
- Секретов в черновике быть не должно. Цитаты брать только из вывода скриптов: он уже прошёл `redact`.
|
||||
8. **PR.** `bash tests/run.sh` должен быть зелёным. Коммит: черновик, `metrics.json`, выгрузка, разметка, корпус. Затем `git push -u origin retro/<D>` и `tea pulls create` со ссылкой на issue. Merge — только владелец.
|
||||
|
||||
## Не делать
|
||||
|
||||
- Не удалять хвосты и не чинить хуки в PR ретро — это находки и бэклог.
|
||||
- Не заполнять раздел «Решения владельца»: он пишется на ревью PR.
|
||||
- Не вставлять в черновик сырые куски транскриптов мимо скриптов.
|
||||
49
.claude/skills/harness-retro/template.md
Normal file
49
.claude/skills/harness-retro/template.md
Normal file
@ -0,0 +1,49 @@
|
||||
# Ретроспектива обвязки claude-harness — <ДД.ММ.ГГГГ>
|
||||
|
||||
Период: <since>–<until>, полигон — <проект или workspace>.
|
||||
Предыдущая точка сравнения — `docs/retro/<прошлое>.md`.
|
||||
|
||||
Источники:
|
||||
- <N> транскриптов сессий (<каталоги из sources.dirs>, <объём>);
|
||||
- <N> файлов auto-memory (<объём>);
|
||||
- состояние репозиториев (`retro_leftovers.py`);
|
||||
- блокировки среза, размеченные в корпус: `docs/retro/fixtures/hook-labels-<D>.json`.
|
||||
|
||||
Количественную часть считал `docs/retro/scripts/retro_metrics.py`, качественную — по прочтению памяти целиком и кандидатов в поправки.
|
||||
|
||||
Маркировка находок: `RT-n`, продолжение нумерации прошлых ретро. Приоритет: P1 — теряет время каждую неделю или ломает процесс; P2 — трение; P3 — наблюдение.
|
||||
|
||||
## 1. Цифры
|
||||
|
||||
<таблица из retro_metrics.py --markdown --prev>
|
||||
|
||||
Хвосты чеклиста закрытия:
|
||||
|
||||
<таблица из retro_leftovers.py --markdown>
|
||||
|
||||
## 2. Находки
|
||||
|
||||
**RT-<n> (P<k>). <Одна фраза — что происходит>.** <Факты: где, сколько раз, чем стоило>. Источники: <сессии, память, метрика>.
|
||||
|
||||
## 3. Поправки владельца
|
||||
|
||||
| Вид | Сколько | Пример (цитата, что было до неё) | Что вышло | Находка |
|
||||
|---|---|---|---|---|
|
||||
|
||||
## 4. Что сработало (сохранить)
|
||||
|
||||
- <…>
|
||||
|
||||
## 5. Бэклог улучшений (по приоритету)
|
||||
|
||||
| # | Что | Находки | Оценка |
|
||||
|---|---|---|---|
|
||||
|
||||
## 6. Решения владельца (<дата>)
|
||||
|
||||
Заполняется на ревью PR.
|
||||
|
||||
## 7. Что не измерялось
|
||||
|
||||
- Сторона Gitea чеклиста закрытия: открытые issue после слитого PR, пропущенные follow-up.
|
||||
- <…>
|
||||
14
CHANGELOG.md
14
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`).
|
||||
|
||||
Транскрипты сабагентов (`<сессия>/subagents/`) входят в сессию-родителя; период режется по меткам времени записей, поэтому длинная сессия делится между ретро. Без `--since` период берётся из существующего `--out`, `--from` рисует «Цифры» из сохранённого среза. Заменяет `retro_stats.py` и `retro_blocks.py`.
|
||||
- `docs/retro/scripts/retro_leftovers.py` ищет хвосты чеклиста закрытия по состоянию репозиториев: слитые ветки на сервере и локально, worktree, SDD workspace закрытых задач, неархивные changes, отставший или грязный main.
|
||||
- Корпус хуков разбит на сегменты со своей раскладкой; записи `known` — известные ошибки хуков (`KNOWN-FIXED` — пора снять пометку), doctor показывает их число. `hook_blocks_export.py --since --id-prefix` выгружает блокировки среза, `hook_cases_build.py` собирает корпус из `hook-labels-<дата>.json`.
|
||||
- `docs/retro/2026-10-11.metrics.json` — метрики ретро 11.10, перенесённые из таблицы. Блокировки — 76 ошибок хука, из них 72 ложные (O-1). Ходы и токены 11.10 считались по строкам и помечены как несравнимые.
|
||||
|
||||
## 0.5.1 — 2026-10-24
|
||||
- Worktree живут в `<repo>/.claude/worktrees/<slug>`, а не в соседнем каталоге: их создаёт `EnterWorktree` с именем, ветка `worktree-<slug>` переименовывается в `feat/<id>-<slug>` (#25). Вход в worktree вне `.claude/worktrees/` спрашивает подтверждение при любых allow-правилах (Claude Code ≥ 2.1.206). Описано в `roles/dev.md` и `HARNESS.md`.
|
||||
- `.claude/worktrees/` — в `.gitignore` проектов: `adopt-project.sh`, `project-template/gitignore`, этот репозиторий. `doctor.sh`: `WARN .claude/worktrees/ не в .gitignore`.
|
||||
|
||||
@ -18,7 +18,7 @@
|
||||
- `roles/` — общий слой ролей (формат: четыре раздела, см. спеку ролей), `skills/role/` — skill `/role`, `agents/briefing.md` — агент выжимки, `link-roles.sh` — симлинки workspace → репозитории направлений. Ставятся `install.sh` в `~/.claude/{roles,skills/role,agents,harness}`.
|
||||
- `tests/` — тесты пакета на bash: `run.sh` + `test-*.sh`, работают с источниками, `install.sh` ставят во временный `CLAUDE_CONFIG_DIR`.
|
||||
- `caveman/config.json` — выключенный по умолчанию caveman. `examples/` — реальный `CLAUDE.md` проекта-пилота.
|
||||
- `docs/HARNESS.md` — как устроен цикл фичи, SDD, маркировка, закрытие. `docs/PILOT-2026-09-27.md` — пилот. `docs/retro/` — ретроспектива 11.10.2026 и скрипты подсчёта по транскриптам. `docs/superpowers/specs/` — спеки фич обвязки, `docs/superpowers/plans/` — планы.
|
||||
- `docs/HARNESS.md` — как устроен цикл фичи, SDD, маркировка, закрытие. `docs/PILOT-2026-09-27.md` — пилот. `docs/retro/` — ретроспективы, их метрики (`<дата>.metrics.json`) и скрипты: `retro_metrics.py` (срез метрик и поправки владельца), `retro_leftovers.py` (хвосты закрытия), `hook_blocks_export.py` и `hook_cases_build.py` (блокировки → корпус); проектный skill `/harness-retro` — `.claude/skills/harness-retro/`. `docs/superpowers/specs/` — спеки фич обвязки, `docs/superpowers/plans/` — планы.
|
||||
- Связь слоёв: хуки читают JSON вызова из stdin и состояние git проекта; скрипты бутстрапа читают `project-template/`; `doctor.sh` читает установленные копии в `~/.claude`, не репозиторий.
|
||||
|
||||
## Команды
|
||||
@ -28,13 +28,13 @@
|
||||
- `bash skills/harness-doctor/scripts/doctor.sh [проект]` — отчёт и самотест хуков. Только чтение.
|
||||
- `bash adopt-project.sh <папка> [--no-merge] [--push]` — подключить существующий проект. Из терминала, не из Claude Code.
|
||||
- `bash new-project.sh <папка> [ssh-url]` — каркас нового проекта.
|
||||
- `python3 docs/retro/scripts/retro_stats.py <каталог транскриптов>` и `retro_blocks.py` — статистика сессий и блокировок хуков для ретро; `hook_blocks_export.py <каталог> [...]` — выгрузка блокировок в JSONL (сырьё для тестов хуков); `hook_cases_build.py > skills/harness-doctor/fixtures/hook-cases.jsonl` — сборка размеченного корпуса из выгрузки.
|
||||
- `/harness-retro <папка проекта> [--since ГГГГ-ММ-ДД]` — ретро обвязки (в сессии в этом репозитории). Под ним: `python3 docs/retro/scripts/retro_metrics.py <папка> [--since …] [--out …] [--markdown --prev …] [--corrections]` — срез метрик; `retro_leftovers.py <папка> [--fetch] [--markdown]` — хвосты закрытия; `hook_blocks_export.py [--since …] [--id-prefix …] <каталог> [...]` — выгрузка блокировок в JSONL; `hook_cases_build.py > skills/harness-doctor/fixtures/hook-cases.jsonl` — сборка корпуса из разметки.
|
||||
- `python3 skills/harness-doctor/scripts/hook_corpus.py hooks [--summary]` — корпус хуков по источникам. `python3 hooks/harness_target.py targets '<команда>' [cwd]` — какие цели записи видит хук.
|
||||
|
||||
## Тесты
|
||||
- `tests/run.sh` — автоматические тесты источников (хуки через фиктивный JSON, скрипты во временных каталогах, размеры). Самотест хуков в `doctor.sh` проверяет установленные копии; перед ним `bash install.sh`.
|
||||
- Ручная проверка хука: на main в любом репозитории попросить агента создать файл, ожидается `BLOCKED`.
|
||||
- Хуки: `test-hook-parse` — разбор команды на цели; `test-hook-guard-main`, `test-hook-require-plan` — сценарии спеки `hook-guards` в песочнице из настоящих репозиториев; `test-hook-corpus` — корпус реальных команд (assert на запись, корпус должен совпадать со сборкой `hook_cases_build.py`); `test-hook-export` — экстрактор. Плюс `role-hint` и скрипты `install.sh`, `doctor.sh`, `link-roles.sh`.
|
||||
- Хуки: `test-hook-parse` — разбор команды на цели; `test-hook-guard-main`, `test-hook-require-plan` — сценарии спеки `hook-guards` в песочнице из настоящих репозиториев; `test-hook-corpus` — корпус реальных команд (assert на запись, корпус должен совпадать со сборкой `hook_cases_build.py`); `test-hook-export` — экстрактор. Ретро: `test-retro-metrics` (синтетические транскрипты), `test-retro-leftovers` (песочница репозиториев), `test-retro-skill`. Плюс `role-hint` и скрипты `install.sh`, `doctor.sh`, `link-roles.sh`.
|
||||
- Правишь разбор или вердикт — сначала случай в `test-hook-parse`/`test-hook-guard-main`; меняется ожидаемый вердикт реальной команды — разметка в `hook_cases_build.py` и пересборка корпуса.
|
||||
|
||||
## Текущая работа
|
||||
@ -44,7 +44,8 @@
|
||||
- Сделано: B3 — allow-правило `git push origin --delete` печатают adopt/new-project, проверяет doctor (issue #22, 0.4.2). B4 закрыт в #6 (решение владельца 1). B10 — workspace-хук awp-ca, в части 2 issue #2.
|
||||
- Сделано: allow-правила цикла фичи ставит `install.sh` (issue #24, 0.5.0; пересмотр решения владельца 2).
|
||||
- Сделано: worktree — `EnterWorktree` в `<repo>/.claude/worktrees/<slug>`, вход без подтверждения (issue #25, 0.5.1). Для awp-ca вопрос открыт в части 2 issue #2.
|
||||
- Дальше: B12.
|
||||
- Сделано: B12 — `/harness-retro`: срез метрик, поправки владельца, хвосты закрытия, корпус по сегментам (issue #29, 0.6.0), спека — `openspec/specs/harness-retro/`.
|
||||
- Дальше: первое ретро `/harness-retro` по полигону — на машине с awp-ca; остальные пункты бэклога — по мере надобности.
|
||||
|
||||
## OpenSpec и superpowers
|
||||
- Вход в любую задачу — superpowers:brainstorming. Skills openspec-propose и openspec-explore сами по себе не запускать.
|
||||
|
||||
@ -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 результаты пилота и находки
|
||||
```
|
||||
|
||||
@ -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): один контекст, одно ревью в конце.
|
||||
|
||||
44
docs/retro/2026-10-11.metrics.json
Normal file
44
docs/retro/2026-10-11.metrics.json
Normal file
@ -0,0 +1,44 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"period": {"since": "2026-10-02", "until": "2026-10-11"},
|
||||
"project": "/Users/andrey.epifancev/projects/awp-ca",
|
||||
"sources": {
|
||||
"dirs": ["-Users-andrey-epifancev-projects-awp-ca"],
|
||||
"sessions": 44,
|
||||
"bytes": 881852416,
|
||||
"bad_lines": null,
|
||||
"note": "перенесено вручную из таблицы «Цифры» docs/retro/2026-10-11.md (#29); 47 транскриптов, 841 МБ"
|
||||
},
|
||||
"metrics": {
|
||||
"sessions": 44,
|
||||
"prompts": 613,
|
||||
"interrupts": null,
|
||||
"turns": {"value": 12121, "note": "по строкам транскрипта, не по сообщениям"},
|
||||
"tool_calls": {"value": 6583, "note": "без сабагентов"},
|
||||
"tools": {"Bash": 4302, "Playwright": 1100, "Read": 346, "Write": 271, "Edit": 233},
|
||||
"roles": {"dev": 32, "admin": 7, "qa": 5},
|
||||
"subagents": 61,
|
||||
"subagent_models": {"sonnet": 47, "opus": 9, "не указана": 5},
|
||||
"skills": {"brainstorming": 19, "writing-plans": 14, "systematic-debugging": 8, "subagent-driven-development": 4,
|
||||
"executing-plans": 3, "using-git-worktrees": 3, "test-driven-development": 1,
|
||||
"finishing-a-development-branch": 1},
|
||||
"tokens": {
|
||||
"output": {"value": 18300000, "note": "по строкам транскрипта"},
|
||||
"cache_read": {"value": 3820000000, "note": "по строкам транскрипта"},
|
||||
"cache_write": {"value": 145000000, "note": "по строкам транскрипта"}
|
||||
},
|
||||
"compactions": 4,
|
||||
"start_context": {"median": 56000, "min": 52000, "max": 61000},
|
||||
"hook_blocks": {"value": 76, "note": "без сабагентов"},
|
||||
"hook_blocks_by_hook": null,
|
||||
"false_blocks": {"labelled": 76, "skipped": 0, "false": 72, "share": 0.95, "fixed": 72, "known": 0},
|
||||
"classifier_denials": {"value": 6, "note": "без сабагентов"},
|
||||
"classifier_by_reason": {"Git Destructive": 2, "Auto-Mode Bypass": 2, "Interfere With Workloads": 1, "safety-check": 1},
|
||||
"owner_denials": 6,
|
||||
"codegraph": {"init": {"value": 43, "note": "без сабагентов"}, "explore": {"value": 15, "note": "без сабагентов"}, "code_reads": null, "explore_share": null},
|
||||
"corrections": null,
|
||||
"recall": {"value": 9, "note": "широкий регэксп, со сводками компакции"},
|
||||
"memory": {"index_bytes": null, "files": 47, "bytes": 183296},
|
||||
"cd_main": {"retired": "хуки 0.4.0 не зависят от cwd (RT-2); считалось по именам репозиториев awp-ca, было 84"}
|
||||
}
|
||||
}
|
||||
@ -1,14 +1,20 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Выгрузка блокировок хуков из транскриптов Claude Code в JSONL — сырьё для регрессионных тестов хуков (B2).
|
||||
|
||||
Запуск: python3 hook_blocks_export.py <каталог-транскриптов> [<каталог> ...] > blocks.jsonl
|
||||
Запуск: python3 hook_blocks_export.py [--since ГГГГ-ММ-ДД] [--id-prefix <дата>] <каталог-транскриптов> [...]
|
||||
Одна строка — одна блокировка: сессия, время, cwd и ветка на момент вызова, инструмент, команда или путь файла,
|
||||
первая строка ответа хука. Поле expected не заполняется: вердикт (block/allow) проставляется при разметке.
|
||||
Выгружаются только ошибки хуков (результат начинается с `PreToolUse:<инструмент> hook error`); вывод команд,
|
||||
в котором встретилось слово BLOCKED (файлы памяти, исходники хуков), не попадает.
|
||||
Похожие на секреты подстроки заменяются на <redacted>.
|
||||
Транскрипты сабагентов (<сессия>/subagents/*.jsonl) выгружаются с сессией-родителем. --since — только вызовы
|
||||
с меткой времени не раньше даты; --id-prefix (по умолчанию — дата --since) — поле id вида <префикс>-NNN (срез
|
||||
ретро, B12). Похожие на секреты подстроки заменяются на <redacted> до обрезки.
|
||||
"""
|
||||
import json, sys, glob, os, re
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
HOOK_ERROR = re.compile(r"\s*PreToolUse:\S+ hook error")
|
||||
|
||||
@ -26,6 +32,12 @@ def redact(s):
|
||||
return RE_SECRET.sub(sub, s)
|
||||
|
||||
|
||||
def clip(s, n, tail=False):
|
||||
"""redact, затем обрезка: секрет на границе обрезки не утекает частично."""
|
||||
s = redact(s or "")
|
||||
return s[-n:] if tail else s[:n]
|
||||
|
||||
|
||||
def hook_of(txt, tool):
|
||||
if "[guard-main]" in txt:
|
||||
return "guard-main(Bash)" if tool == "Bash" else "guard-main(Edit/Write)"
|
||||
@ -42,38 +54,80 @@ def hook_of(txt, tool):
|
||||
return "other"
|
||||
|
||||
|
||||
for d in sys.argv[1:]:
|
||||
for f in sorted(glob.glob(os.path.join(os.path.expanduser(d), "*.jsonl"))):
|
||||
sid = os.path.basename(f)[:8]
|
||||
pending = {} # tool_use_id -> (name, input, cwd, branch, ts)
|
||||
for line in open(f, encoding="utf-8", errors="replace"):
|
||||
try:
|
||||
j = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
m = j.get("message") or {}
|
||||
cont = m.get("content")
|
||||
if j.get("type") == "assistant":
|
||||
for b in cont or []:
|
||||
if isinstance(b, dict) and b.get("type") == "tool_use":
|
||||
pending[b.get("id")] = (b.get("name"), b.get("input") or {}, j.get("cwd", ""),
|
||||
j.get("gitBranch", ""), j.get("timestamp", ""))
|
||||
elif j.get("type") == "user" and isinstance(cont, list):
|
||||
for b in cont:
|
||||
if not (isinstance(b, dict) and b.get("type") == "tool_result"):
|
||||
continue
|
||||
txt = b.get("content", "")
|
||||
if isinstance(txt, list):
|
||||
txt = "\n".join(x.get("text", "") for x in txt if isinstance(x, dict))
|
||||
if not HOOK_ERROR.match(txt):
|
||||
continue # обычный вывод команды, даже если в нём есть слово BLOCKED
|
||||
name, inp, cwd, branch, ts = pending.get(b.get("tool_use_id"), ("?", {}, "", "", ""))
|
||||
if name not in ("Bash", "Edit", "Write", "MultiEdit"):
|
||||
continue
|
||||
msg = next((l for l in txt.splitlines() if "BLOCKED" in l), "")
|
||||
print(json.dumps({
|
||||
"session": sid, "ts": ts, "cwd": cwd, "branch": branch, "tool": name,
|
||||
"command": redact(inp.get("command", "")) if name == "Bash" else None,
|
||||
"file_path": inp.get("file_path") if name != "Bash" else None,
|
||||
"hook": hook_of(txt, name), "message": redact(msg[:300]), "expected": None,
|
||||
}, ensure_ascii=False))
|
||||
def _records(path):
|
||||
for line in open(path, encoding="utf-8", errors="replace"):
|
||||
try:
|
||||
j = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if isinstance(j, dict):
|
||||
yield j
|
||||
|
||||
|
||||
def _blocks(path, sid, since):
|
||||
"""Блокировки одного транскрипта; с since — только вызовы с меткой времени не раньше даты."""
|
||||
pending = {} # tool_use_id -> (name, input, cwd, branch, ts)
|
||||
for j in _records(path):
|
||||
m = j.get("message") or {}
|
||||
cont = m.get("content")
|
||||
if j.get("type") == "assistant":
|
||||
for b in cont or []:
|
||||
if isinstance(b, dict) and b.get("type") == "tool_use":
|
||||
pending[b.get("id")] = (b.get("name"), b.get("input") or {}, j.get("cwd", ""),
|
||||
j.get("gitBranch", ""), j.get("timestamp", ""))
|
||||
elif j.get("type") == "user" and isinstance(cont, list):
|
||||
for b in cont:
|
||||
if not (isinstance(b, dict) and b.get("type") == "tool_result"):
|
||||
continue
|
||||
txt = b.get("content", "")
|
||||
if isinstance(txt, list):
|
||||
txt = "\n".join(x.get("text", "") for x in txt if isinstance(x, dict))
|
||||
if not isinstance(txt, str) or not HOOK_ERROR.match(txt):
|
||||
continue # обычный вывод команды, даже если в нём есть слово BLOCKED
|
||||
name, inp, cwd, branch, ts = pending.get(b.get("tool_use_id"), ("?", {}, "", "", ""))
|
||||
if name not in ("Bash", "Edit", "Write", "MultiEdit"):
|
||||
continue
|
||||
if since and (ts or "")[:10] < since:
|
||||
continue
|
||||
msg = next((l for l in txt.splitlines() if "BLOCKED" in l), "")
|
||||
yield {
|
||||
"session": sid, "ts": ts, "cwd": cwd, "branch": branch, "tool": name,
|
||||
"command": redact(inp.get("command", "")) if name == "Bash" else None,
|
||||
"file_path": inp.get("file_path") if name != "Bash" else None,
|
||||
"hook": hook_of(txt, name), "message": clip(msg, 300), "expected": None,
|
||||
}
|
||||
|
||||
|
||||
def export(dirs, since=None, prefix=None):
|
||||
"""Записи выгрузки по каталогам транскриптов: сессия, затем её сабагенты (<сессия>/subagents/*.jsonl)."""
|
||||
n = 0
|
||||
for d in dirs:
|
||||
for f in sorted(glob.glob(os.path.join(os.path.expanduser(d), "*.jsonl"))):
|
||||
sid = os.path.basename(f)[:8]
|
||||
subs = sorted(glob.glob(os.path.join(f[:-len(".jsonl")], "subagents", "*.jsonl")))
|
||||
for path in [f] + subs:
|
||||
for rec in _blocks(path, sid, since):
|
||||
if prefix:
|
||||
n += 1
|
||||
rec = dict({"id": "%s-%03d" % (prefix, n)}, **rec)
|
||||
yield rec
|
||||
|
||||
|
||||
def main(argv):
|
||||
args, dirs, since, prefix = argv[1:], [], None, None
|
||||
i = 0
|
||||
while i < len(args):
|
||||
if args[i] == "--since" and i + 1 < len(args):
|
||||
since, i = args[i + 1], i + 2
|
||||
elif args[i] == "--id-prefix" and i + 1 < len(args):
|
||||
prefix, i = args[i + 1], i + 2
|
||||
else:
|
||||
dirs.append(args[i])
|
||||
i += 1
|
||||
for rec in export(dirs, since, prefix or since):
|
||||
print(json.dumps(rec, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
|
||||
@ -2,19 +2,22 @@
|
||||
"""Сборка регрессионного корпуса хуков из сырой выгрузки блокировок (B2, #6).
|
||||
|
||||
Запуск из корня репозитория:
|
||||
python3 docs/retro/scripts/hook_cases_build.py > skills/harness-doctor/fixtures/hook-cases.jsonl
|
||||
Вход: docs/retro/fixtures/hook-blocks.jsonl (hook_blocks_export.py, 12.10.2026, 126 записей).
|
||||
Выход: первая строка — {"home": …, "layout": […]} (раскладка песочницы), дальше по записи на случай:
|
||||
поля выгрузки + id, expected (allow|block), why (класс). Вердикт размечен вручную по спеке
|
||||
python3 docs/retro/scripts/hook_cases_build.py [--fixtures-dir <каталог>] > skills/harness-doctor/fixtures/hook-cases.jsonl
|
||||
Вход: <fixtures>/hook-blocks.jsonl (hook_blocks_export.py, 12.10.2026, 126 записей) и пары ретро
|
||||
hook-blocks-<дата>.jsonl + hook-labels-<дата>.json (B12, #29).
|
||||
Выход — сегменты: строка {"home": …, "layout": […]} (раскладка песочницы), дальше по записи на случай:
|
||||
поля выгрузки + id, expected (allow|block), why (класс)[, known]. Первый сегмент — пилот 12.10 и живые случаи,
|
||||
дальше по сегменту на пару ретро; записи со skip не входят. Вердикт размечен вручную по спеке
|
||||
openspec/specs/hook-guards/spec.md, а не прогоном хука.
|
||||
"""
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
RAW = os.path.join(HERE, "..", "fixtures", "hook-blocks.jsonl")
|
||||
FIX = os.path.join(HERE, "..", "fixtures")
|
||||
HOME = "/Users/andrey.epifancev"
|
||||
AW, CH = "projects/awp-ca", "projects/cc-skills/claude-harness"
|
||||
|
||||
@ -77,9 +80,40 @@ def why_of(cid, r):
|
||||
return "devnull" if DEVNULL.search(r.get("command") or "") else "read-only"
|
||||
|
||||
|
||||
def main():
|
||||
def label_segments(fix):
|
||||
"""Сегменты ретро: пары hook-blocks-<дата>.jsonl + hook-labels-<дата>.json. Разметка должна совпадать с выгрузкой."""
|
||||
for bp in sorted(glob.glob(os.path.join(fix, "hook-blocks-*.jsonl"))):
|
||||
date = os.path.basename(bp)[len("hook-blocks-"):-len(".jsonl")]
|
||||
if not os.path.isfile(os.path.join(fix, "hook-labels-%s.json" % date)):
|
||||
raise SystemExit("%s: нет разметки hook-labels-%s.json — ретро не уходит с неразмеченными блокировками"
|
||||
% (os.path.basename(bp), date))
|
||||
for lp in sorted(glob.glob(os.path.join(fix, "hook-labels-*.json"))):
|
||||
date = os.path.basename(lp)[len("hook-labels-"):-len(".json")]
|
||||
lab = json.load(open(lp, encoding="utf-8"))
|
||||
cases = lab.get("cases", {})
|
||||
recs = [json.loads(l) for l in open(os.path.join(fix, "hook-blocks-%s.jsonl" % date), encoding="utf-8")
|
||||
if l.strip()]
|
||||
ids = [r.get("id") for r in recs]
|
||||
missing, extra = [i for i in ids if i not in cases], sorted(set(cases) - set(ids))
|
||||
if missing or extra:
|
||||
raise SystemExit("hook-labels-%s.json: нет разметки для %s; лишняя разметка %s"
|
||||
% (date, ", ".join(missing) or "—", ", ".join(extra) or "—"))
|
||||
yield {"home": lab["home"], "layout": lab["layout"]}
|
||||
for r in recs:
|
||||
c = cases[r["id"]]
|
||||
if "skip" in c:
|
||||
continue
|
||||
r.update({"expected": c["expected"], "why": c["why"]})
|
||||
if c.get("known"):
|
||||
r["known"] = c["known"]
|
||||
yield r
|
||||
|
||||
|
||||
def main(argv):
|
||||
fix = argv[argv.index("--fixtures-dir") + 1] if "--fixtures-dir" in argv else FIX
|
||||
print(json.dumps({"home": HOME, "layout": LAYOUT}, ensure_ascii=False))
|
||||
for n, line in enumerate(l for l in open(RAW, encoding="utf-8") if l.strip()):
|
||||
raw = os.path.join(fix, "hook-blocks.jsonl")
|
||||
for n, line in enumerate(l for l in open(raw, encoding="utf-8") if l.strip()):
|
||||
r = json.loads(line)
|
||||
cid = "c%03d" % (n + 1)
|
||||
r.update({"id": cid, "expected": "block" if cid in BLOCK else "allow", "why": why_of(cid, r)})
|
||||
@ -88,7 +122,9 @@ def main():
|
||||
print(json.dumps({"id": cid, "session": "live-1015", "cwd": HOME + "/" + CH, "branch": "main",
|
||||
"tool": "Bash", "command": cmd, "file_path": None, "hook": "guard-main(Bash)",
|
||||
"message": "", "expected": "allow", "why": why}, ensure_ascii=False))
|
||||
for obj in label_segments(fix):
|
||||
print(json.dumps(obj, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main(sys.argv)
|
||||
|
||||
@ -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}")
|
||||
154
docs/retro/scripts/retro_leftovers.py
Normal file
154
docs/retro/scripts/retro_leftovers.py
Normal file
@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Хвосты чеклиста закрытия по текущему состоянию репозиториев проекта или workspace (B12, #29).
|
||||
|
||||
Запуск: python3 retro_leftovers.py <папка проекта или workspace> [--fetch] [--markdown]
|
||||
Репозитории: сам git-проект или каталоги из таблицы «| Каталог | Репозиторий | Роль |» в CLAUDE.md workspace.
|
||||
Хвост: слитая в origin/main ветка на сервере или локально, worktree на ней или prunable, .superpowers/sdd/*,
|
||||
неархивный openspec/changes/<id>/ в origin/main, основной checkout позади origin/main или с изменениями.
|
||||
Ничего не меняет; --fetch делает только git fetch --prune origin. Спека: openspec/specs/harness-retro/spec.md.
|
||||
"""
|
||||
import collections
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
TABLE = re.compile(r"^\|\s*Каталог\s*\|\s*Репозиторий\s*\|\s*Роль\s*\|")
|
||||
PROTECTED = ("main", "master")
|
||||
|
||||
|
||||
def git(repo, *args):
|
||||
env = {k: v for k, v in os.environ.items() if k not in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE")}
|
||||
r = subprocess.run(["git", "-C", repo] + list(args), stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
||||
universal_newlines=True, env=env)
|
||||
return r.stdout if r.returncode == 0 else None
|
||||
|
||||
|
||||
def lines(s):
|
||||
return [l.strip() for l in (s or "").splitlines() if l.strip()]
|
||||
|
||||
|
||||
def repos_of(project):
|
||||
project = os.path.abspath(project)
|
||||
if os.path.exists(os.path.join(project, ".git")):
|
||||
return [project]
|
||||
md, out, on = os.path.join(project, "CLAUDE.md"), [], False
|
||||
if not os.path.isfile(md):
|
||||
return out
|
||||
for line in open(md, encoding="utf-8", errors="replace"):
|
||||
if TABLE.match(line):
|
||||
on = True
|
||||
continue
|
||||
if not on:
|
||||
continue
|
||||
if not line.startswith("|"):
|
||||
break
|
||||
d = line.strip().strip("|").split("|")[0].strip()
|
||||
if not d or set(d) <= set("-: "):
|
||||
continue
|
||||
p = os.path.join(project, d.rstrip("/"))
|
||||
if os.path.exists(os.path.join(p, ".git")):
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def worktrees(repo):
|
||||
out, cur = [], {}
|
||||
for l in (git(repo, "worktree", "list", "--porcelain") or "").splitlines() + [""]:
|
||||
if not l.strip():
|
||||
if cur:
|
||||
out.append(cur)
|
||||
cur = {}
|
||||
continue
|
||||
k, _, v = l.partition(" ")
|
||||
cur[k] = v or True
|
||||
return out
|
||||
|
||||
|
||||
def leftovers(repo, fetch=False):
|
||||
items = []
|
||||
|
||||
def add(kind, item, hint):
|
||||
items.append({"kind": kind, "repo": repo, "item": item, "hint": hint})
|
||||
|
||||
if fetch:
|
||||
git(repo, "fetch", "--prune", "-q", "origin")
|
||||
sha = lambda ref: (git(repo, "rev-parse", "-q", "--verify", ref) or "").strip()
|
||||
tip = sha("origin/main")
|
||||
if not tip:
|
||||
add("no-origin-main", "origin/main", "нет origin/main: git fetch origin или проверить remote")
|
||||
return items
|
||||
for b in lines(git(repo, "branch", "-r", "--merged", "origin/main")):
|
||||
if "->" in b or not b.startswith("origin/") or b[len("origin/"):] in PROTECTED + ("HEAD",):
|
||||
continue
|
||||
if sha(b) != tip:
|
||||
add("remote-branch", b, "git push origin --delete " + b[len("origin/"):])
|
||||
stale = set()
|
||||
for b in lines(git(repo, "branch", "--merged", "origin/main", "--format=%(refname:short)")):
|
||||
if b not in PROTECTED and sha("refs/heads/" + b) != tip:
|
||||
stale.add(b)
|
||||
for l in lines(git(repo, "for-each-ref", "--format=%(refname:short) %(upstream:track)", "refs/heads")):
|
||||
name, _, track = l.partition(" ")
|
||||
if "gone" in track and name not in PROTECTED:
|
||||
stale.add(name)
|
||||
for b in sorted(stale):
|
||||
add("local-branch", b, "git branch -D " + b)
|
||||
wts = worktrees(repo)
|
||||
main_wt = wts[0]["worktree"] if wts else repo
|
||||
done = [main_wt] # SDD workspace — хвост в основном checkout и в worktree закрытой задачи, не в активном
|
||||
for w in wts[1:]:
|
||||
br = str(w.get("branch") or "")
|
||||
if br.startswith("refs/heads/"):
|
||||
br = br[len("refs/heads/"):]
|
||||
if w.get("prunable") or br in stale:
|
||||
add("worktree", w["worktree"], "ExitWorktree, затем git worktree remove " + w["worktree"])
|
||||
done.append(w["worktree"])
|
||||
for w in done:
|
||||
for d in sorted(glob.glob(os.path.join(w, ".superpowers", "sdd", "*"))):
|
||||
if os.path.isdir(d):
|
||||
add("sdd-workspace", d, "rm -rf " + d)
|
||||
for c in lines(git(repo, "ls-tree", "-d", "--name-only", "origin/main", "openspec/changes/")):
|
||||
name = c.rstrip("/").rsplit("/", 1)[-1]
|
||||
if name != "archive":
|
||||
add("openspec-change", c, "openspec archive %s — отдельным PR" % name)
|
||||
behind = (git(main_wt, "rev-list", "--count", "HEAD..origin/main") or "0").strip()
|
||||
if behind not in ("", "0"):
|
||||
add("main-behind", main_wt, "git pull --ff-only (позади origin/main на %s)" % behind)
|
||||
dirty = [l for l in (git(main_wt, "status", "--porcelain") or "").splitlines() if l.strip()]
|
||||
if dirty:
|
||||
add("main-dirty", main_wt, "не трогать, перечислить: " + ", ".join(l[3:] for l in dirty[:5]))
|
||||
return items
|
||||
|
||||
|
||||
def to_markdown(items):
|
||||
if not items:
|
||||
return "Хвостов закрытия нет."
|
||||
out = ["| Вид | Репозиторий | Что | Чем убрать |", "|---|---|---|---|"]
|
||||
for i in items:
|
||||
out.append("| %s | %s | `%s` | `%s` |" % (i["kind"], os.path.basename(i["repo"]), i["item"], i["hint"]))
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def main(argv):
|
||||
args = argv[1:]
|
||||
pos = [a for a in args if not a.startswith("--")]
|
||||
if not pos:
|
||||
print(__doc__.strip().splitlines()[2])
|
||||
return 1
|
||||
repos = repos_of(pos[0])
|
||||
if not repos:
|
||||
sys.stderr.write("retro_leftovers: в %s нет git-репозитория и таблицы репозиториев workspace\n" % pos[0])
|
||||
return 1
|
||||
items = [i for r in repos for i in leftovers(r, "--fetch" in args)]
|
||||
if "--markdown" in args:
|
||||
print(to_markdown(items))
|
||||
else:
|
||||
print(json.dumps({"repos": repos, "items": items,
|
||||
"totals": dict(collections.Counter(i["kind"] for i in items))}, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
544
docs/retro/scripts/retro_metrics.py
Normal file
544
docs/retro/scripts/retro_metrics.py
Normal file
@ -0,0 +1,544 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Срез метрик обвязки по транскриптам и памяти одного проекта или workspace (B12, #29).
|
||||
|
||||
Запуск: python3 retro_metrics.py <папка проекта> [--since ГГГГ-ММ-ДД] [--harness <репозиторий обвязки>]
|
||||
[--projects-root <каталог>] [--labels <hook-labels.json>] [--out <metrics.json>]
|
||||
[--markdown [--prev <прошлый.metrics.json>]] [--corrections]
|
||||
Без флагов вывода печатает JSON метрик (схема 1); --out пишет его в файл; --markdown печатает таблицу «Цифры»
|
||||
со сравнением с --prev; --corrections — кандидаты в поправки владельца, по JSON-строке на кандидата.
|
||||
Только чтение транскриптов и памяти. Спека: openspec/specs/harness-retro/spec.md.
|
||||
"""
|
||||
import argparse
|
||||
import collections
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import statistics
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
from hook_blocks_export import HOOK_ERROR, clip, hook_of, redact # noqa: E402
|
||||
|
||||
INTERRUPT = "[Request interrupted by user"
|
||||
OWNER_DENY = "The user doesn't want to proceed with this tool use"
|
||||
CLASSIFIER = re.compile(r"denied by the Claude Code auto mode classifier\. Reason: \[([^\]]+)\]")
|
||||
RECALL = re.compile(r"(перечитай|прочитай|сверься|вспомни|освежи)[^.?!\n]{0,40}?(памят|контекст|claude\.md|\.md\b)",
|
||||
re.I)
|
||||
MARKERS = re.compile(r"(?<!\w)(нет|не так|почему|зачем|опять|снова|я же|стоп|подожди|не надо|сначала|верни|откати"
|
||||
r"|сделай сам)(?!\w)", re.I)
|
||||
READERS = ("grep", "rg", "find", "cat", "head")
|
||||
FILE_TOOLS = ("Edit", "Write", "Read", "MultiEdit", "NotebookEdit")
|
||||
MERGED = ("turns", "tools", "subagents", "skills", "tokens", "blocks", "classifier", "owner_denials", "cg_init",
|
||||
"cg_explore", "code_reads") # что сабагент добавляет сессии-родителю
|
||||
|
||||
|
||||
def slug(path):
|
||||
"""Имя каталога транскриптов Claude Code: каждый символ кроме [A-Za-z0-9] заменён на «-»."""
|
||||
return re.sub(r"[^A-Za-z0-9]", "-", path)
|
||||
|
||||
|
||||
def inside(path, root):
|
||||
path, root = os.path.normpath(path), os.path.normpath(root)
|
||||
return path == root or path.startswith(root + os.sep)
|
||||
|
||||
|
||||
def read_session(path, bad):
|
||||
"""Записи-объекты транскрипта; битые строки считаются в bad["n"]."""
|
||||
with open(path, encoding="utf-8", errors="replace") as fh:
|
||||
for line in fh:
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
rec = json.loads(line)
|
||||
except ValueError:
|
||||
bad["n"] = bad.get("n", 0) + 1
|
||||
continue
|
||||
if isinstance(rec, dict):
|
||||
yield rec
|
||||
|
||||
|
||||
def first_meta(path):
|
||||
"""(cwd, метка времени) первых записей, где они есть."""
|
||||
cwd = ts = None
|
||||
for rec in read_session(path, {}):
|
||||
if not cwd and isinstance(rec.get("cwd"), str) and rec["cwd"]:
|
||||
cwd = rec["cwd"]
|
||||
if not ts and isinstance(rec.get("timestamp"), str) and rec["timestamp"]:
|
||||
ts = rec["timestamp"]
|
||||
if cwd and ts:
|
||||
break
|
||||
return cwd, ts
|
||||
|
||||
|
||||
def subagent_files(path):
|
||||
"""Транскрипты сабагентов сессии: <каталог>/<сессия>/subagents/*.jsonl."""
|
||||
return sorted(glob.glob(os.path.join(path[:-len(".jsonl")], "subagents", "*.jsonl")))
|
||||
|
||||
|
||||
def in_period(ts, since):
|
||||
return not since or (ts or "")[:10] >= since
|
||||
|
||||
|
||||
def project_sessions(project, root, since):
|
||||
"""Транскрипты проекта: каталоги по префиксу slug, сессии по первому cwd. Период режется по записям (since)."""
|
||||
pre = slug(project)
|
||||
best = {}
|
||||
for d in sorted(glob.glob(os.path.join(root, "*"))):
|
||||
if not (os.path.isdir(d) and os.path.basename(d).startswith(pre)):
|
||||
continue
|
||||
for f in glob.glob(os.path.join(d, "*.jsonl")):
|
||||
sid = os.path.basename(f)
|
||||
if sid not in best or os.path.getsize(f) > os.path.getsize(best[sid]):
|
||||
best[sid] = f
|
||||
files = []
|
||||
for f in sorted(best.values()):
|
||||
cwd, _ = first_meta(f)
|
||||
if cwd and (inside(cwd, project) or inside(cwd, project + ".wt")):
|
||||
files.append(f)
|
||||
return files
|
||||
|
||||
|
||||
def blocks(rec):
|
||||
c = (rec.get("message") or {}).get("content")
|
||||
if isinstance(c, str):
|
||||
return [{"type": "text", "text": c}]
|
||||
return [b for b in c if isinstance(b, dict)] if isinstance(c, list) else []
|
||||
|
||||
|
||||
def result_text(b):
|
||||
c = b.get("content", "")
|
||||
if isinstance(c, list):
|
||||
return "\n".join(x.get("text", "") for x in c if isinstance(x, dict))
|
||||
return c if isinstance(c, str) else ""
|
||||
|
||||
|
||||
def prompt_text(rec):
|
||||
"""Текст промпта владельца или None: служебное, tool_result, прерывание, сабагент."""
|
||||
if rec.get("type") != "user" or rec.get("isMeta") or rec.get("isSidechain"):
|
||||
return None
|
||||
if rec.get("isCompactSummary") or rec.get("isVisibleInTranscriptOnly"):
|
||||
return None # сводка после компакции, не промпт владельца
|
||||
if rec.get("userType", "external") != "external":
|
||||
return None
|
||||
bs = blocks(rec)
|
||||
if any(b.get("type") == "tool_result" for b in bs):
|
||||
return None
|
||||
txt = "\n".join(b.get("text") or "" for b in bs if b.get("type") == "text").strip()
|
||||
if not txt or txt.startswith("<") or txt.startswith(INTERRUPT):
|
||||
return None
|
||||
return txt
|
||||
|
||||
|
||||
def is_interrupt(rec):
|
||||
return rec.get("type") == "user" and any(
|
||||
b.get("type") == "text" and (b.get("text") or "").startswith(INTERRUPT) for b in blocks(rec))
|
||||
|
||||
|
||||
def split_repo(path):
|
||||
"""(основной репозиторий, путь от корня worktree или репозитория) или (None, None)."""
|
||||
p = os.path.normpath(path) if path else ""
|
||||
m = re.match(r"^(.+?)/\.claude/worktrees/[^/]+/(.*)$", p)
|
||||
if m:
|
||||
return m.group(1), m.group(2)
|
||||
m = re.match(r"^(.+)/\.wt/[^/]+/([^/]+)/(.*)$", p)
|
||||
if m:
|
||||
return m.group(1) + "/" + m.group(2), m.group(3)
|
||||
m = re.match(r"^(.*[^/])\.wt/[^/]+/(.*)$", p)
|
||||
if m:
|
||||
return m.group(1), m.group(2)
|
||||
d = os.path.dirname(p)
|
||||
while d and d != os.path.dirname(d):
|
||||
if os.path.exists(os.path.join(d, ".git")):
|
||||
return d, os.path.relpath(p, d)
|
||||
d = os.path.dirname(d)
|
||||
return None, None
|
||||
|
||||
|
||||
def code_prefixes(repo, cache):
|
||||
if repo not in cache:
|
||||
cp = os.path.join(repo, ".claude", "code-paths")
|
||||
if os.path.isfile(cp):
|
||||
pre = [l.split("#", 1)[0].strip().rstrip("/") for l in open(cp, encoding="utf-8", errors="replace")]
|
||||
cache[repo] = [x for x in pre if x]
|
||||
else:
|
||||
cache[repo] = None
|
||||
return cache[repo]
|
||||
|
||||
|
||||
def is_code(path, cache):
|
||||
repo, rel = split_repo(path)
|
||||
if not repo:
|
||||
return False
|
||||
pre = code_prefixes(repo, cache)
|
||||
if pre is None:
|
||||
return "/src/" in "/" + rel + "/"
|
||||
return any(rel == x or rel.startswith(x + "/") for x in pre)
|
||||
|
||||
|
||||
def simple_commands(cmd):
|
||||
"""Простые команды Bash списками слов: кавычки учитываются, делят &&, ||, ;, |, &, скобки; цель редиректа
|
||||
пропускается. Незакрытая кавычка — разбор по пробелам."""
|
||||
try:
|
||||
lx = shlex.shlex(cmd or "", posix=True, punctuation_chars=True)
|
||||
lx.whitespace_split = True
|
||||
toks = list(lx)
|
||||
except ValueError:
|
||||
toks = (cmd or "").split()
|
||||
segs, cur, skip = [], [], False
|
||||
for t in toks:
|
||||
if t and set(t) <= set(";&|()"):
|
||||
if cur:
|
||||
segs.append(cur)
|
||||
cur = []
|
||||
elif t and set(t) <= set("<>"):
|
||||
skip = True
|
||||
elif skip:
|
||||
skip = False
|
||||
else:
|
||||
cur.append(t)
|
||||
if cur:
|
||||
segs.append(cur)
|
||||
return segs
|
||||
|
||||
|
||||
def bash_reads_code(cmd, cwd, cache):
|
||||
for words in simple_commands(cmd):
|
||||
if not (words[0] in READERS or (words[0] == "sed" and "-n" in words[1:2])):
|
||||
continue
|
||||
for w in words[1:]:
|
||||
if w.startswith("-") or not (os.path.isabs(w) or cwd):
|
||||
continue
|
||||
if is_code(w if os.path.isabs(w) else os.path.join(cwd, w), cache):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def action_of(b):
|
||||
name, inp = b.get("name") or "?", b.get("input") or {}
|
||||
if name == "Bash":
|
||||
return "Bash: " + " ".join((inp.get("command") or "").split())
|
||||
if name in FILE_TOOLS:
|
||||
return "%s: %s" % (name, inp.get("file_path") or inp.get("notebook_path") or "")
|
||||
return name
|
||||
|
||||
|
||||
def count_tool(s, b, cwd, cache):
|
||||
name, inp = b.get("name") or "?", b.get("input") or {}
|
||||
s["tools"][name] += 1
|
||||
if name in ("Agent", "Task"):
|
||||
s["subagents"][inp.get("model") or "не указана"] += 1
|
||||
elif name == "Skill":
|
||||
sk = inp.get("skill") or "?"
|
||||
s["skills"][sk] += 1
|
||||
if s["role"] is None and (sk == "role" or sk.endswith(":role")):
|
||||
s["role"] = redact(str(inp.get("args") or "").strip()) or "?"
|
||||
if "codegraph_explore" in name:
|
||||
s["cg_explore"] += 1
|
||||
elif name == "Read":
|
||||
s["code_reads"] += is_code(inp.get("file_path") or "", cache)
|
||||
elif name in ("Grep", "Glob"):
|
||||
s["code_reads"] += 1
|
||||
elif name == "Bash":
|
||||
cmd = inp.get("command") or ""
|
||||
s["cg_init"] += "codegraph init" in cmd
|
||||
s["code_reads"] += bash_reads_code(cmd, cwd, cache)
|
||||
|
||||
|
||||
def session_stats(path, cache, bad, since=None):
|
||||
"""Счётчики сессии по записям периода (запись без метки — по предыдущей). Сабагенты сессии добавляют MERGED."""
|
||||
s = {"prompts": 0, "interrupts": 0, "turns": 0, "tools": collections.Counter(),
|
||||
"subagents": collections.Counter(), "skills": collections.Counter(), "role": None,
|
||||
"tokens": collections.Counter(), "compactions": 0, "start": None, "blocks": collections.Counter(),
|
||||
"classifier": collections.Counter(), "owner_denials": 0, "cg_init": 0, "cg_explore": 0,
|
||||
"code_reads": 0, "recall": 0, "last_ts": ""}
|
||||
seen, pending, cur, first_turn = set(), {}, "", True
|
||||
for rec in read_session(path, bad):
|
||||
ts = rec.get("timestamp")
|
||||
if isinstance(ts, str) and ts:
|
||||
cur = ts
|
||||
live = in_period(cur, since)
|
||||
if live and cur > s["last_ts"]:
|
||||
s["last_ts"] = cur
|
||||
t = rec.get("type")
|
||||
if t == "system" and rec.get("subtype") == "compact_boundary":
|
||||
s["compactions"] += live
|
||||
elif t == "assistant":
|
||||
msg = rec.get("message") or {}
|
||||
if rec.get("isApiErrorMessage") or msg.get("model") == "<synthetic>":
|
||||
continue # сообщение Claude Code об ошибке API — не ход модели
|
||||
mid = msg.get("id")
|
||||
if mid is None or mid not in seen:
|
||||
if mid is not None:
|
||||
seen.add(mid)
|
||||
u = msg.get("usage") or {}
|
||||
n = lambda k: u.get(k, 0) or 0
|
||||
if u and first_turn:
|
||||
first_turn = False # стартовый контекст — первый ход сессии, если он в периоде
|
||||
if live:
|
||||
s["start"] = n("input_tokens") + n("cache_creation_input_tokens") + n("cache_read_input_tokens")
|
||||
if live:
|
||||
s["turns"] += 1
|
||||
s["tokens"]["output"] += n("output_tokens")
|
||||
s["tokens"]["cache_read"] += n("cache_read_input_tokens")
|
||||
s["tokens"]["cache_write"] += n("cache_creation_input_tokens")
|
||||
for b in blocks(rec):
|
||||
if b.get("type") == "tool_use":
|
||||
pending[b.get("id")] = b.get("name") or "?"
|
||||
if live:
|
||||
count_tool(s, b, rec.get("cwd"), cache)
|
||||
elif t == "user" and live:
|
||||
txt = prompt_text(rec)
|
||||
if txt is not None:
|
||||
s["prompts"] += 1
|
||||
s["recall"] += bool(RECALL.search(txt))
|
||||
elif is_interrupt(rec):
|
||||
s["interrupts"] += 1
|
||||
for b in blocks(rec):
|
||||
if b.get("type") != "tool_result":
|
||||
continue
|
||||
rt = result_text(b)
|
||||
if HOOK_ERROR.match(rt) and "BLOCKED" in rt:
|
||||
s["blocks"][hook_of(rt, pending.get(b.get("tool_use_id"), "?"))] += 1
|
||||
m = CLASSIFIER.search(rt)
|
||||
if m:
|
||||
s["classifier"][m.group(1)] += 1
|
||||
s["owner_denials"] += OWNER_DENY in rt
|
||||
for sf in subagent_files(path):
|
||||
sub = session_stats(sf, cache, bad, since)
|
||||
for k in MERGED:
|
||||
s[k] = s[k] + sub[k]
|
||||
s["last_ts"] = max(s["last_ts"], sub["last_ts"])
|
||||
return s
|
||||
|
||||
|
||||
def aggregate(stats):
|
||||
total = lambda k: sum(s[k] for s in stats)
|
||||
merged = lambda k: sum((s[k] for s in stats), collections.Counter())
|
||||
tools, blk, cls, sub = merged("tools"), merged("blocks"), merged("classifier"), merged("subagents")
|
||||
tok = merged("tokens")
|
||||
starts = [s["start"] for s in stats if s["start"] is not None]
|
||||
explore, reads = total("cg_explore"), total("code_reads")
|
||||
return {
|
||||
"sessions": len(stats), "prompts": total("prompts"), "interrupts": total("interrupts"),
|
||||
"turns": total("turns"), "tool_calls": sum(tools.values()), "tools": dict(tools.most_common()),
|
||||
"roles": dict(collections.Counter(s["role"] or "не выбрана" for s in stats).most_common()),
|
||||
"subagents": sum(sub.values()), "subagent_models": dict(sub.most_common()),
|
||||
"skills": dict(merged("skills").most_common()),
|
||||
"tokens": {k: tok.get(k, 0) for k in ("output", "cache_read", "cache_write")},
|
||||
"compactions": total("compactions"),
|
||||
"start_context": {"median": int(statistics.median(starts)), "min": min(starts), "max": max(starts)}
|
||||
if starts else None,
|
||||
"hook_blocks": sum(blk.values()), "hook_blocks_by_hook": dict(blk.most_common()),
|
||||
"classifier_denials": sum(cls.values()), "classifier_by_reason": dict(cls.most_common()),
|
||||
"owner_denials": total("owner_denials"),
|
||||
"codegraph": {"init": total("cg_init"), "explore": explore, "code_reads": reads,
|
||||
"explore_share": round(explore / (explore + reads), 2) if explore + reads else None},
|
||||
"recall": total("recall"),
|
||||
}
|
||||
|
||||
|
||||
def memory_stats(root, project):
|
||||
d = os.path.join(root, slug(project), "memory")
|
||||
files = [f for f in glob.glob(os.path.join(d, "**", "*"), recursive=True) if os.path.isfile(f)]
|
||||
idx = os.path.join(d, "MEMORY.md")
|
||||
return {"index_bytes": os.path.getsize(idx) if os.path.isfile(idx) else 0, "files": len(files),
|
||||
"bytes": sum(os.path.getsize(f) for f in files)}
|
||||
|
||||
|
||||
def false_blocks(labels):
|
||||
cases = labels.get("cases", {})
|
||||
lab = [c for c in cases.values() if "skip" not in c]
|
||||
allow = [c for c in lab if c.get("expected") == "allow"]
|
||||
known = [c for c in allow if c.get("known")]
|
||||
return {"labelled": len(lab), "skipped": len(cases) - len(lab), "false": len(allow),
|
||||
"share": round(len(allow) / len(lab), 2) if lab else None, "fixed": len(allow) - len(known),
|
||||
"known": len(known)}
|
||||
|
||||
|
||||
def corrections(path, since=None):
|
||||
"""Кандидаты в поправки владельца с последним действием и хвостом ответа агента перед ними (записи периода)."""
|
||||
out, act, reply, sid, cur = [], "", "", os.path.basename(path)[:8], ""
|
||||
for rec in read_session(path, {}):
|
||||
if isinstance(rec.get("timestamp"), str) and rec["timestamp"]:
|
||||
cur = rec["timestamp"]
|
||||
t = rec.get("type")
|
||||
if t == "assistant":
|
||||
for b in blocks(rec):
|
||||
if b.get("type") == "tool_use":
|
||||
act = action_of(b)
|
||||
elif b.get("type") == "text" and (b.get("text") or "").strip():
|
||||
reply = b["text"]
|
||||
continue
|
||||
if t != "user" or not in_period(cur, since):
|
||||
continue
|
||||
txt, kind = prompt_text(rec), None
|
||||
if txt is not None:
|
||||
if len(txt) <= 2000 and MARKERS.search(txt[:200]):
|
||||
kind = "prompt"
|
||||
elif is_interrupt(rec):
|
||||
kind, txt = "interrupt", "[Request interrupted by user]"
|
||||
elif any(b.get("type") == "tool_result" and OWNER_DENY in result_text(b) for b in blocks(rec)):
|
||||
kind, txt = "denial", "отказ владельца в разрешении"
|
||||
if kind:
|
||||
out.append({"session": sid, "ts": rec.get("timestamp") or "", "kind": kind, "text": clip(txt, 300),
|
||||
"action": clip(act, 300), "reply_tail": clip(" ".join(reply.split()), 200, tail=True)})
|
||||
return out
|
||||
|
||||
|
||||
ROWS = [
|
||||
("sessions", "Сессий"), ("prompts", "Промптов владельца"), ("interrupts", "Прерываний"),
|
||||
("turns", "Ходов модели"), ("tool_calls", "Вызовов инструментов"), ("subagents", "Запусков сабагентов"),
|
||||
("tokens.output", "Токенов вывода"), ("tokens.cache_read", "Чтение из кэша, токенов"),
|
||||
("tokens.cache_write", "Запись в кэш, токенов"), ("compactions", "Компакций контекста"),
|
||||
("start_context.median", "Стартовый контекст, медиана"), ("start_context.min", "Стартовый контекст, мин"),
|
||||
("start_context.max", "Стартовый контекст, макс"), ("hook_blocks", "Блокировок хуков"),
|
||||
("false_blocks.false", "Ложных блокировок"), ("false_blocks.share", "Доля ложных блокировок"),
|
||||
("false_blocks.fixed", "Ложных, уже исправлено"), ("false_blocks.known", "Ложных, ещё не исправлено"),
|
||||
("classifier_denials", "Отказов классификатора"), ("owner_denials", "Отказов владельца"),
|
||||
("codegraph.init", "codegraph init"), ("codegraph.explore", "Вызовов codegraph_explore"),
|
||||
("codegraph.code_reads", "Чтений кода (Read/Grep/Glob/Bash)"),
|
||||
("codegraph.explore_share", "Доля codegraph_explore"),
|
||||
("corrections", "Кандидатов в поправки владельца"), ("recall", "Промптов «перечитай память/контекст»"),
|
||||
("memory.index_bytes", "MEMORY.md, байт"), ("memory.files", "Файлов памяти"), ("memory.bytes", "Память, байт"),
|
||||
("cd_main", "cd в основной checkout на main"),
|
||||
]
|
||||
BREAKDOWNS = [("tools", "Инструменты"), ("roles", "Роли"), ("subagent_models", "Сабагенты по моделям"),
|
||||
("skills", "Skills"), ("hook_blocks_by_hook", "Блокировки по хукам"),
|
||||
("classifier_by_reason", "Отказы классификатора")]
|
||||
|
||||
|
||||
def get(m, key):
|
||||
for k in key.split("."):
|
||||
if not isinstance(m, dict) or k not in m:
|
||||
return None
|
||||
m = m[k]
|
||||
return m
|
||||
|
||||
|
||||
def _num(v):
|
||||
return isinstance(v, (int, float)) and not isinstance(v, bool)
|
||||
|
||||
|
||||
def fmt(v):
|
||||
if v is None:
|
||||
return "—"
|
||||
if isinstance(v, float):
|
||||
return "%.2f" % v
|
||||
if _num(v):
|
||||
return "{:,}".format(v).replace(",", " ")
|
||||
return str(v)
|
||||
|
||||
|
||||
def cell(label, cur, old):
|
||||
if isinstance(cur, dict):
|
||||
cur = None
|
||||
if isinstance(old, dict) and "retired" in old:
|
||||
return "| %s | %s | — | не считается: %s |" % (label, fmt(cur), old["retired"])
|
||||
if isinstance(old, dict) and "value" in old:
|
||||
return "| %s | %s | %s (%s) | — |" % (label, fmt(cur), fmt(old["value"]), old.get("note", "иначе считалось"))
|
||||
d = "—"
|
||||
if _num(cur) and _num(old):
|
||||
diff = cur - old
|
||||
d = ("%+.2f" % diff) if isinstance(diff, float) else "{:+,}".format(diff).replace(",", " ")
|
||||
return "| %s | %s | %s | %s |" % (label, fmt(cur), fmt(old), d)
|
||||
|
||||
|
||||
def to_markdown(doc, prev):
|
||||
m, pm = doc["metrics"], (prev or {}).get("metrics") or {}
|
||||
out = ["| Показатель | Сейчас | Прошлое ретро | Δ |", "|---|---|---|---|"]
|
||||
for key, label in ROWS:
|
||||
cur, old = get(m, key), get(pm, key)
|
||||
if cur is None and old is None:
|
||||
continue
|
||||
out.append(cell(label, cur, old))
|
||||
out.append("")
|
||||
for key, label in BREAKDOWNS:
|
||||
d = m.get(key) or {}
|
||||
if d:
|
||||
out.append("- %s: %s" % (label, ", ".join("%s %s" % (k, fmt(v)) for k, v in list(d.items())[:10])))
|
||||
src, per = doc.get("sources") or {}, doc.get("period") or {}
|
||||
out += ["", "Период %s — %s; сессий %s; каталоги транскриптов: %s; битых строк %s." % (
|
||||
per.get("since") or "начало", per.get("until") or "—", src.get("sessions"),
|
||||
", ".join(src.get("dirs") or []) or "—", src.get("bad_lines"))]
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def default_since(harness):
|
||||
names = [os.path.basename(f)[:10] for f in glob.glob(os.path.join(harness, "docs", "retro", "*.md"))]
|
||||
names = [n for n in names if re.match(r"^\d{4}-\d{2}-\d{2}$", n)]
|
||||
return max(names) if names else None
|
||||
|
||||
|
||||
def parse_args(argv):
|
||||
cc = os.environ.get("CLAUDE_CONFIG_DIR") or os.path.expanduser("~/.claude")
|
||||
p = argparse.ArgumentParser(prog="retro_metrics.py", description="Срез метрик обвязки (B12)")
|
||||
p.add_argument("project")
|
||||
p.add_argument("--since")
|
||||
p.add_argument("--harness", default=os.path.normpath(os.path.join(HERE, "..", "..", "..")))
|
||||
p.add_argument("--projects-root", default=os.path.join(cc, "projects"))
|
||||
p.add_argument("--labels")
|
||||
p.add_argument("--out")
|
||||
p.add_argument("--markdown", action="store_true")
|
||||
p.add_argument("--prev")
|
||||
p.add_argument("--from", dest="from_file")
|
||||
p.add_argument("--corrections", action="store_true")
|
||||
return p.parse_args(argv[1:])
|
||||
|
||||
|
||||
def main(argv):
|
||||
a = parse_args(argv)
|
||||
prev = json.load(open(a.prev, encoding="utf-8")) if a.prev else None
|
||||
if a.from_file: # таблица из сохранённого среза, без пересчёта
|
||||
print(to_markdown(json.load(open(a.from_file, encoding="utf-8")), prev))
|
||||
return 0
|
||||
project = os.path.abspath(a.project)
|
||||
since = a.since
|
||||
if not since and a.out and os.path.isfile(a.out): # повторный срез того же ретро — тот же период
|
||||
try:
|
||||
since = json.load(open(a.out, encoding="utf-8"))["period"]["since"]
|
||||
except (ValueError, KeyError, TypeError):
|
||||
since = None
|
||||
since = since or default_since(a.harness)
|
||||
bad, cache, kept = {"n": 0}, {}, []
|
||||
stats = []
|
||||
for f in project_sessions(project, a.projects_root, since):
|
||||
s = session_stats(f, cache, bad, since)
|
||||
if s["prompts"] or s["turns"]:
|
||||
stats.append(s)
|
||||
kept.append(f)
|
||||
if not stats:
|
||||
sys.stderr.write("retro_metrics: нет сессий проекта %s (slug %s) в %s с %s\n"
|
||||
% (project, slug(project), a.projects_root, since or "начала"))
|
||||
return 1
|
||||
files, dirs = kept, sorted(set(os.path.basename(os.path.dirname(f)) for f in kept))
|
||||
cands = [c for f in files for c in corrections(f, since)]
|
||||
if a.corrections:
|
||||
for c in cands:
|
||||
print(json.dumps(c, ensure_ascii=False))
|
||||
return 0
|
||||
m = aggregate(stats)
|
||||
m["corrections"] = len(cands)
|
||||
m["memory"] = memory_stats(a.projects_root, project)
|
||||
if a.labels:
|
||||
m["false_blocks"] = false_blocks(json.load(open(a.labels, encoding="utf-8")))
|
||||
doc = {"schema": 1, "period": {"since": since, "until": max(s["last_ts"] for s in stats)[:10]},
|
||||
"project": project,
|
||||
"sources": {"dirs": dirs, "sessions": len(stats), "bytes": sum(os.path.getsize(f) for f in files),
|
||||
"bad_lines": bad["n"]},
|
||||
"metrics": m}
|
||||
if a.out:
|
||||
with open(a.out, "w", encoding="utf-8") as fh:
|
||||
json.dump(doc, fh, ensure_ascii=False, indent=2)
|
||||
fh.write("\n")
|
||||
if a.markdown:
|
||||
print(to_markdown(doc, prev))
|
||||
elif not a.out:
|
||||
print(json.dumps(doc, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
@ -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}")
|
||||
1764
docs/superpowers/plans/2026-10-24-harness-retro.md
Normal file
1764
docs/superpowers/plans/2026-10-24-harness-retro.md
Normal file
File diff suppressed because it is too large
Load Diff
2
openspec/changes/harness-retro/.openspec.yaml
Normal file
2
openspec/changes/harness-retro/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-24
|
||||
190
openspec/changes/harness-retro/design.md
Normal file
190
openspec/changes/harness-retro/design.md
Normal file
@ -0,0 +1,190 @@
|
||||
# Design: `/harness-retro`
|
||||
|
||||
## Context
|
||||
|
||||
Ретро 11.10.2026 (`docs/retro/2026-10-11.md`) сделано вручную. Таблицу «Цифры» собрали из вывода `retro_stats.py` и `retro_blocks.py`, находки — по прочтению 47 файлов памяти. В скриптах зашиты имена репозиториев awp-ca, вывод — текст, тестов нет. Подсчёт блокировок завышен: в 103 вошли выводы команд со словом `BLOCKED`, настоящих ошибок хука было 76 (O-1, #6).
|
||||
|
||||
После ретро вышли хуки по цели записи (0.4.0, корпус 129 записей), роли (0.3.0), allow-правила (0.5.0) и worktree в `.claude/worktrees/` (0.5.1). Что из этого помогло, видно только по сравнимым цифрам.
|
||||
|
||||
Владелец решил (brainstorming 24.10.2026):
|
||||
- результат — черновик ретро-документа;
|
||||
- срез — один проект или workspace;
|
||||
- запуск — из репозитория обвязки, проект передаётся аргументом;
|
||||
- ложные блокировки размечает агент по спеке, и они идут в корпус;
|
||||
- хвосты закрытия ищутся по текущему состоянию репозиториев;
|
||||
- поправки владельца по процессу собираются в ретро;
|
||||
- подход A — проектный skill и модуль метрик с тестами.
|
||||
|
||||
Транскрипты Claude Code: `~/.claude/projects/<slug>/<session>.jsonl`, по строке на запись. Типы записей: `user`, `assistant` (`message.content`: `text`, `tool_use`; `message.usage`), `system` (`subtype: compact_boundary`), `ai-title`, `permission-mode`. У записей есть `cwd`, `gitBranch`, `timestamp`.
|
||||
|
||||
Сессия, перешедшая в worktree, пишет транскрипт в каталог со slug пути worktree. Пример — эта сессия: `-Users-andrejepifancev-Projects-cc-harness-wt-6-guard-main-target`.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals**
|
||||
- Повторяемый срез метрик одного проекта в JSON и сравнение с прошлым ретро.
|
||||
- Блокировки среза размечаются и становятся регрессионными записями корпуса; известные ошибки хуков видны в doctor.
|
||||
- Кандидаты в поправки владельца с контекстом; агент отбирает и группирует.
|
||||
- Хвосты чеклиста закрытия по состоянию репозиториев.
|
||||
- Черновик ретро по шаблону, утверждение через PR.
|
||||
|
||||
**Non-Goals**
|
||||
- Сторона Gitea в хвостах: открытые issue после слитого PR, пропущенные follow-up.
|
||||
- Срез по нескольким проектам сразу.
|
||||
- Автоматические находки по порогам.
|
||||
- Автоматическая очистка хвостов: скрипт только подсказывает команду.
|
||||
- Переписывать ретро 11.10.
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Проектный skill, без установки
|
||||
`/harness-retro` нужен только в сессии в репозитории обвязки: туда пишется черновик, там корпус и `hook_cases_build.py`. Поэтому skill проектный (`.claude/skills/harness-retro/`), как skills openspec. `install.sh` и `~/.claude` не меняются.
|
||||
|
||||
`.claude/skills/` не входит в `.claude/code-paths`, поэтому `SKILL.md` и шаблон — документация. Код лежит в `docs/retro/scripts/`, как и сейчас.
|
||||
|
||||
### 2. `retro_metrics.py`: модуль на функциях
|
||||
Только stdlib, Python 3.8+. CLI:
|
||||
|
||||
```
|
||||
retro_metrics.py <папка проекта> [--since ГГГГ-ММ-ДД] [--harness <репозиторий обвязки>]
|
||||
[--projects-root <каталог>] [--out <metrics.json>]
|
||||
[--markdown [--prev <прошлый.metrics.json>]] [--corrections]
|
||||
```
|
||||
|
||||
- `--projects-root` по умолчанию — `${CLAUDE_CONFIG_DIR:-~/.claude}/projects`; тесты подставляют свой.
|
||||
- `--harness` по умолчанию — репозиторий, в котором лежит скрипт. Из него берутся даты прошлых ретро для `--since`.
|
||||
|
||||
Функции:
|
||||
- `slug(path)`;
|
||||
- `project_sessions(project, root, since)` — список транскриптов среза;
|
||||
- `read_session(path)` — генератор записей, битые строки считаются;
|
||||
- `session_stats(records, code_of)` — словарь счётчиков сессии;
|
||||
- `aggregate(stats)` — метрики среза;
|
||||
- `corrections(records)` — кандидаты;
|
||||
- `to_markdown(metrics, prev)`;
|
||||
- `main(argv)`.
|
||||
|
||||
Каждая метрика считается в одном месте, в `session_stats`, и проверяется отдельным тестом.
|
||||
|
||||
### 3. Сессии проекта
|
||||
Каталоги-кандидаты — те, чьё имя начинается со slug проекта. Сессия принадлежит проекту по первому `cwd`: путь внутри проекта или внутри `<проект>.wt/` (старое место worktree). Префикс slug нужен, чтобы найти каталоги worktree; проверка `cwd` отсекает чужой проект `/p/repo-tools` при проекте `/p/repo`.
|
||||
|
||||
Период режется по записям, а не по сессиям: запись входит, если её метка времени (у записи без метки — предыдущая) не раньше `since`. Сессия входит, если в ней есть ходы или промпты периода. Стартовый контекст — только если первый ход сессии в периоде. Так длинная сессия на стыке периодов (владелец держит одну сессию на много задач) делится между ретро, а не теряется.
|
||||
|
||||
Транскрипты сабагентов `<каталог>/<сессия>/subagents/*.jsonl` (Claude Code пишет их отдельно, в основном транскрипте `isSidechain` нет) вливаются в сессию-родителя: ходы, токены, инструменты, codegraph, блокировки, отказы. Промпты, роль, стартовый контекст и кандидаты в поправки — только из основного транскрипта.
|
||||
|
||||
### 4. Что такое «код»
|
||||
Пути в транскриптах относятся к репозиториям, которых может уже не быть: worktree удалены. Относительный путь считается от корня worktree или репозитория. Корень определяется в таком порядке:
|
||||
1. часть пути после `/.claude/worktrees/<имя>/`;
|
||||
2. затем после `<репо>.wt/<имя>/`;
|
||||
3. затем после `<workspace>/.wt/<задача>/<репо>/`;
|
||||
4. затем после ближайшего существующего каталога с `.git`.
|
||||
|
||||
Префиксы кода берутся из `.claude/code-paths` основного репозитория, без него — правило `src/` на любой глубине. Так же решает require-plan.
|
||||
|
||||
Bash-команда считается чтением кода, если хотя бы одна её простая команда начинается с `grep`, `rg`, `find`, `cat`, `sed -n` или `head` и хотя бы один аргумент указывает в каталог кода относительно `cwd`. Простые команды выделяет `shlex` с `punctuation_chars`: кавычки учитываются, делят `&&`, `||`, `;`, `|`, `&` и скобки, цель редиректа пропускается; при незакрытой кавычке — `split`. Правило `src/` срабатывает и на сам каталог `src` в аргументе.
|
||||
|
||||
### 5. Блокировки, отказы, роли
|
||||
- Блокировка: результат инструмента совпадает с `^\s*PreToolUse:\S+ hook error` и содержит `BLOCKED`. Хук определяется так же, как в `hook_blocks_export.hook_of`, функция импортируется.
|
||||
- Отказ классификатора: `denied by the Claude Code auto mode classifier. Reason: \[([^\]]+)\]`.
|
||||
- Отказ владельца: результат инструмента содержит `The user doesn't want to proceed with this tool use`. Прерывание (`[Request interrupted by user…`) считается отдельной метрикой, а не отказом.
|
||||
- Ход модели — уникальный `message.id` ассистента. Claude Code пишет одно сообщение несколькими строками с общим `id` и одинаковым `usage` (проверено на транскриптах этой машины: 538 id на 1381 строку). Поэтому токены считаются один раз на `id`. Строки без `id` считаются по одной.
|
||||
- Сессия — файл `<id>.jsonl`. Транскрипт переезжает в каталог worktree (запись `relocated`); если один id встретился в нескольких каталогах, берётся самый большой файл.
|
||||
- Роль: первый `tool_use` с `name: Skill` и `input.skill` = `role` (или `…:role`), аргумент — `input.args`.
|
||||
|
||||
### 6. Кандидаты в поправки
|
||||
Признаки ищутся в первых 200 символах промпта, без учёта регистра и с границами слов. Список признаков — в спеке. Промпт длиннее 2000 символов признаком не проверяется: это вставка материала.
|
||||
|
||||
Последнее действие — последний `tool_use` ассистента перед промптом: `Bash: <команда>`, `Edit|Write|Read: <путь>`, `<Имя>` для остальных. Хвост ответа — последние 200 символов последнего текстового блока ассистента.
|
||||
|
||||
Весь вывод проходит через `redact()` из `hook_blocks_export.py`.
|
||||
|
||||
### 7. `retro_leftovers.py`
|
||||
Репозитории: сам git-проект или каталоги из таблицы workspace (разбор как в `doctor.sh`). Для каждого:
|
||||
- `git branch -r --merged origin/main` без `origin/main` и `origin/HEAD`;
|
||||
- `git for-each-ref --format='%(refname:short) %(upstream:track)' refs/heads`, где слитые в `origin/main` или `[gone]`;
|
||||
- `git worktree list --porcelain` — ветка из слитых или `prunable`;
|
||||
- `ls .superpowers/sdd/*/` в основном checkout и в worktree, которые сами хвост (слитая или `gone` ветка, `prunable`). SDD workspace в worktree активной задачи — работа в процессе, не хвост;
|
||||
- `git ls-tree -d --name-only origin/main openspec/changes/` без `archive`;
|
||||
- `git rev-list --count HEAD..origin/main` и `git status --porcelain` основного checkout.
|
||||
|
||||
Нет `origin/main` — у репозитория одна строка `kind: no-origin-main`. `--fetch` перед проверками делает `git fetch --prune origin`.
|
||||
|
||||
### 8. Корпус: сегменты и `known`
|
||||
Формат `hook-cases.jsonl`: строка с ключом `layout` — шапка сегмента `{home, layout}`, следующие строки — записи этого сегмента. Первый сегмент — пилот 12.10 и живые случаи, без изменений.
|
||||
|
||||
`hook_corpus.py` строит песочницу на каждый сегмент (`<tmp>/seg-<n>/home`). Поле `known`:
|
||||
- расхождение при `known` — `KNOWN <id>`, тест не падает;
|
||||
- совпадение при `known` — `KNOWN-FIXED <id>`, тест падает.
|
||||
|
||||
Итог: `корпус хуков: N/M`, при известных ошибках — `корпус хуков: N/M, известных ошибок K`. N — записи без расхождений, считая известные; M — все записи.
|
||||
|
||||
`hook-labels-<дата>.json`:
|
||||
|
||||
```json
|
||||
{"home": "/Users/x", "layout": [...], "cases": {"2026-11-01-001": {"expected": "allow", "why": "devnull"},
|
||||
"2026-11-01-002": {"skip": "ветка репозитория на момент вызова неизвестна"},
|
||||
"2026-11-01-003": {"expected": "allow", "why": "read-only", "known": "RT-21"}}}
|
||||
```
|
||||
|
||||
`hook_cases_build.py` падает, если у записи выгрузки нет разметки: ретро не уходит с неразмеченными блокировками.
|
||||
|
||||
### 9. `metrics.json` и сравнение
|
||||
Схема `{"schema": 1, "period": {"since", "until"}, "project", "sources": {...}, "metrics": {...}}`. Ключи `metrics` стабильны: по ним `--prev` сопоставляет строки. Метрика, которая больше не считается, хранится как `{"retired": "<причина>"}`.
|
||||
|
||||
`2026-10-11.metrics.json` заполняется из таблицы «Цифры» 11.10. Блокировки — по разметке корпуса: 76 настоящих ошибок хуков, из них 4 по делу, ложных 72 (95 %). В таблице 11.10 было 103 — это завышено (O-1). `cd` в main — `retired`.
|
||||
|
||||
### 10. Шаблон и порядок skill
|
||||
`template.md` повторяет разделы ретро 11.10 и добавляет «Поправки владельца» и «Что не измерялось». `SKILL.md` — шаги:
|
||||
1. issue и worktree;
|
||||
2. `retro_metrics.py --out`;
|
||||
3. `hook_blocks_export.py --since` → разметка → `hook_cases_build.py` → `tests/run.sh`;
|
||||
4. `retro_leftovers.py --fetch`;
|
||||
5. `--corrections` → отбор и группировка;
|
||||
6. чтение памяти целиком;
|
||||
7. черновик и `--markdown --prev`;
|
||||
8. коммит и PR.
|
||||
|
||||
Маркеры: находки `RT-n` по сквозной нумерации ретро.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Формат транскриптов Claude Code не документирован.** Признаки (`hook error`, `compact_boundary`, текст отказа) могут смениться. Смягчение: тесты на синтетических транскриптах фиксируют известный формат, а неожиданные записи пропускаются. Изменение формата увидят нулевые метрики при новом ретро.
|
||||
- **Эвристика поправок шумит.** «Почему» часто бывает вопросом по содержанию. Скрипт только предлагает кандидатов, решает агент, проверяет владелец в PR.
|
||||
- **Корень пути у удалённого worktree** определяется по шаблону пути (решение 4). Нестандартный путь даёт правило `src/`, и чтения кода могут не посчитаться.
|
||||
- **Разметка блокировок требует раскладки песочницы,** которую агент восстанавливает по транскриптам. Ошибка в раскладке даёт неверный вердикт в корпусе. Смягчение: `skip` и ревью PR.
|
||||
- **`KNOWN-FIXED` валит тесты при исправлении хука,** пока не снята пометка. Это сделано намеренно: исправление хука включает снятие `known`.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Добавить скрипты, тесты, skill и шаблон; изменить `hook_corpus.py` и `hook_cases_build.py` — корпус пересобирается без изменения записей.
|
||||
2. `2026-10-11.metrics.json` — из таблицы ретро 11.10.
|
||||
3. Удалить `retro_stats.py` и `retro_blocks.py`, поправить ссылки в `CLAUDE.md`.
|
||||
4. После merge — `bash install.sh --no-permissions`: doctor и раннер корпуса. Первое ретро — по команде владельца на машине с полигоном.
|
||||
|
||||
Откат — revert PR. Корпус возвращается к одному сегменту.
|
||||
|
||||
## Decisions made without discussion
|
||||
|
||||
| Дата | Решение | Обоснование | Цена ошибки |
|
||||
|---|---|---|---|
|
||||
| 2026-10-24 | Skill проектный, `install.sh` не меняется | Запуск решён «из репозитория обвязки»; данные корпуса и черновик там же | Чтобы запускать из другого каталога, придётся ставить skill глобально |
|
||||
| 2026-10-24 | Сессия входит в срез по `cwd` первой записи, а каталоги ищутся по префиксу slug | Префикс находит каталоги worktree, `cwd` отсекает чужой проект с тем же префиксом | Сессия, начатая вне проекта и перешедшая в него, не войдёт в срез |
|
||||
| 2026-10-24 | Корень пути удалённого worktree — по шаблонам `.claude/worktrees/`, `.wt/` | Каталогов уже нет, а `code-paths` нужен для «кода» | Нестандартное место worktree: чтения кода не посчитаются |
|
||||
| 2026-10-24 | Признаки поправок — в первых 200 символах; промпты > 2000 символов не проверяются | Длинные промпты — вставки материала, признаки в них случайны | Пропуск поправки внутри длинного сообщения |
|
||||
| 2026-10-24 | `KNOWN-FIXED` — FAIL | Иначе пометка `known` переживёт исправление и скроет регрессию | Исправление хука требует ещё одной правки (снять `known`) |
|
||||
| 2026-10-24 | `hook_cases_build.py` падает на записи без разметки | Ретро не должно уходить с неразмеченными блокировками | Нельзя закоммитить частичную разметку |
|
||||
| 2026-10-24 | Метрика `cd` в main — `retired` | RT-2 снят хуками 0.4.0; счёт был по именам репозиториев awp-ca | Потеря одного ряда сравнения |
|
||||
| 2026-10-24 | Блокировки 11.10 в `metrics.json` — 76 ошибок хука, 72 ложных (по корпусу), а не 103 из таблицы | 103 завышено выводами со словом `BLOCKED` (O-1) | Сравнение с таблицей 11.10 по этой строке расходится с документом |
|
||||
| 2026-10-24 | `retro_stats.py` и `retro_blocks.py` удаляются | Их заменяет `retro_metrics.py`; в ретро 11.10 они упомянуты как история | Чтобы воспроизвести 11.10 буква в букву, нужен git history |
|
||||
| 2026-10-24 | Ходы и токены — по уникальному `message.id`, а не по строкам транскрипта | Строки одного сообщения повторяют `usage`; подсчёт по строкам завышает токены в 2–3 раза | Ходы и токены 11.10 (по строкам) несравнимы; в `2026-10-11.metrics.json` они помечены |
|
||||
| 2026-10-24 | Записи ассистента с `isApiErrorMessage` или `model: <synthetic>` — не ход модели: не считаются в ходах, токенах и стартовом контексте | Это сообщения Claude Code об ошибке API с нулевым `usage`; на живых транскриптах давали стартовый контекст 0 (O-18) | Если Claude Code пометит так настоящий ход, он выпадет из подсчёта |
|
||||
| 2026-10-24 | SDD workspace в worktree неслитой ветки — не хвост | Живой прогон показал рабочий каталог текущего плана как хвост (O-19); это работа в процессе | Брошенная неслитая задача со своим SDD workspace в хвосты не попадёт вовсе: worktree на неслитой ветке скрипт тоже не выводит |
|
||||
| 2026-10-24 | Транскрипты сабагентов вливаются в сессию-родителя (C-1, финальное ревью) | В режиме SDD почти вся работа — в сабагентах; без них замер codegraph (решение владельца 3) пуст по построению | Метрики 11.10 считались без сабагентов: `tool_calls`, `hook_blocks`, `classifier_denials`, `codegraph.init/explore` в `2026-10-11.metrics.json` помечены несравнимыми |
|
||||
| 2026-10-24 | Период режется по меткам времени записей, а не по первой метке сессии (I-7, финальное ревью) | Владелец держит одну сессию на много задач; при фильтре по началу сессии её работа после прошлого ретро не измерялась бы никогда | Сессия на стыке делится между двумя ретро; запись без метки относится к предыдущей |
|
||||
| 2026-10-24 | Без `--since` период берётся из существующего `--out`; «Цифры» — `--from` сохранённого среза (I-4, финальное ревью) | Черновик `docs/retro/<D>.md` иначе сдвигал период по умолчанию на дату самого ретро | Чтобы пересчитать срез за другой период, нужен явный `--since` или новый `--out` |
|
||||
| 2026-10-24 | Простые команды Bash — `shlex` с `punctuation_chars`, а не `Lexer` хука (I-1, финальное ревью) | Самодостаточно, учитывает кавычки; ретро не зависит от внутреннего API `harness_target.py` | Разбор heredoc и `$(…)` грубее, чем у хука: чтение кода внутри них может не посчитаться |
|
||||
| 2026-10-24 | Секреты заменяются до обрезки — `clip()` в `hook_blocks_export.py` (I-3, финальное ревью) | Обрезка до `redact` оставляла обрывок токена, который шаблон уже не узнаёт | Нет |
|
||||
| 2026-10-24 | «Перечитай» — побуждение перечитать рядом с «память», «контекст», `.md`; сводка компакции — не промпт (I-6, финальное ревью) | Широкий регэксп 11.10 ловил любое упоминание контекста и сводки компакции | `recall` 11.10 (широкий регэксп) помечен несравнимым |
|
||||
| 2026-10-24 | Свежая ветка, совпадающая с `origin/main`, — не хвост | `--merged` считает слитой и ветку без коммитов, например только что созданный worktree | Брошенная пустая ветка не попадёт в хвосты |
|
||||
| 2026-10-24 | Сторона Gitea в хвостах не проверяется | Хрупко (формат описаний PR и issue); агент видит это при чтении | Незакрытая issue после merge не попадёт в метрику |
|
||||
69
openspec/changes/harness-retro/proposal.md
Normal file
69
openspec/changes/harness-retro/proposal.md
Normal file
@ -0,0 +1,69 @@
|
||||
# Proposal: `/harness-retro` — регулярное ретро обвязки
|
||||
|
||||
Issue: #29. Источники:
|
||||
- ретро `docs/retro/2026-10-11.md` — B12, решение владельца 3 (замер codegraph);
|
||||
- находки O-10, O-11 (#21), O-17 (#28);
|
||||
- brainstorming 24.10.2026.
|
||||
|
||||
## Why
|
||||
|
||||
Ретро 11.10 считали два одноразовых скрипта, `docs/retro/scripts/retro_stats.py` и `retro_blocks.py`. У них нет функций и тестов. В них зашиты имена репозиториев awp-ca, и они ничего не знают про `.claude/worktrees/`. Вывод — текст для глаз, поэтому таблицу «Цифры» собирали вручную.
|
||||
|
||||
Повторить такое ретро и сравнить его с прошлым нельзя, а именно сравнение показывает, помогли ли изменения обвязки: хуки 0.4.0, роли 0.3.0, allow-правила 0.5.0. Решение владельца 3 требует замерить codegraph в одном срезе, и метрика должна входить в `/harness-retro`.
|
||||
|
||||
Владелец хочет видеть в ретро места, где он поправлял агента по процессу. Сейчас они теряются в транскриптах.
|
||||
|
||||
Блокировки, размеченные при ретро, в регрессионный корпус хуков не попадают. Ложный блок, найденный на ретро, тестом не становится.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Новый проектный skill `/harness-retro`** (`.claude/skills/harness-retro/`). Запускается в сессии в репозитории обвязки: `/harness-retro <папка проекта или workspace> [--since ГГГГ-ММ-ДД]`. Работа идёт по циклу фичи: issue, `EnterWorktree`, PR. Результат — черновик `docs/retro/<дата>.md` по шаблону и `docs/retro/<дата>.metrics.json`. Утверждение — merge владельца.
|
||||
- **`docs/retro/scripts/retro_metrics.py`** — stdlib, на функциях, с тестами. Даёт метрики среза по транскриптам и памяти проекта:
|
||||
- сессии, промпты, инструменты, роли, сабагенты, skills, токены, компакции;
|
||||
- стартовый контекст;
|
||||
- блокировки хуков;
|
||||
- отказы классификатора и владельца;
|
||||
- codegraph против чтения кода;
|
||||
- поправки владельца;
|
||||
- «перечитай память»;
|
||||
- размер памяти.
|
||||
|
||||
Выводит JSON, таблицу «Цифры» со сравнением с прошлым `*.metrics.json` и кандидатов в поправки владельца с контекстом.
|
||||
- **`docs/retro/scripts/retro_leftovers.py`** ищет хвосты чеклиста закрытия по текущему состоянию репозиториев проекта:
|
||||
- слитые ветки на сервере и локально, worktree на них;
|
||||
- `.superpowers/sdd/*`;
|
||||
- неархивные changes в `origin/main`;
|
||||
- отставший или грязный основной checkout.
|
||||
|
||||
Только чтение; `--fetch` по запросу.
|
||||
- **`hook_blocks_export.py --since`** выгружает блокировки среза в `docs/retro/fixtures/hook-blocks-<дата>.jsonl`. Агент размечает их по спеке `hook-guards` в `hook-labels-<дата>.json` (вердикт, класс, раскладка песочницы), и `hook_cases_build.py` дописывает их в корпус.
|
||||
- **Корпус хуков** состоит из сегментов: у каждого своя шапка `{home, layout}`, раннер строит песочницу на сегмент. Запись с `known: "RT-n"` — известная ошибка текущих хуков, тест на ней не падает. Doctor показывает число известных ошибок.
|
||||
- `retro_stats.py` и `retro_blocks.py` удаляются. Их заменяет `retro_metrics.py`. Ретро 11.10 ссылается на них как на историю.
|
||||
- `docs/retro/2026-10-11.metrics.json` переносится один раз из таблицы ретро 11.10, чтобы следующему ретро было с чем сравнивать.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `harness-retro`: срез метрик обвязки по транскриптам, памяти и состоянию репозиториев проекта; кандидаты в поправки владельца; разметка блокировок в корпус; черновик ретро и сравнение с прошлым.
|
||||
|
||||
### Modified Capabilities
|
||||
- `hook-guards`: требование «Регрессионный корпус хуков» — сегменты с собственной раскладкой и известные ошибки `known`.
|
||||
|
||||
## Impact
|
||||
|
||||
- **Код:**
|
||||
- новые `docs/retro/scripts/retro_metrics.py`, `retro_leftovers.py`;
|
||||
- `hook_blocks_export.py` (`--since`, id);
|
||||
- `hook_cases_build.py` (разметка из `hook-labels-*.json`, сегменты);
|
||||
- `skills/harness-doctor/scripts/hook_corpus.py` (сегменты, `known`);
|
||||
- `doctor.sh` (строка корпуса).
|
||||
- **Skill:** `.claude/skills/harness-retro/SKILL.md` и `template.md`. Он проектный, поэтому `install.sh` не меняется.
|
||||
- **Тесты:**
|
||||
- новые `tests/test-retro-metrics.sh`, `tests/test-retro-leftovers.sh`;
|
||||
- дополнения в `test-hook-corpus.sh`, `test-hook-export.sh`, `test-doctor.sh`.
|
||||
- **Документация:** `CLAUDE.md` («Команды», «Архитектура», «Тесты»), `docs/HARNESS.md` (раздел «Ретро»), `README.md`, `CHANGELOG.md` 0.6.0.
|
||||
- **Вне задачи:**
|
||||
- сторона Gitea в хвостах закрытия: открытые issue после слитого PR, пропущенные follow-up;
|
||||
- срезы по нескольким проектам сразу;
|
||||
- автоматические находки по порогам;
|
||||
- workspace awp-ca как полигон — первое ретро по нему делается на той машине.
|
||||
238
openspec/changes/harness-retro/specs/harness-retro/spec.md
Normal file
238
openspec/changes/harness-retro/specs/harness-retro/spec.md
Normal file
@ -0,0 +1,238 @@
|
||||
# Spec Delta: harness-retro
|
||||
|
||||
## Purpose
|
||||
|
||||
Регулярное ретро обвязки claude-harness. Срез метрик берётся из транскриптов, памяти и состояния репозиториев одного проекта или workspace. Из него получается черновик ретро-документа со сравнением с прошлым ретро, разметкой блокировок хуков в регрессионный корпус и списком поправок владельца по процессу.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Запуск из репозитория обвязки
|
||||
`/harness-retro` SHALL быть проектным skill репозитория обвязки (`.claude/skills/harness-retro/`). Он принимает папку проекта или workspace и необязательный `--since ГГГГ-ММ-ДД`. Работа MUST идти по циклу фичи: issue, worktree через `EnterWorktree`, коммит, PR. Skill MUST NOT делать merge.
|
||||
|
||||
#### Scenario: Черновик и метрики в PR
|
||||
- **WHEN** владелец запускает `/harness-retro ~/projects/awp-ca` в сессии в репозитории обвязки
|
||||
- **THEN** в ветке ретро появляются `docs/retro/<дата>.md` и `docs/retro/<дата>.metrics.json`, открыт PR со ссылкой на issue ретро
|
||||
- **AND** main не меняется до merge владельцем
|
||||
|
||||
### Requirement: Каталоги транскриптов проекта
|
||||
`retro_metrics.py` SHALL брать сессии из каталогов `~/.claude/projects/<имя>`, где `<имя>` начинается со slug папки проекта. Slug — абсолютный путь, в котором каждый символ кроме `[A-Za-z0-9]` заменён на `-`. Сессия MUST входить в срез, только если `cwd` её первой записи с этим полем лежит внутри папки проекта или внутри `<папка проекта>.wt/`.
|
||||
|
||||
#### Scenario: Сессии в worktree проекта
|
||||
- **WHEN** у проекта `/p/repo` есть транскрипты в `~/.claude/projects/-p-repo/` и в `~/.claude/projects/-p-repo--claude-worktrees-x/`
|
||||
- **THEN** сессии обоих каталогов входят в срез
|
||||
|
||||
#### Scenario: Чужой проект с тем же префиксом
|
||||
- **WHEN** в каталоге `~/.claude/projects/-p-repo-tools/` первая запись сессии имеет `cwd` `/p/repo-tools`
|
||||
- **THEN** сессия в срез проекта `/p/repo` не входит
|
||||
|
||||
### Requirement: Транскрипты сабагентов
|
||||
Транскрипты сабагентов сессии `<каталог>/<сессия>/subagents/*.jsonl` SHALL входить в сессию-родителя: ходы, токены, вызовы инструментов, codegraph, блокировки, отказы. Промпты, роль, стартовый контекст и кандидаты в поправки MUST браться только из основного транскрипта.
|
||||
|
||||
#### Scenario: codegraph у implementer-а
|
||||
- **WHEN** сабагент сессии вызвал `codegraph_explore` и прочитал файл кода, а его хук заблокировал запись
|
||||
- **THEN** вызов, чтение и блокировка считаются в срезе, а сообщение контроллера сабагенту промптом владельца не считается
|
||||
|
||||
### Requirement: Период среза
|
||||
Запись SHALL входить в срез, если её метка времени (у записи без метки — предыдущая) не раньше `--since`; сессия — если в ней есть записи периода. Стартовый контекст сессии MUST считаться, только если её первый ход в периоде. Без `--since` начало периода MUST браться из `period.since` существующего `--out`, иначе из даты последнего `docs/retro/ГГГГ-ММ-ДД*.md`. Если сессий нет — код 1 и сообщение со slug и периодом.
|
||||
|
||||
#### Scenario: Период по умолчанию
|
||||
- **WHEN** последний ретро-документ — `docs/retro/2026-10-11.md`, `--since` не указан и `--out` ещё нет
|
||||
- **THEN** в срез входят записи с меткой времени 2026-10-11 и позже
|
||||
|
||||
#### Scenario: Длинная сессия на стыке периодов
|
||||
- **WHEN** сессия начата 2026-10-30, а `--since` — 2026-11-01
|
||||
- **THEN** её записи с 2026-11-01 входят в срез, записи до этой даты и её стартовый контекст — нет
|
||||
|
||||
#### Scenario: Черновик текущего ретро не сдвигает период
|
||||
- **WHEN** срез уже записан в `docs/retro/<D>.metrics.json`, создан черновик `docs/retro/<D>.md`, и срез запускается снова с тем же `--out` без `--since`
|
||||
- **THEN** период тот же, что в существующем `--out`
|
||||
|
||||
#### Scenario: Нет транскриптов
|
||||
- **WHEN** ни одного каталога со slug проекта нет или в периоде нет ни одной сессии
|
||||
- **THEN** `retro_metrics.py` завершается с кодом 1 и сообщением со slug и периодом
|
||||
|
||||
### Requirement: Формат метрик
|
||||
`retro_metrics.py` SHALL выводить JSON со схемой версии `1` и полями `period`, `project`, `sources`, `metrics`. В `sources` MUST быть использованные каталоги транскриптов, число сессий, объём и число пропущенных битых строк JSONL. Битая строка MUST NOT прерывать срез.
|
||||
|
||||
#### Scenario: Битая строка
|
||||
- **WHEN** в транскрипте одна строка — не JSON
|
||||
- **THEN** срез считается по остальным строкам, а `sources.bad_lines` равно 1
|
||||
|
||||
### Requirement: Промпты владельца
|
||||
Промпт владельца SHALL считаться так: сообщение пользователя без `tool_result`, не `isMeta`, не сводка компакции (`isCompactSummary`), не начинающееся с `<`. Прерывания `[Request interrupted by user]` MUST считаться отдельной метрикой, не промптами.
|
||||
|
||||
#### Scenario: Служебные сообщения не считаются промптами
|
||||
- **WHEN** в транскрипте есть сообщение пользователя с `tool_result`, сообщение с `isMeta` и строка `<command-name>…`
|
||||
- **THEN** ни одно из них не входит в число промптов владельца
|
||||
|
||||
### Requirement: Активность сессий
|
||||
Метрики SHALL включать:
|
||||
- ходы модели и вызовы инструментов — всего и по инструментам;
|
||||
- сессии по ролям — по первому вызову skill `role`, без вызова — «не выбрана»;
|
||||
- запуски сабагентов по полю `model`, включая «не указана»;
|
||||
- вызовы skills по имени;
|
||||
- токены вывода, чтения и записи кэша;
|
||||
- число записей `compact_boundary`.
|
||||
|
||||
#### Scenario: Роль сессии
|
||||
- **WHEN** в сессии первый вызов skill `role` — с аргументом `dev`
|
||||
- **THEN** сессия считается в роли `dev`
|
||||
|
||||
### Requirement: Стартовый контекст
|
||||
Стартовый контекст сессии SHALL равняться сумме `input_tokens`, `cache_creation_input_tokens` и `cache_read_input_tokens` первого хода модели. Метрика MUST выводить медиану, минимум и максимум по сессиям среза.
|
||||
|
||||
#### Scenario: Три сессии
|
||||
- **WHEN** стартовый контекст сессий — 50 000, 56 000 и 61 000 токенов
|
||||
- **THEN** медиана 56 000, минимум 50 000, максимум 61 000
|
||||
|
||||
### Requirement: Блокировки и отказы
|
||||
Блокировкой хука SHALL считаться только результат инструмента, который является ошибкой хука (`PreToolUse:<инструмент> hook error` и `BLOCKED`), с разбивкой по хукам. Отказ классификатора auto mode MUST считаться по причине `Reason: [<причина>]`, отказы владельца — отдельно.
|
||||
|
||||
#### Scenario: Вывод команды со словом BLOCKED
|
||||
- **WHEN** результат `cat hooks/require-plan.sh` содержит `BLOCKED`
|
||||
- **THEN** это не блокировка хука
|
||||
|
||||
#### Scenario: Отказ классификатора
|
||||
- **WHEN** результат инструмента содержит `denied by the Claude Code auto mode classifier. Reason: [Self-Modification]`
|
||||
- **THEN** метрика отказов классификатора увеличивается по причине `Self-Modification`
|
||||
|
||||
### Requirement: codegraph против чтения кода
|
||||
Метрика codegraph SHALL считать команды `codegraph init`, вызовы `codegraph_explore` и чтения кода, а также долю `codegraph_explore` среди вызовов чтения. Чтение кода — Read файла в каталоге кода, Grep и Glob, Bash с `grep`, `rg`, `find`, `cat`, `sed -n` или `head` по каталогу кода. Каталог кода определяется как в require-plan: префиксы `.claude/code-paths`, без него — любой `src/`.
|
||||
|
||||
#### Scenario: Доля explore
|
||||
- **WHEN** в сессии 2 вызова `codegraph_explore`, Read `hooks/a.py` при `.claude/code-paths` = `hooks/`, Read `docs/x.md` и `grep -rn x hooks/`
|
||||
- **THEN** вызовов explore 2, чтений кода 2, доля explore 0.5; Read `docs/x.md` чтением кода не считается
|
||||
|
||||
#### Scenario: Разделитель внутри кавычек
|
||||
- **WHEN** Bash-команда `grep -n "a\|b" hooks/x.py` при `.claude/code-paths` = `hooks/`
|
||||
- **THEN** это одно чтение кода: `|` внутри кавычек команду не делит
|
||||
|
||||
#### Scenario: Каталог src без code-paths
|
||||
- **WHEN** в репозитории без `.claude/code-paths` выполнены `grep -rn foo src/` и `find svc/src -name x`
|
||||
- **THEN** обе команды — чтение кода
|
||||
|
||||
### Requirement: Память проекта
|
||||
Метрика памяти SHALL выводить размер `~/.claude/projects/<slug проекта>/memory/MEMORY.md` в байтах, число и общий объём файлов в этом каталоге. Отсутствующий каталог MUST давать нули, а не ошибку. Отдельно SHALL считаться промпты-побуждения перечитать: «перечитай», «прочитай», «сверься», «вспомни», «освежи» рядом с «память», «контекст» или `.md`.
|
||||
|
||||
#### Scenario: Памяти нет
|
||||
- **WHEN** каталога памяти проекта нет
|
||||
- **THEN** метрики памяти равны нулю, срез завершается с кодом 0
|
||||
|
||||
#### Scenario: Упоминание контекста — не побуждение
|
||||
- **WHEN** промпт «я сейчас смотрю на размер контекста» и сводка компакции со словами «перечитай память»
|
||||
- **THEN** ни то, ни другое не считается промптом «перечитай память/контекст»
|
||||
|
||||
### Requirement: Кандидаты в поправки владельца
|
||||
`retro_metrics.py --corrections` SHALL выгружать кандидатов в поправки владельца по процессу. Кандидат — это:
|
||||
- промпт владельца со словом-признаком: «нет», «не так», «почему», «зачем», «опять», «снова», «я же», «стоп», «подожди», «не надо», «сначала», «верни», «откати», «сделай сам» (агент переложил работу на владельца, H-1);
|
||||
- прерывание;
|
||||
- отказ владельца в разрешении.
|
||||
|
||||
Отбор настоящих поправок делает агент, не скрипт.
|
||||
|
||||
#### Scenario: Вопрос после действия агента
|
||||
- **WHEN** после вызова `EnterWorktree` владелец пишет «почему мне нужно это каждый раз подтверждать?»
|
||||
- **THEN** этот промпт выгружается как кандидат
|
||||
|
||||
### Requirement: Контекст кандидата
|
||||
Для каждого кандидата SHALL выводиться:
|
||||
- сессия и время;
|
||||
- текст, не длиннее 300 символов;
|
||||
- последнее действие агента перед ним: инструмент и команда или путь;
|
||||
- последние 200 символов последнего ответа агента.
|
||||
|
||||
#### Scenario: Последнее действие
|
||||
- **WHEN** перед кандидатом агент вызвал `Bash` с командой `git branch -m feat/x`
|
||||
- **THEN** у кандидата указано действие `Bash: git branch -m feat/x`
|
||||
|
||||
### Requirement: Сравнение с прошлым ретро
|
||||
`retro_metrics.py --markdown --prev <прошлый>.metrics.json` SHALL печатать таблицу «Цифры» в три колонки: текущее значение, значение прошлого ретро, разница. Если метрики нет в прошлом файле или там `null`, MUST ставиться «—»; снятая или иначе считавшаяся метрика — с пометкой. `--from <metrics.json>` MUST печатать таблицу из сохранённого среза без пересчёта.
|
||||
|
||||
#### Scenario: Новая метрика
|
||||
- **WHEN** в прошлом файле нет метрики `corrections`
|
||||
- **THEN** строка поправок владельца показывает текущее значение и «—» в колонках прошлого и разницы
|
||||
|
||||
### Requirement: Секреты не попадают в черновик
|
||||
Всё, что скрипты ретро выводят из транскриптов (промпты, команды, сообщения), SHALL проходить замену похожих на секреты подстрок на `<redacted>` — той же функцией, что в `hook_blocks_export.py`, и MUST проходить её до обрезки, чтобы секрет на границе обрезки не утёк частично.
|
||||
|
||||
#### Scenario: Токен в промпте
|
||||
- **WHEN** промпт-кандидат содержит `tea login add --token 0123456789abcdef0123456789abcdef01234567`
|
||||
- **THEN** в выводе `--corrections` вместо токена стоит `<redacted>`
|
||||
|
||||
### Requirement: Хвосты чеклиста закрытия
|
||||
`retro_leftovers.py <папка проекта>` SHALL искать хвосты по текущему состоянию каждого репозитория проекта (для workspace — из таблицы репозиториев в `CLAUDE.md`). Хвост — это:
|
||||
- ветка `origin/<ветка>`, слитая в `origin/main`;
|
||||
- локальная ветка, слитая или с upstream `gone`;
|
||||
- worktree на такой ветке или `prunable`;
|
||||
- `.superpowers/sdd/*` в основном checkout или в worktree такой ветки;
|
||||
- неархивный `openspec/changes/<id>/` в `origin/main`;
|
||||
- отставший или грязный основной checkout.
|
||||
|
||||
#### Scenario: Слитая ветка осталась на сервере
|
||||
- **WHEN** ветка `feat/6-x` слита в `origin/main` и есть на `origin`
|
||||
- **THEN** выводится хвост вида `remote-branch` с подсказкой `git push origin --delete feat/6-x`
|
||||
|
||||
#### Scenario: SDD workspace активной задачи
|
||||
- **WHEN** worktree на неслитой ветке `feat/2` содержит `.superpowers/sdd/plan-y/`
|
||||
- **THEN** этот каталог хвостом не считается
|
||||
|
||||
#### Scenario: Чистый репозиторий
|
||||
- **WHEN** у репозитория нет слитых веток кроме main, нет worktree, SDD workspace, неархивных changes, и основной checkout совпадает с `origin/main`
|
||||
- **THEN** список хвостов пуст
|
||||
|
||||
### Requirement: Хвосты только читаются
|
||||
`retro_leftovers.py` SHALL NOT менять репозитории и их рабочие деревья. Единственное исключение — `git fetch --prune origin` при флаге `--fetch`. Вывод MUST быть JSON со списком `{kind, repo, item, hint}` и итогами по видам; с `--markdown` — таблица для черновика.
|
||||
|
||||
#### Scenario: Без --fetch
|
||||
- **WHEN** скрипт запущен без `--fetch`
|
||||
- **THEN** ссылки `refs/remotes/` и рабочее дерево репозитория не меняются
|
||||
|
||||
### Requirement: Выгрузка блокировок среза
|
||||
`hook_blocks_export.py --since <дата>` SHALL выгружать только блокировки вызовов не раньше даты, вместе с транскриптами сабагентов сессии, с идентификаторами `<дата>-NNN`. Выгрузка среза MUST сохраняться в `docs/retro/fixtures/hook-blocks-<дата>.jsonl`; прежняя выгрузка 12.10 не меняется.
|
||||
|
||||
#### Scenario: Ранний вызов не выгружается
|
||||
- **WHEN** в длинной сессии блокировка до `--since` и блокировка после
|
||||
- **THEN** выгружается только вторая
|
||||
|
||||
### Requirement: Разметка блокировок в корпус
|
||||
Агент SHALL размечать выгрузку в `docs/retro/fixtures/hook-labels-<дата>.json`:
|
||||
- для записи — `expected` (`allow|block`) и `why` по спеке `hook-guards`, либо `skip` с причиной;
|
||||
- для файла — раскладку песочницы.
|
||||
|
||||
`hook_cases_build.py` MUST добавлять по сегменту на каждую пару выгрузка + разметка и падать на выгрузке без разметки. Записи со `skip` в корпус не входят.
|
||||
|
||||
#### Scenario: Размеченная выгрузка в корпусе
|
||||
- **WHEN** в `hook-labels-2026-11-01.json` три записи размечены и одна имеет `skip`
|
||||
- **THEN** после сборки в корпусе новый сегмент с тремя записями, а `bash tests/run.sh` зелёный
|
||||
|
||||
### Requirement: Метрика ложных блокировок
|
||||
Ложной SHALL считаться блокировка среза, размеченная `expected: allow`. Метрика MUST выводить:
|
||||
- число блокировок;
|
||||
- число ложных и их долю;
|
||||
- разбивку ложных на уже исправленные (текущие хуки дают `allow`) и ещё ложные (с `known`).
|
||||
|
||||
#### Scenario: Исправленная и неисправленная
|
||||
- **WHEN** из 4 блокировок среза 3 размечены `allow`, по двум текущие хуки дают `allow`, одна помечена `known`
|
||||
- **THEN** ложных 3 из 4 (75 %): исправлено 2, ещё ложных 1
|
||||
|
||||
### Requirement: Разделы черновика
|
||||
Черновик SHALL создаваться по шаблону `.claude/skills/harness-retro/template.md`. Разделы:
|
||||
- шапка (период, проект, источники);
|
||||
- «Цифры» из `retro_metrics.py --markdown --prev`;
|
||||
- «Находки»;
|
||||
- «Поправки владельца»;
|
||||
- «Что сработало»;
|
||||
- «Бэклог»;
|
||||
- «Решения владельца» — пустой, заполняется на ревью;
|
||||
- «Что не измерялось», в том числе сторона Gitea чеклиста закрытия.
|
||||
|
||||
#### Scenario: Пустые решения
|
||||
- **WHEN** черновик создан
|
||||
- **THEN** раздел «Решения владельца» пуст и помечен «заполняется на ревью PR»
|
||||
|
||||
### Requirement: Нумерация находок и сохранение метрик
|
||||
Находки SHALL нумероваться `RT-n`, продолжая максимальный номер в прошлых `docs/retro/*.md`. Повторяющийся вид поправки владельца MUST становиться находкой. Метрики среза MUST сохраняться в `docs/retro/<дата>.metrics.json`.
|
||||
|
||||
#### Scenario: Продолжение нумерации
|
||||
- **WHEN** в `docs/retro/2026-10-11.md` максимальный маркер `RT-20`
|
||||
- **THEN** первая находка нового черновика — `RT-21`
|
||||
33
openspec/changes/harness-retro/specs/hook-guards/spec.md
Normal file
33
openspec/changes/harness-retro/specs/hook-guards/spec.md
Normal file
@ -0,0 +1,33 @@
|
||||
# Spec Delta: hook-guards
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Регрессионный корпус хуков
|
||||
Пакет SHALL содержать корпус вызовов хуков из реальных транскриптов и живых случаев. Каждая запись MUST иметь идентификатор, ожидаемый вердикт `allow|block` и код класса.
|
||||
|
||||
Корпус MUST состоять из сегментов: строка-шапка `{home, layout}`, за ней записи этого сегмента. Для каждого сегмента строится своя песочница с раскладкой из его шапки. Корпус MUST прогоняться через хуки тестами пакета по источникам и `doctor.sh` по установленным копиям.
|
||||
|
||||
Запись с полем `known: "<маркер находки>"` — известная ошибка текущих хуков:
|
||||
- её расхождение с ожидаемым вердиктом MUST NOT давать FAIL;
|
||||
- совпадение вердикта при наличии `known` MUST давать FAIL `KNOWN-FIXED`, чтобы пометку сняли.
|
||||
|
||||
#### Scenario: Расхождение в тестах пакета
|
||||
- **WHEN** хук из `hooks/` выносит по записи корпуса без `known` вердикт, отличный от ожидаемого
|
||||
- **THEN** `bash tests/run.sh` печатает FAIL с идентификатором и классом записи и завершается с ненулевым кодом
|
||||
|
||||
#### Scenario: Отчёт doctor
|
||||
- **WHEN** запущен `doctor.sh` при установленных хуках
|
||||
- **THEN** в разделе самотеста есть строка `корпус хуков: <совпало>/<всего>`, расхождения перечислены с идентификаторами
|
||||
- **AND** при наличии записей `known` строка продолжается `, известных ошибок <K>`
|
||||
|
||||
#### Scenario: Сегменты с разной раскладкой
|
||||
- **WHEN** корпус содержит сегмент пилота 12.10 и сегмент ретро с другим домашним каталогом и другими репозиториями
|
||||
- **THEN** каждая запись прогоняется в песочнице своего сегмента, и вердикты совпадают с ожидаемыми
|
||||
|
||||
#### Scenario: Известная ошибка
|
||||
- **WHEN** запись имеет `expected: allow`, `known: "RT-21"`, а текущий guard-main её блокирует
|
||||
- **THEN** тест корпуса не падает, в выводе запись отмечена как известная ошибка
|
||||
|
||||
#### Scenario: Известная ошибка исправлена
|
||||
- **WHEN** запись имеет `known: "RT-21"`, а текущие хуки выносят ожидаемый вердикт
|
||||
- **THEN** тест корпуса печатает FAIL `KNOWN-FIXED` с идентификатором записи
|
||||
@ -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
|
||||
|
||||
|
||||
@ -2,11 +2,12 @@
|
||||
"""Регрессионный корпус хуков (B2): прогон fixtures/hook-cases.jsonl через guard-main и require-plan в песочнице.
|
||||
|
||||
Запуск: python3 hook_corpus.py <каталог хуков> [--summary] [--fixtures <jsonl>]
|
||||
Песочница строится во временном каталоге по раскладке из первой строки корпуса; префикс домашнего каталога записей
|
||||
заменяется на её home, HOME хуков подменяется туда же. Bash идёт через guard-main, Edit/Write — через guard-main и
|
||||
require-plan (блок, если блокирует любой).
|
||||
Вывод: строка на запись «id<TAB>why<TAB>ожидалось<TAB>получено<TAB>команда» и итог «total N/M»;
|
||||
с --summary — только расхождения и «корпус хуков: N/M». Код выхода 0 — всё совпало, 1 — нет.
|
||||
Корпус — сегменты: строка с ключом layout ({home, layout}) открывает сегмент, следующие строки — его записи.
|
||||
Для каждого сегмента строится своя песочница; префикс home записей заменяется на её home, HOME хуков — туда же.
|
||||
Bash идёт через guard-main, Edit/Write — через guard-main и require-plan (блок, если блокирует любой).
|
||||
Запись с known — известная ошибка хуков: расхождение — KNOWN (не ошибка прогона), совпадение — KNOWN-FIXED (ошибка:
|
||||
снять known). Вывод: строка «id<TAB>why<TAB>ожидалось<TAB>получено<TAB>статус<TAB>команда» и итог «total N/M»;
|
||||
с --summary — только MISMATCH и KNOWN-FIXED и «корпус хуков: N/M[, известных ошибок K]». Код 0 — нет ошибок.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
@ -52,6 +53,23 @@ def verdict(hooks, case, prefix, home, env):
|
||||
return "allow"
|
||||
|
||||
|
||||
def segments(lines):
|
||||
"""[(шапка, [записи])]: строка с ключом layout открывает сегмент."""
|
||||
segs = []
|
||||
for obj in lines:
|
||||
if "layout" in obj:
|
||||
segs.append((obj, []))
|
||||
elif segs:
|
||||
segs[-1][1].append(obj)
|
||||
return segs
|
||||
|
||||
|
||||
def status(case, got):
|
||||
if got == case["expected"]:
|
||||
return "KNOWN-FIXED" if case.get("known") else "ok"
|
||||
return "KNOWN" if case.get("known") else "MISMATCH"
|
||||
|
||||
|
||||
def main(argv):
|
||||
args = argv[1:]
|
||||
summary = "--summary" in args
|
||||
@ -64,29 +82,33 @@ def main(argv):
|
||||
return 1
|
||||
hooks = os.path.abspath(pos[0])
|
||||
lines = [json.loads(l) for l in open(fx, encoding="utf-8") if l.strip()]
|
||||
head, cases = lines[0], lines[1:]
|
||||
env0 = {k: v for k, v in os.environ.items() if k not in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE")}
|
||||
tmp = tempfile.mkdtemp(prefix="hook-corpus-")
|
||||
try:
|
||||
home = os.path.join(tmp, "home")
|
||||
try:
|
||||
build(home, head["layout"])
|
||||
except Exception as e:
|
||||
print("корпус хуков: песочница не собрана (%s)" % e)
|
||||
return 1
|
||||
env = {k: v for k, v in os.environ.items() if k not in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE")}
|
||||
env["HOME"] = home
|
||||
ok = 0
|
||||
for c in cases:
|
||||
got = verdict(hooks, c, head["home"], home, env)
|
||||
ok += got == c["expected"]
|
||||
body = (c.get("command") or c.get("file_path") or "").replace(head["home"], "~")
|
||||
row = "\t".join([c["id"], c["why"], c["expected"], got, " ".join(body.split())[:70]])
|
||||
if not summary:
|
||||
print(row)
|
||||
elif got != c["expected"]:
|
||||
print("MISMATCH " + row)
|
||||
print(("корпус хуков: %d/%d" if summary else "total %d/%d") % (ok, len(cases)))
|
||||
return 0 if ok == len(cases) else 1
|
||||
total = good = known = 0
|
||||
for n, (head, cases) in enumerate(segments(lines)):
|
||||
home = os.path.join(tmp, "seg-%d" % n, "home")
|
||||
try:
|
||||
build(home, head["layout"])
|
||||
except Exception as e:
|
||||
print("корпус хуков: песочница сегмента %d не собрана (%s)" % (n + 1, e))
|
||||
return 1
|
||||
env = dict(env0, HOME=home)
|
||||
for c in cases:
|
||||
got = verdict(hooks, c, head["home"], home, env)
|
||||
st = status(c, got)
|
||||
total += 1
|
||||
good += st in ("ok", "KNOWN")
|
||||
known += st == "KNOWN"
|
||||
body = (c.get("command") or c.get("file_path") or "").replace(head["home"], "~")
|
||||
row = "\t".join([c["id"], c["why"], c["expected"], got, st, " ".join(body.split())[:70]])
|
||||
if not summary:
|
||||
print(row)
|
||||
elif st in ("MISMATCH", "KNOWN-FIXED"):
|
||||
print(st + " " + row)
|
||||
tail = ", известных ошибок %d" % known if known else ""
|
||||
print(("корпус хуков: %d/%d%s" if summary else "total %d/%d%s") % (good, total, tail))
|
||||
return 0 if good == total else 1
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
@ -4,7 +4,61 @@ F="$ROOT/skills/harness-doctor/fixtures/hook-cases.jsonl"
|
||||
assert_file "hook_corpus.py существует" "$R"
|
||||
assert_file "корпус hook-cases.jsonl" "$F"
|
||||
assert_eq "корпус совпадает со сборкой из выгрузки" "$(python3 "$ROOT/docs/retro/scripts/hook_cases_build.py" | cmp -s - "$F" && echo same)" "same"
|
||||
while IFS=$'\t' read -r id why exp got cmd; do
|
||||
while IFS=$'\t' read -r id why exp got st cmd; do
|
||||
case "$id" in total*) continue ;; esac
|
||||
assert_eq "корпус $id ($why): $cmd" "$got" "$exp"
|
||||
case "$st" in
|
||||
ok|KNOWN) ok "корпус $id ($why)" ;;
|
||||
*) fail "корпус $id ($why): $cmd" "ожидалось $exp, получено $got ($st)" ;;
|
||||
esac
|
||||
done < <(python3 "$R" "$ROOT/hooks")
|
||||
# сегменты и known (#29): синтетический корпус из двух сегментов с разными home
|
||||
S="$TMP/seg"; mkdir -p "$S"
|
||||
cat > "$S/c.jsonl" <<'EOF'
|
||||
{"home": "/h1", "layout": [{"path": "m", "branch": "main"}]}
|
||||
{"id": "a1", "why": "main", "tool": "Write", "file_path": "/h1/m/a.md", "cwd": "/h1/m", "expected": "block"}
|
||||
{"id": "a2", "why": "known-x", "tool": "Write", "file_path": "/h1/m/b.md", "cwd": "/h1/m", "expected": "allow", "known": "RT-21"}
|
||||
{"home": "/h2", "layout": [{"path": "r", "branch": "feat/x"}]}
|
||||
{"id": "b1", "why": "branch", "tool": "Write", "file_path": "/h2/r/a.md", "cwd": "/h2/r", "expected": "allow"}
|
||||
EOF
|
||||
out=$(python3 "$R" "$ROOT/hooks" --summary --fixtures "$S/c.jsonl"); rc=$?
|
||||
assert_eq "два сегмента и известная ошибка: exit 0" "$rc" "0"
|
||||
assert_eq "итог с известными ошибками" "$out" "корпус хуков: 3/3, известных ошибок 1"
|
||||
sed 's/"expected": "allow", "known"/"expected": "block", "known"/' "$S/c.jsonl" > "$S/f.jsonl"
|
||||
out=$(python3 "$R" "$ROOT/hooks" --summary --fixtures "$S/f.jsonl"); rc=$?
|
||||
assert_eq "known совпал с вердиктом: exit 1" "$rc" "1"
|
||||
assert_grep "KNOWN-FIXED с id записи" '^KNOWN-FIXED a2' <(printf '%s' "$out")
|
||||
# разметка ретро (#29): hook-labels-<дата>.json + hook-blocks-<дата>.jsonl → новый сегмент корпуса
|
||||
B="$ROOT/docs/retro/scripts/hook_cases_build.py"
|
||||
FX="$TMP/fx"; mkdir -p "$FX"; cp "$ROOT/docs/retro/fixtures/hook-blocks.jsonl" "$FX/"
|
||||
cat > "$FX/hook-blocks-2026-11-01.jsonl" <<'EOF'
|
||||
{"id": "2026-11-01-001", "session": "s", "ts": "2026-11-02T10:00:00Z", "cwd": "/h3/r", "branch": "main", "tool": "Bash", "command": "ls x 2>/dev/null", "file_path": null, "hook": "guard-main(Bash)", "message": "BLOCKED", "expected": null}
|
||||
{"id": "2026-11-01-002", "session": "s", "ts": "2026-11-02T10:01:00Z", "cwd": "/h3/r", "branch": "main", "tool": "Bash", "command": "echo x > f", "file_path": null, "hook": "guard-main(Bash)", "message": "BLOCKED", "expected": null}
|
||||
{"id": "2026-11-01-003", "session": "s", "ts": "2026-11-02T10:02:00Z", "cwd": "/h3/r", "branch": "main", "tool": "Bash", "command": "cat $X", "file_path": null, "hook": "guard-main(Bash)", "message": "BLOCKED", "expected": null}
|
||||
EOF
|
||||
cat > "$FX/hook-labels-2026-11-01.json" <<'EOF'
|
||||
{"home": "/h3", "layout": [{"path": "r", "branch": "main"}],
|
||||
"cases": {"2026-11-01-001": {"expected": "allow", "why": "devnull"},
|
||||
"2026-11-01-002": {"expected": "block", "why": "main-redirect"},
|
||||
"2026-11-01-003": {"skip": "цель в переменной, состояние неизвестно"}}}
|
||||
EOF
|
||||
out=$(python3 "$B" --fixtures-dir "$FX"); rc=$?
|
||||
assert_eq "сборка с разметкой ретро: exit 0" "$rc" "0"
|
||||
assert_eq "в корпусе два сегмента" "$(printf '%s\n' "$out" | grep -c '"layout"')" "2"
|
||||
assert_eq "сегмент ретро: две записи" "$(printf '%s\n' "$out" | grep -c '"2026-11-01-00[12]"')" "2"
|
||||
assert_eq "запись со skip не в корпусе" "$(printf '%s\n' "$out" | grep -c '2026-11-01-003')" "0"
|
||||
printf '%s\n' "$out" > "$FX/cases.jsonl"
|
||||
assert_eq "корпус с сегментом ретро проходит хуки" "$(python3 "$R" "$ROOT/hooks" --summary --fixtures "$FX/cases.jsonl" | tail -1)" "корпус хуков: 131/131"
|
||||
python3 - "$FX/hook-labels-2026-11-01.json" <<'PY'
|
||||
import json, sys
|
||||
p = sys.argv[1]; d = json.load(open(p)); del d["cases"]["2026-11-01-002"]; d["cases"]["2026-11-01-009"] = {"skip": "x"}
|
||||
json.dump(d, open(p, "w"))
|
||||
PY
|
||||
out=$(python3 "$B" --fixtures-dir "$FX" 2>&1 >/dev/null); rc=$?
|
||||
assert_eq "разметка не совпадает с выгрузкой: exit 1" "$rc" "1"
|
||||
assert_grep "сообщение называет недостающий и лишний id" '2026-11-01-002.*2026-11-01-009' <(printf '%s' "$out")
|
||||
# I-5: выгрузка ретро без парной разметки — сборка падает, а не пропускает её молча
|
||||
FX2="$TMP/fx2"; mkdir -p "$FX2"; cp "$ROOT/docs/retro/fixtures/hook-blocks.jsonl" "$FX2/"
|
||||
head -1 "$FX/hook-blocks-2026-11-01.jsonl" > "$FX2/hook-blocks-2026-11-07.jsonl"
|
||||
out=$(python3 "$B" --fixtures-dir "$FX2" 2>&1 >/dev/null); rc=$?
|
||||
assert_eq "выгрузка без разметки: exit 1" "$rc" "1"
|
||||
assert_grep "сообщение называет файл выгрузки" 'hook-blocks-2026-11-07\.jsonl' <(printf '%s' "$out")
|
||||
|
||||
@ -13,3 +13,23 @@ out=$(python3 "$X" "$T")
|
||||
assert_eq "две записи: вывод cat с BLOCKED не выгружен" "$(printf '%s\n' "$out" | grep -c .)" "2"
|
||||
assert_eq "новый префикс → guard-main(Bash)" "$(printf '%s\n' "$out" | sed -n 1p | python3 -c 'import json,sys; r=json.load(sys.stdin); print(r["hook"], r["command"])')" "guard-main(Bash) echo x > f"
|
||||
assert_eq "старая фраза → guard-main(Edit/Write)" "$(printf '%s\n' "$out" | sed -n 2p | python3 -c 'import json,sys; r=json.load(sys.stdin); print(r["hook"], r["file_path"])')" "guard-main(Edit/Write) /r/a.md"
|
||||
# --since и --id-prefix (B12, #29): только сессии не раньше даты, id <дата>-NNN; модуль импортируется без выгрузки
|
||||
T2="$TMP/tr2"; mkdir -p "$T2"
|
||||
blk='{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"u1","is_error":true,"content":"PreToolUse:Bash hook error: BLOCKED [guard-main]: запись в f"}]}}'
|
||||
printf '%s\n%s\n' '{"type":"assistant","cwd":"/r","gitBranch":"main","timestamp":"2026-10-01T10:00:00Z","message":{"content":[{"type":"tool_use","id":"u1","name":"Bash","input":{"command":"echo a > f"}}]}}' "$blk" > "$T2/old.jsonl"
|
||||
printf '%s\n%s\n' '{"type":"assistant","cwd":"/r","gitBranch":"main","timestamp":"2026-11-02T10:00:00Z","message":{"content":[{"type":"tool_use","id":"u1","name":"Bash","input":{"command":"echo b > f"}}]}}' "$blk" > "$T2/new.jsonl"
|
||||
# C-1: блокировка сабагента — в выгрузку сессии-родителя
|
||||
mkdir -p "$T2/new/subagents"
|
||||
printf '%s\n%s\n' '{"type":"assistant","isSidechain":true,"cwd":"/r","gitBranch":"main","timestamp":"2026-11-02T11:00:00Z","message":{"content":[{"type":"tool_use","id":"u1","name":"Bash","input":{"command":"echo c > f"}}]}}' "$blk" > "$T2/new/subagents/agent-x.jsonl"
|
||||
# I-7: длинная сессия на стыке — блок до периода не выгружается, в периоде — выгружается; I-3: секрет на границе обрезки
|
||||
lblk='{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"u2","is_error":true,"content":"PreToolUse:Bash hook error: BLOCKED [guard-main] '"$(printf 'z%.0s' $(seq 235))"' fedcba9876543210fedcba9876543210fedcba98"}]}}'
|
||||
printf '%s\n%s\n%s\n%s\n' '{"type":"assistant","cwd":"/r","gitBranch":"main","timestamp":"2026-10-01T10:00:00Z","message":{"content":[{"type":"tool_use","id":"u1","name":"Bash","input":{"command":"echo k > f"}}]}}' "$blk" '{"type":"assistant","cwd":"/r","gitBranch":"main","timestamp":"2026-11-03T10:00:00Z","message":{"content":[{"type":"tool_use","id":"u2","name":"Bash","input":{"command":"echo l > f"}}]}}' "$lblk" > "$T2/long.jsonl"
|
||||
out=$(python3 "$X" --since 2026-11-01 --id-prefix 2026-11-05 "$T2")
|
||||
assert_eq "--since по записям, сабагенты — в выгрузке, id <дата>-NNN" "$(printf '%s\n' "$out" | python3 -c 'import json,sys; print([(r["id"], r["session"], r["command"]) for r in map(json.loads, sys.stdin)])')" "[('2026-11-05-001', 'long.jso', 'echo l > f'), ('2026-11-05-002', 'new.json', 'echo b > f'), ('2026-11-05-003', 'new.json', 'echo c > f')]"
|
||||
assert_eq "секрет на границе обрезки сообщения не утекает" "$(printf '%s\n' "$out" | python3 -c '
|
||||
import sys
|
||||
out, t = sys.stdin.read(), "fedcba9876543210fedcba9876543210fedcba98"
|
||||
print(sum(t[i:i + 8] in out for i in range(len(t) - 7)))')" "0"
|
||||
assert_eq "старая обрезка до redact утекла бы (проверка теста)" "$(python3 -c "import sys; sys.path.insert(0, '$ROOT/docs/retro/scripts'); import hook_blocks_export as h; m='PreToolUse:Bash hook error: BLOCKED [guard-main] ' + 'z'*235 + ' fedcba9876543210fedcba9876543210fedcba98'; print('fedcba98' in h.redact(m[:300]), 'fedcba98' in h.clip(m, 300))")" "True False"
|
||||
assert_eq "без флагов поля id нет (выгрузка 12.10 не меняется)" "$(python3 "$X" "$T2" | python3 -c 'import json,sys; print(sum("id" in json.loads(l) for l in sys.stdin))')" "0"
|
||||
assert_eq "импорт модуля не выгружает" "$(python3 -c "import sys; sys.argv=['x', '$T2']; sys.path.insert(0, '$ROOT/docs/retro/scripts'); import hook_blocks_export" 2>&1)" ""
|
||||
|
||||
60
tests/test-retro-leftovers.sh
Normal file
60
tests/test-retro-leftovers.sh
Normal file
@ -0,0 +1,60 @@
|
||||
# retro_leftovers.py: хвосты чеклиста закрытия по состоянию репозиториев (спека harness-retro, #29).
|
||||
L="$ROOT/docs/retro/scripts/retro_leftovers.py"
|
||||
G="$TMP/lo"; mkdir -p "$G"
|
||||
git init -q --bare -b main "$G/o.git"
|
||||
git clone -q "$G/o.git" "$G/c" 2>/dev/null; git -C "$G/c" symbolic-ref HEAD refs/heads/main
|
||||
printf '.superpowers/\n.claude/worktrees/\n' > "$G/c/.gitignore"
|
||||
git -C "$G/c" add .gitignore && git -C "$G/c" commit -q -m init && git -C "$G/c" push -q origin main 2>/dev/null
|
||||
git -C "$G/c" switch -q -c feat/1 && git -C "$G/c" commit -q --allow-empty -m f1 && git -C "$G/c" push -q -u origin feat/1 2>/dev/null
|
||||
git -C "$G/c" switch -q main && git -C "$G/c" merge -q --no-ff feat/1 -m "merge feat/1"
|
||||
mkdir -p "$G/c/openspec/changes/foo" "$G/c/openspec/changes/archive/2026-01-01-bar"
|
||||
echo x > "$G/c/openspec/changes/foo/proposal.md"; echo y > "$G/c/openspec/changes/archive/2026-01-01-bar/p.md"
|
||||
git -C "$G/c" add openspec && git -C "$G/c" commit -q -m spec && git -C "$G/c" push -q origin main 2>/dev/null
|
||||
git -C "$G/c" worktree add -q "$G/c/.claude/worktrees/w1" feat/1
|
||||
mkdir -p "$G/c/.superpowers/sdd/plan-x" && touch "$G/c/.superpowers/sdd/plan-x/progress.md"
|
||||
# активная задача: worktree на неслитой ветке со своим SDD workspace — не хвост
|
||||
git -C "$G/c" worktree add -q "$G/c/.claude/worktrees/w2" -b feat/2 && git -C "$G/c/.claude/worktrees/w2" commit -q --allow-empty -m wip
|
||||
mkdir -p "$G/c/.claude/worktrees/w2/.superpowers/sdd/plan-y" && touch "$G/c/.claude/worktrees/w2/.superpowers/sdd/plan-y/progress.md"
|
||||
git clone -q "$G/o.git" "$G/c2" 2>/dev/null && git -C "$G/c2" commit -q --allow-empty -m ahead && git -C "$G/c2" push -q origin main 2>/dev/null
|
||||
git -C "$G/c" fetch -q origin
|
||||
echo x > "$G/c/untracked.txt"
|
||||
J="$TMP/lo.json"
|
||||
python3 "$L" "$G/c" > "$J"; rc=$?
|
||||
assert_eq "хвосты: exit 0" "$rc" "0"
|
||||
k(){ python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(eval(sys.argv[2]))' "$J" "$1"; }
|
||||
assert_eq "виды хвостов" "$(k 'sorted(d["totals"])')" "['local-branch', 'main-behind', 'main-dirty', 'openspec-change', 'remote-branch', 'sdd-workspace', 'worktree']"
|
||||
assert_eq "ветка на сервере и подсказка" "$(k '[(i["item"], i["hint"]) for i in d["items"] if i["kind"] == "remote-branch"]')" "[('origin/feat/1', 'git push origin --delete feat/1')]"
|
||||
assert_eq "локальная ветка" "$(k '[i["item"] for i in d["items"] if i["kind"] == "local-branch"]')" "['feat/1']"
|
||||
assert_eq "worktree на слитой ветке" "$(k '[i["item"].endswith("/.claude/worktrees/w1") for i in d["items"] if i["kind"] == "worktree"]')" "[True]"
|
||||
assert_eq "SDD workspace" "$(k '[i["item"].endswith("/sdd/plan-x") for i in d["items"] if i["kind"] == "sdd-workspace"]')" "[True]"
|
||||
assert_eq "неархивный change — без archive" "$(k '[i["item"] for i in d["items"] if i["kind"] == "openspec-change"]')" "['openspec/changes/foo']"
|
||||
assert_grep "отставание от origin/main" 'позади origin/main на 1' "$J"
|
||||
assert_grep "грязное дерево с путём" 'untracked.txt' "$J"
|
||||
# только чтение: без --fetch ссылки и дерево не меняются
|
||||
git -C "$G/c2" commit -q --allow-empty -m ahead2 && git -C "$G/c2" push -q origin main 2>/dev/null
|
||||
before="$(git -C "$G/c" for-each-ref)$(git -C "$G/c" status --porcelain)"
|
||||
python3 "$L" "$G/c" > /dev/null
|
||||
assert_eq "без --fetch ничего не меняется" "$(git -C "$G/c" for-each-ref)$(git -C "$G/c" status --porcelain)" "$before"
|
||||
python3 "$L" "$G/c" --fetch > "$J"
|
||||
assert_grep "--fetch видит новые коммиты" 'позади origin/main на 2' "$J"
|
||||
md=$(python3 "$L" "$G/c" --markdown)
|
||||
assert_grep "таблица для черновика" '^\| remote-branch \| c \| `origin/feat/1` \| `git push origin --delete feat/1` \|$' <(printf '%s' "$md")
|
||||
# чистый репозиторий: свежая ветка на вершине origin/main — не хвост
|
||||
git init -q --bare -b main "$G/k.git"; git clone -q "$G/k.git" "$G/k" 2>/dev/null; git -C "$G/k" symbolic-ref HEAD refs/heads/main
|
||||
git -C "$G/k" commit -q --allow-empty -m init && git -C "$G/k" push -q origin main 2>/dev/null && git -C "$G/k" branch feat/new
|
||||
python3 "$L" "$G/k" > "$J"
|
||||
assert_eq "чистый репозиторий — хвостов нет" "$(k 'd["items"]')" "[]"
|
||||
assert_eq "чистый репозиторий — markdown" "$(python3 "$L" "$G/k" --markdown)" "Хвостов закрытия нет."
|
||||
# без origin
|
||||
git init -q -b main "$G/n" && git -C "$G/n" commit -q --allow-empty -m init
|
||||
python3 "$L" "$G/n" > "$J"; rc=$?
|
||||
assert_eq "без origin: exit 0 и no-origin-main" "$rc $(k '[i["kind"] for i in d["items"]]')" "0 ['no-origin-main']"
|
||||
# workspace: репозитории из таблицы CLAUDE.md
|
||||
W="$TMP/ws"; mkdir -p "$W"; git clone -q "$G/o.git" "$W/r" 2>/dev/null
|
||||
printf '# w\n\n| Каталог | Репозиторий | Роль |\n|---|---|---|\n| r/ | o/r | dev |\n| - | x | qa |\n' > "$W/CLAUDE.md"
|
||||
python3 "$L" "$W" > "$J"
|
||||
assert_eq "workspace: репозитории из таблицы" "$(k '[r.endswith("/ws/r") for r in d["repos"]]')" "[True]"
|
||||
assert_eq "workspace: ветка на сервере видна" "$(k '[i["item"] for i in d["items"] if i["kind"] == "remote-branch"]')" "['origin/feat/1']"
|
||||
# не проект и не workspace
|
||||
mkdir -p "$TMP/empty"; python3 "$L" "$TMP/empty" > /dev/null 2>&1; rc=$?
|
||||
assert_eq "не репозиторий и не workspace: exit 1" "$rc" "1"
|
||||
178
tests/test-retro-metrics.sh
Normal file
178
tests/test-retro-metrics.sh
Normal file
@ -0,0 +1,178 @@
|
||||
# retro_metrics.py: срез метрик по синтетическим транскриптам (спека harness-retro, #29).
|
||||
M="$ROOT/docs/retro/scripts/retro_metrics.py"
|
||||
P="$TMP/proj"; CR="$TMP/cc/projects"; mkdir -p "$P/.git" "$P/.claude" "$P/hooks" "$CR"
|
||||
echo 'hooks/' > "$P/.claude/code-paths"
|
||||
cat > "$TMP/gen.py" <<'PY'
|
||||
import json, os, re, sys
|
||||
P, CR = sys.argv[1], sys.argv[2]
|
||||
slug = lambda p: re.sub(r"[^A-Za-z0-9]", "-", p)
|
||||
def w(d, name, recs):
|
||||
os.makedirs(os.path.join(CR, d), exist_ok=True)
|
||||
with open(os.path.join(CR, d, name), "w", encoding="utf-8") as fh:
|
||||
for r in recs:
|
||||
fh.write((r if isinstance(r, str) else json.dumps(r, ensure_ascii=False)) + "\n")
|
||||
def u(text, ts, cwd=P, **kw):
|
||||
return dict({"type": "user", "timestamp": ts, "cwd": cwd, "message": {"role": "user", "content": text}}, **kw)
|
||||
def tr(tid, text, ts):
|
||||
return {"type": "user", "timestamp": ts, "cwd": P,
|
||||
"message": {"content": [{"type": "tool_result", "tool_use_id": tid, "content": text}]}}
|
||||
def a(mid, content, ts, cwd=P, usage=None):
|
||||
m = {"id": mid, "role": "assistant", "content": content}
|
||||
if usage:
|
||||
m["usage"] = usage
|
||||
return {"type": "assistant", "timestamp": ts, "cwd": cwd, "message": m}
|
||||
def tu(tid, tool, **inp):
|
||||
return {"type": "tool_use", "id": tid, "name": tool, "input": inp}
|
||||
A = slug(P)
|
||||
T = "2026-11-02T10:%02d:00Z"
|
||||
U1 = {"input_tokens": 100, "cache_creation_input_tokens": 1000, "cache_read_input_tokens": 50000, "output_tokens": 10}
|
||||
w(A, "s1.jsonl", [
|
||||
u("сделай X", T % 0),
|
||||
a("m1", [{"type": "text", "text": "Начинаю."}], T % 1, usage=U1),
|
||||
a("m1", [tu("t0", "Skill", skill="role", args="dev")], T % 1, usage=U1),
|
||||
a("m2", [tu("t1", "Read", file_path=P + "/hooks/a.py"), tu("t2", "Read", file_path=P + "/docs/x.md")], T % 2),
|
||||
a("m3", [tu("t3", "mcp__codegraph__codegraph_explore", query="x"), tu("t4", "mcp__codegraph__codegraph_explore", query="y"),
|
||||
tu("t5", "Bash", command="grep -rn x hooks/"), tu("t6", "Bash", command="codegraph init"),
|
||||
tu("t7", "Agent", model="sonnet", prompt="p"), tu("t8", "Agent", prompt="q"),
|
||||
tu("t9", "Bash", command="echo x > f"), tu("t10", "Bash", command="cat hooks/require-plan.sh")], T % 3),
|
||||
tr("t9", 'PreToolUse:Bash hook error: ["$HOME/.claude/hooks/guard-main.sh"]: BLOCKED [guard-main]: запись в f', T % 3),
|
||||
tr("t10", 'echo "BLOCKED: editing without a plan" >&2', T % 3),
|
||||
tr("t6", "Permission for this action was denied by the Claude Code auto mode classifier. Reason: [Self-Modification]. If", T % 3),
|
||||
tr("t5", "The user doesn't want to proceed with this tool use. The tool use was rejected", T % 3),
|
||||
u("<command-name>/clear</command-name>", T % 4),
|
||||
u("служебное", T % 4, isMeta=True),
|
||||
u("[Request interrupted by user]", T % 4),
|
||||
a("m4", [{"type": "text", "text": "Вхожу в worktree."}, tu("t11", "EnterWorktree", name="x")], T % 5),
|
||||
u("почему мне нужно это каждый раз подтверждать?", T % 6),
|
||||
u("перечитай память", T % 7),
|
||||
a("m5", [tu("t12", "Bash", command="git branch -m feat/x")], T % 8),
|
||||
u("нет, tea login add --token 0123456789abcdef0123456789abcdef01234567 не так", T % 9),
|
||||
{"type": "system", "subtype": "compact_boundary", "timestamp": T % 9},
|
||||
"not json",
|
||||
"[]",
|
||||
# I-3: секрет на границе обрезки хвоста ответа (200) и текста кандидата (300)
|
||||
a("m6", [{"type": "text", "text": "Выполни: tea login add --token fedcba9876543210fedcba9876543210fedcba98 " + "y" * 170}], T % 10),
|
||||
u("стоп, " + "x" * 270 + " 0123abcd0123abcd0123abcd0123abcd0123abcd", T % 11),
|
||||
# I-6: упоминание контекста — не «перечитай»; сводка компакции — не промпт владельца
|
||||
u("я сейчас смотрю на размер контекста в чистой сессии", T % 12),
|
||||
{"type": "user", "timestamp": T % 13, "cwd": P, "isCompactSummary": True, "isVisibleInTranscriptOnly": True,
|
||||
"message": {"role": "user", "content": "This session is being continued from a previous conversation. Нет, перечитай память."}},
|
||||
# I-1: «|» внутри кавычек не режет команду
|
||||
a("m7", [tu("t13", "Bash", command='grep -n "a\\|b" hooks/x.py'), tu("t14", "Bash", command='cat docs/a.md | grep "x|y" docs/')], T % 14),
|
||||
])
|
||||
# C-1: транскрипт сабагента — в сессию-родителя (инструменты, ходы, токены, codegraph, блокировки, отказы), без промптов
|
||||
os.makedirs(os.path.join(CR, A, "s1", "subagents"), exist_ok=True)
|
||||
w(os.path.join(A, "s1", "subagents"), "agent-a1.jsonl", [
|
||||
{"type": "user", "isSidechain": True, "timestamp": "2026-11-02T10:03:30Z", "cwd": P,
|
||||
"message": {"role": "user", "content": "Задача: реализуй, почему бы и нет"}},
|
||||
a("sa1", [tu("sx1", "mcp__codegraph__codegraph_explore", query="z"), tu("sx2", "Read", file_path=P + "/hooks/b.py"),
|
||||
tu("sx3", "Bash", command="echo y > g"), tu("sx4", "Bash", command="git push --force")], "2026-11-02T10:03:40Z",
|
||||
usage={"input_tokens": 10, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 5}),
|
||||
tr("sx3", 'PreToolUse:Bash hook error: BLOCKED [guard-main]: запись в g', "2026-11-02T10:03:41Z"),
|
||||
tr("sx4", "denied by the Claude Code auto mode classifier. Reason: [Git Destructive]", "2026-11-02T10:03:42Z"),
|
||||
])
|
||||
# I-7: длинная сессия на стыке периодов — считаются только записи периода, стартовый контекст — вне периода
|
||||
w(A, "s6.jsonl", [
|
||||
u("старт до периода", "2026-10-30T09:00:00Z"),
|
||||
a("p1", [], "2026-10-30T09:01:00Z",
|
||||
usage={"input_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 40000, "output_tokens": 0}),
|
||||
u("продолжаю", "2026-11-02T08:00:00Z"),
|
||||
a("p2", [tu("z1", "Bash", command="ls")], "2026-11-02T08:01:00Z",
|
||||
usage={"input_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 70000, "output_tokens": 0}),
|
||||
])
|
||||
W = P + "/.claude/worktrees/x"
|
||||
w(A + "--claude-worktrees-x", "s2.jsonl", [
|
||||
u("ок", "2026-11-03T09:00:00Z", cwd=W),
|
||||
{"type": "assistant", "timestamp": "2026-11-03T09:00:30Z", "cwd": W, "isApiErrorMessage": True, # не ход модели
|
||||
"message": {"id": "syn-1", "model": "<synthetic>", "role": "assistant", "content": [{"type": "text", "text": "API Error"}],
|
||||
"usage": {"input_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 0}}},
|
||||
a("n1", [tu("x1", "Bash", command='grep "unbalanced hooks/a.py')], "2026-11-03T09:01:00Z", cwd=W,
|
||||
usage={"input_tokens": 0, "cache_creation_input_tokens": 6000, "cache_read_input_tokens": 50000, "output_tokens": 0}),
|
||||
])
|
||||
s5 = [u("да", "2026-11-04T09:00:00Z", cwd=P + ".wt/y"),
|
||||
a("k1", [tu("y1", "Grep", pattern="x")], "2026-11-04T09:01:00Z", cwd=P + ".wt/y",
|
||||
usage={"input_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 61000, "output_tokens": 0})]
|
||||
w(slug(P + ".wt/y"), "s5.jsonl", s5)
|
||||
w(A, "s5.jsonl", s5[:1]) # усечённая копия той же сессии — берётся больший файл
|
||||
w(A, "s0.jsonl", [u("старое", "2026-10-01T09:00:00Z")])
|
||||
w(A + "-tools", "s3.jsonl", [u("чужое", "2026-11-02T09:00:00Z", cwd=P + "-tools")])
|
||||
os.makedirs(os.path.join(CR, A, "memory"), exist_ok=True)
|
||||
open(os.path.join(CR, A, "memory", "MEMORY.md"), "w").write("x" * 100)
|
||||
open(os.path.join(CR, A, "memory", "note.md"), "w").write("y" * 50)
|
||||
PY
|
||||
python3 "$TMP/gen.py" "$P" "$CR"
|
||||
J="$TMP/m.json"
|
||||
python3 "$M" "$P/" --since 2026-11-01 --projects-root "$CR" --out "$J"; rc=$?
|
||||
assert_eq "срез: exit 0" "$rc" "0"
|
||||
q(){ python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); m=d["metrics"]; print(eval(sys.argv[2]))' "$J" "$1"; }
|
||||
assert_eq "схема 1" "$(q 'd["schema"]')" "1"
|
||||
assert_eq "сессии: s1, s2 (worktree), s5 (.wt), s6 (на стыке) — без старой и чужой" "$(q 'm["sessions"]')" "4"
|
||||
assert_eq "каталоги транскриптов среза" "$(q 'len(d["sources"]["dirs"])')" "3"
|
||||
assert_eq "битые строки" "$(q 'd["sources"]["bad_lines"]')" "1"
|
||||
assert_eq "промпты владельца без служебных, прерываний, сводки компакции, сабагента и записей до периода" "$(q 'm["prompts"]')" "9"
|
||||
assert_eq "прерывания" "$(q 'm["interrupts"]')" "1"
|
||||
assert_eq "ходы — по message.id, с сабагентом, только в периоде" "$(q 'm["turns"]')" "11"
|
||||
assert_eq "вызовы инструментов с сабагентом" "$(q 'm["tool_calls"]')" "22"
|
||||
assert_eq "токены вывода один раз на сообщение, с сабагентом" "$(q 'm["tokens"]["output"]')" "15"
|
||||
assert_eq "стартовый контекст — без сабагента и без сессии, начатой до периода" "$(q 'sorted(m["start_context"].items())')" "[('max', 61000), ('median', 56000), ('min', 51100)]"
|
||||
assert_eq "блокировки только по ошибке хука, с сабагентом" "$(q 'm["hook_blocks"], m["hook_blocks_by_hook"]')" "(2, {'guard-main(Bash)': 2})"
|
||||
assert_eq "отказы классификатора по причине, с сабагентом" "$(q 'sorted(m["classifier_by_reason"].items())')" "[('Git Destructive', 1), ('Self-Modification', 1)]"
|
||||
assert_eq "отказы владельца" "$(q 'm["owner_denials"]')" "1"
|
||||
assert_eq "codegraph против чтения кода: с сабагентом и grep с «|» в кавычках" "$(q 'sorted(m["codegraph"].items())')" "[('code_reads', 7), ('explore', 3), ('explore_share', 0.3), ('init', 1)]"
|
||||
assert_eq "роли" "$(q 'sorted(m["roles"].items())')" "[('dev', 1), ('не выбрана', 3)]"
|
||||
assert_eq "сабагенты по моделям" "$(q 'sorted(m["subagent_models"].items())')" "[('sonnet', 1), ('не указана', 1)]"
|
||||
assert_eq "skills" "$(q 'm["skills"]')" "{'role': 1}"
|
||||
assert_eq "компакции; «перечитай» — только побуждение перечитать" "$(q 'm["compactions"], m["recall"]')" "(1, 1)"
|
||||
assert_eq "кандидаты в поправки" "$(q 'm["corrections"]')" "5"
|
||||
assert_eq "память" "$(q 'sorted(m["memory"].items())')" "[('bytes', 150), ('files', 2), ('index_bytes', 100)]"
|
||||
assert_eq "памяти нет — нули" "$(python3 -c "import sys; sys.path.insert(0, '$ROOT/docs/retro/scripts'); import retro_metrics; print(sorted(retro_metrics.memory_stats('$TMP/none', '$P').items()))")" "[('bytes', 0), ('files', 0), ('index_bytes', 0)]"
|
||||
# I-2: правило src/ без code-paths — каталог src как аргумент тоже код
|
||||
NC="$TMP/nocp"; mkdir -p "$NC/.git" "$NC/src" "$NC/svc/src"
|
||||
assert_eq "src/ без code-paths: каталог как аргумент" "$(python3 -c "import sys; sys.path.insert(0, '$ROOT/docs/retro/scripts'); import retro_metrics as r; print([r.bash_reads_code(c, '$NC', {}) for c in ('grep -rn foo src/', 'grep -rn foo src', 'find svc/src -name x', 'grep -rn foo docs/')])")" "[True, True, True, False]"
|
||||
# кандидаты в поправки: контекст и redact
|
||||
C="$TMP/corr.jsonl"
|
||||
python3 "$M" "$P" --since 2026-11-01 --projects-root "$CR" --corrections > "$C"
|
||||
assert_eq "кандидатов 5" "$(grep -c . "$C")" "5"
|
||||
assert_eq "«почему» после EnterWorktree" "$(python3 -c 'import json,sys; print([c["action"] for c in map(json.loads, open(sys.argv[1])) if "почему" in c["text"]])' "$C")" "['EnterWorktree']"
|
||||
assert_eq "последнее действие Bash" "$(python3 -c 'import json,sys; print([c["action"] for c in map(json.loads, open(sys.argv[1])) if c["text"].startswith("нет")])' "$C")" "['Bash: git branch -m feat/x']"
|
||||
assert_eq "токен замазан" "$(grep -c '0123456789abcdef' "$C")" "0"
|
||||
assert_grep "токен заменён на <redacted>" '<redacted>' "$C"
|
||||
assert_eq "секрет на границе обрезки не утекает частично (I-3)" "$(python3 -c '
|
||||
import sys
|
||||
out = open(sys.argv[1], encoding="utf-8").read()
|
||||
toks = ["fedcba9876543210fedcba9876543210fedcba98", "0123abcd0123abcd0123abcd0123abcd0123abcd"]
|
||||
print(sum(t[i:i + 8] in out for t in toks for i in range(len(t) - 7)))' "$C")" "0"
|
||||
assert_eq "виды кандидатов" "$(python3 -c 'import json,sys; print(sorted(set(c["kind"] for c in map(json.loads, open(sys.argv[1])))))' "$C")" "['denial', 'interrupt', 'prompt']"
|
||||
# сравнение с прошлым ретро
|
||||
cat > "$TMP/prev.json" <<'EOF'
|
||||
{"schema": 1, "metrics": {"sessions": 44, "turns": {"value": 12121, "note": "по строкам"}, "cd_main": {"retired": "хуки 0.4.0"}}}
|
||||
EOF
|
||||
md=$(python3 "$M" "$P" --since 2026-11-01 --projects-root "$CR" --markdown --prev "$TMP/prev.json")
|
||||
assert_grep "строка с разницей" '^\| Сессий \| 4 \| 44 \| -40 \|$' <(printf '%s' "$md")
|
||||
assert_grep "новая метрика — прочерки" '^\| Кандидатов в поправки владельца \| 5 \| — \| — \|$' <(printf '%s' "$md")
|
||||
assert_grep "снятая метрика" '^\| cd в основной checkout на main \| — \| — \| не считается: хуки 0.4.0 \|$' <(printf '%s' "$md")
|
||||
assert_grep "метрика с другим способом подсчёта" '^\| Ходов модели \| 11 \| 12 121 \(по строкам\) \| — \|$' <(printf '%s' "$md")
|
||||
# ложные блокировки по разметке
|
||||
cat > "$TMP/labels.json" <<'EOF'
|
||||
{"home": "/h", "layout": [], "cases": {"x-001": {"expected": "allow", "why": "a"}, "x-002": {"expected": "allow", "why": "b"},
|
||||
"x-003": {"expected": "allow", "why": "c", "known": "RT-21"}, "x-004": {"expected": "block", "why": "d"}, "x-005": {"skip": "s"}}}
|
||||
EOF
|
||||
python3 "$M" "$P" --since 2026-11-01 --projects-root "$CR" --labels "$TMP/labels.json" --out "$J"
|
||||
assert_eq "ложные блокировки" "$(q 'sorted(m["false_blocks"].items())')" "[('false', 3), ('fixed', 2), ('known', 1), ('labelled', 4), ('share', 0.75), ('skipped', 1)]"
|
||||
# период по умолчанию — от даты последнего ретро
|
||||
H="$TMP/harness"; mkdir -p "$H/docs/retro"; touch "$H/docs/retro/2026-11-03.md" "$H/docs/retro/2026-10-11.md"
|
||||
J="$TMP/d.json"
|
||||
python3 "$M" "$P" --harness "$H" --projects-root "$CR" --out "$J"
|
||||
assert_eq "период с последнего ретро" "$(q 'd["period"]["since"], m["sessions"]')" "('2026-11-03', 2)"
|
||||
# I-4: черновик текущего ретро не сдвигает период — при существующем --out берётся его period.since
|
||||
touch "$H/docs/retro/2026-11-05.md"
|
||||
python3 "$M" "$P" --harness "$H" --projects-root "$CR" --out "$J"; rc=$?
|
||||
assert_eq "повторный срез в тот же --out: тот же период" "$rc $(q 'd["period"]["since"], m["sessions"]')" "0 ('2026-11-03', 2)"
|
||||
md=$(python3 "$M" "$P" --from "$J" --markdown --projects-root "$TMP/нет-такого")
|
||||
assert_grep "--from: таблица из сохранённого среза без пересчёта" '^\| Сессий \| 2 \| — \| — \|$' <(printf '%s' "$md")
|
||||
# нет сессий
|
||||
err=$(python3 "$M" "$P" --since 2030-01-01 --projects-root "$CR" 2>&1 >/dev/null); rc=$?
|
||||
assert_eq "нет сессий: exit 1" "$rc" "1"
|
||||
assert_grep "сообщение со slug" 'proj' <(printf '%s' "$err")
|
||||
# H-1 (владелец, PR #30): «сделай сам» — агент переложил работу на владельца — признак поправки
|
||||
assert_eq "«сделай сам» — признак поправки, «сделай X» — нет" "$(python3 -c "import sys; sys.path.insert(0, '$ROOT/docs/retro/scripts'); import retro_metrics as r; print([bool(r.MARKERS.search(t)) for t in ('сделай сам', 'Сделай сам, пожалуйста', 'сделай X', 'сделайсам')])")" "[True, True, False, False]"
|
||||
33
tests/test-retro-skill.sh
Normal file
33
tests/test-retro-skill.sh
Normal file
@ -0,0 +1,33 @@
|
||||
# /harness-retro: проектный skill, шаблон, метрики 11.10, удалённые скрипты (#29).
|
||||
S="$ROOT/.claude/skills/harness-retro"
|
||||
assert_file "SKILL.md" "$S/SKILL.md"
|
||||
assert_grep "SKILL.md: name" '^name: harness-retro$' "$S/SKILL.md"
|
||||
for step in 'retro_metrics\.py' 'hook_blocks_export\.py' 'hook_cases_build\.py' 'retro_leftovers\.py' '--corrections' 'EnterWorktree' 'tea pulls create'; do
|
||||
assert_grep "SKILL.md упоминает $step" "$step" "$S/SKILL.md"
|
||||
done
|
||||
assert_file "template.md" "$S/template.md"
|
||||
for sec in "## 1. Цифры" "## 2. Находки" "## 3. Поправки владельца" "## 4. Что сработало" "## 5. Бэклог" "## 6. Решения владельца" "## 7. Что не измерялось"; do
|
||||
assert_grep "шаблон: $sec" "^$sec" "$S/template.md"
|
||||
done
|
||||
J="$ROOT/docs/retro/2026-10-11.metrics.json"
|
||||
assert_eq "метрики 11.10: схема, cd снят, ложные, блокировки без сабагентов" "$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); m=d["metrics"]; print(d["schema"], "retired" in m["cd_main"], m["false_blocks"]["false"], m["hook_blocks"]["value"])' "$J")" "1 True 72 76"
|
||||
assert_eq "метрики 11.10: несравнимое помечено (сабагенты, широкий «перечитай»)" "$(python3 -c 'import json,sys; m=json.load(open(sys.argv[1]))["metrics"]; print([k for k in ("tool_calls", "hook_blocks", "classifier_denials", "recall") if "note" in m[k]], [k for k in ("init", "explore") if "note" in m["codegraph"][k]])' "$J")" "['tool_calls', 'hook_blocks', 'classifier_denials', 'recall'] ['init', 'explore']"
|
||||
out=$(python3 - "$ROOT/docs/retro/scripts" "$J" <<'PY'
|
||||
import json, sys
|
||||
sys.path.insert(0, sys.argv[1])
|
||||
import retro_metrics
|
||||
prev = json.load(open(sys.argv[2]))
|
||||
doc = {"metrics": {"sessions": 3, "turns": 7, "hook_blocks": 1}, "period": {"since": "2026-10-11", "until": "2026-10-24"},
|
||||
"sources": {"sessions": 3, "dirs": [], "bad_lines": 0}}
|
||||
print(retro_metrics.to_markdown(doc, prev))
|
||||
PY
|
||||
)
|
||||
assert_grep "сравнение с 11.10: сессии" '^\| Сессий \| 3 \| 44 \| -41 \|$' <(printf '%s' "$out")
|
||||
assert_grep "сравнение с 11.10: блокировки считались без сабагентов" '^\| Блокировок хуков \| 1 \| 76 \(без сабагентов\) \| — \|$' <(printf '%s' "$out")
|
||||
assert_grep "сравнение с 11.10: cd снят" '^\| cd в основной checkout на main \| — \| — \| не считается' <(printf '%s' "$out")
|
||||
assert_grep "сравнение с 11.10: ходы считались по строкам" '^\| Ходов модели \| 7 \| 12 121 \(по строкам' <(printf '%s' "$out")
|
||||
assert_eq "retro_stats.py удалён" "$([ -e "$ROOT/docs/retro/scripts/retro_stats.py" ] && echo есть || echo нет)" "нет"
|
||||
assert_eq "retro_blocks.py удалён" "$([ -e "$ROOT/docs/retro/scripts/retro_blocks.py" ] && echo есть || echo нет)" "нет"
|
||||
assert_eq "CLAUDE.md без ссылок на удалённые скрипты" "$(grep -c 'retro_stats\|retro_blocks' "$ROOT/CLAUDE.md")" "0"
|
||||
assert_grep "CLAUDE.md: retro_metrics в командах" 'retro_metrics\.py' "$ROOT/CLAUDE.md"
|
||||
assert_grep "HARNESS.md: раздел Ретро" '^## Ретро' "$ROOT/docs/HARNESS.md"
|
||||
Loading…
x
Reference in New Issue
Block a user