From cc697c030c8bf76ad10da26fccad77ba0342e4d2 Mon Sep 17 00:00:00 2001 From: "andrey.epifantsev" Date: Sat, 24 Oct 2026 01:28:56 +0400 Subject: [PATCH] =?UTF-8?q?feat(retro):=20skill=20/harness-retro,=20=D1=88?= =?UTF-8?q?=D0=B0=D0=B1=D0=BB=D0=BE=D0=BD=20=D1=80=D0=B5=D1=82=D1=80=D0=BE?= =?UTF-8?q?,=20=D0=BC=D0=B5=D1=82=D1=80=D0=B8=D0=BA=D0=B8=2011.10;=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=200.6.0=20(#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .claude/skills/harness-retro/SKILL.md | 38 ++++++ .claude/skills/harness-retro/template.md | 49 +++++++ CHANGELOG.md | 14 ++ CLAUDE.md | 9 +- README.md | 2 + docs/HARNESS.md | 14 ++ docs/retro/2026-10-11.metrics.json | 44 +++++++ docs/retro/scripts/retro_blocks.py | 140 -------------------- docs/retro/scripts/retro_stats.py | 159 ----------------------- tests/test-retro-skill.sh | 32 +++++ 10 files changed, 198 insertions(+), 303 deletions(-) create mode 100644 .claude/skills/harness-retro/SKILL.md create mode 100644 .claude/skills/harness-retro/template.md create mode 100644 docs/retro/2026-10-11.metrics.json delete mode 100644 docs/retro/scripts/retro_blocks.py delete mode 100644 docs/retro/scripts/retro_stats.py create mode 100644 tests/test-retro-skill.sh diff --git a/.claude/skills/harness-retro/SKILL.md b/.claude/skills/harness-retro/SKILL.md new file mode 100644 index 0000000..61bfbc3 --- /dev/null +++ b/.claude/skills/harness-retro/SKILL.md @@ -0,0 +1,38 @@ +--- +name: harness-retro +description: Ретро обвязки claude-harness по транскриптам, памяти и репозиториям одного проекта или workspace — срез метрик, разметка блокировок хуков в корпус, хвосты закрытия, поправки владельца, черновик docs/retro/<дата>.md и PR. Запускать в сессии в репозитории обвязки по команде «/harness-retro <папка> [--since ГГГГ-ММ-ДД]» после среза. +--- + +# /harness-retro + +Аргумент — папка проекта или workspace, который изучаем. Сессия — в репозитории обвязки, роль dev. Дата ретро `` — сегодня, `ГГГГ-ММ-ДД`. Скрипты — `docs/retro/scripts/`, все команды — из корня worktree ретро. Спека — `openspec/specs/harness-retro/spec.md`. + +## Шаги + +1. **Задача и worktree.** Issue «Ретро : <проект>» (`tea issues create`). Затем `EnterWorktree` с именем `retro-`, ветка `git branch -m retro/`, `codegraph init` в worktree. +2. **Срез.** Выполнить `python3 docs/retro/scripts/retro_metrics.py <папка> [--since …] --out docs/retro/.metrics.json`. + - Exit 1 — сессий нет. Сообщить владельцу slug и период и остановиться. + - Битых строк больше 1 % — отметить в «Что не измерялось». +3. **Блокировки.** Сначала выгрузить: `python3 docs/retro/scripts/hook_blocks_export.py --since --id-prefix <каталоги из sources.dirs> > docs/retro/fixtures/hook-blocks-.jsonl`. Каталоги — `~/.claude/projects/<имя>`. + - Разметить каждую запись в `docs/retro/fixtures/hook-labels-.json`: + - `expected` (`allow|block`) и `why` по спеке `hook-guards`; + - раскладку песочницы — по `gitBranch` сессий и выводу `git worktree list` и `git branch` в транскриптах; + - если не восстановить — `skip` с причиной. + - Собрать корпус: `python3 docs/retro/scripts/hook_cases_build.py > skills/harness-doctor/fixtures/hook-cases.jsonl` и прогнать `bash tests/run.sh hook-corpus`. Если текущий хук даёт не тот вердикт — проставить записи `"known": "RT-n"` и завести находку. + - Пересчитать срез с `--labels docs/retro/fixtures/hook-labels-.json --out docs/retro/.metrics.json`. +4. **Хвосты закрытия.** `python3 docs/retro/scripts/retro_leftovers.py <папка> --fetch --markdown`. Ничего не удалять: таблица идёт в черновик, уборка — по решению владельца. +5. **Поправки владельца.** `python3 docs/retro/scripts/retro_metrics.py <папка> [--since …] --corrections`. + - Отобрать только поправки по процессу: пропущенный шаг, лишний вопрос, неверный совет, обход правила, расширение задачи. Вопросы и просьбы по содержанию выбросить. + - Сгруппировать по видам. + - Вид, который повторяется (2 раза и больше) или стоил владельцу заметного времени, — находка `RT-n` и пункт бэклога. +6. **Качественная часть.** Прочитать память проекта целиком (`~/.claude/projects//memory/`) и примеры из выгрузок. Находки `RT-n` — сквозная нумерация: максимум в прошлых `docs/retro/*.md` плюс 1. Приоритет: P1 — теряет время каждую неделю или ломает процесс; P2 — трение; P3 — наблюдение. +7. **Черновик.** Скопировать `.claude/skills/harness-retro/template.md` в `docs/retro/.md` и заполнить. + - «Цифры»: `python3 docs/retro/scripts/retro_metrics.py <папка> [--since …] --labels … --markdown --prev docs/retro/<прошлое>.metrics.json`. + - Секретов в черновике быть не должно. Цитаты брать только из вывода скриптов: он уже прошёл `redact`. +8. **PR.** `bash tests/run.sh` должен быть зелёным. Коммит: черновик, `metrics.json`, выгрузка, разметка, корпус. Затем `git push -u origin retro/` и `tea pulls create` со ссылкой на issue. Merge — только владелец. + +## Не делать + +- Не удалять хвосты и не чинить хуки в PR ретро — это находки и бэклог. +- Не заполнять раздел «Решения владельца»: он пишется на ревью PR. +- Не вставлять в черновик сырые куски транскриптов мимо скриптов. diff --git a/.claude/skills/harness-retro/template.md b/.claude/skills/harness-retro/template.md new file mode 100644 index 0000000..94e9694 --- /dev/null +++ b/.claude/skills/harness-retro/template.md @@ -0,0 +1,49 @@ +# Ретроспектива обвязки claude-harness — <ДД.ММ.ГГГГ> + +Период: –, полигон — <проект или workspace>. +Предыдущая точка сравнения — `docs/retro/<прошлое>.md`. + +Источники: +- транскриптов сессий (<каталоги из sources.dirs>, <объём>); +- файлов auto-memory (<объём>); +- состояние репозиториев (`retro_leftovers.py`); +- блокировки среза, размеченные в корпус: `docs/retro/fixtures/hook-labels-.json`. + +Количественную часть считал `docs/retro/scripts/retro_metrics.py`, качественную — по прочтению памяти целиком и кандидатов в поправки. + +Маркировка находок: `RT-n`, продолжение нумерации прошлых ретро. Приоритет: P1 — теряет время каждую неделю или ломает процесс; P2 — трение; P3 — наблюдение. + +## 1. Цифры + +<таблица из retro_metrics.py --markdown --prev> + +Хвосты чеклиста закрытия: + +<таблица из retro_leftovers.py --markdown> + +## 2. Находки + +**RT- (P). <Одна фраза — что происходит>.** <Факты: где, сколько раз, чем стоило>. Источники: <сессии, память, метрика>. + +## 3. Поправки владельца + +| Вид | Сколько | Пример (цитата, что было до неё) | Что вышло | Находка | +|---|---|---|---|---| + +## 4. Что сработало (сохранить) + +- <…> + +## 5. Бэклог улучшений (по приоритету) + +| # | Что | Находки | Оценка | +|---|---|---|---| + +## 6. Решения владельца (<дата>) + +Заполняется на ревью PR. + +## 7. Что не измерялось + +- Сторона Gitea чеклиста закрытия: открытые issue после слитого PR, пропущенные follow-up. +- <…> diff --git a/CHANGELOG.md b/CHANGELOG.md index 470a978..c391cf5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 0.6.0 — 2026-10-24 +- **`/harness-retro`** (B12, #29) — проектный skill регулярного ретро обвязки. Работает по циклу фичи: issue, worktree, PR. Результат — черновик `docs/retro/<дата>.md` по шаблону и `<дата>.metrics.json` со сравнением с прошлым ретро. +- `docs/retro/scripts/retro_metrics.py` считает срез метрик проекта по транскриптам и памяти: + - сессии, промпты, инструменты, роли, сабагенты, skills, токены (по `message.id`, без синтетических сообщений об ошибке API), компакции, стартовый контекст; + - блокировки хуков, отказы классификатора и владельца; + - codegraph против чтения кода (решение владельца 3); + - «перечитай», память; + - кандидаты в поправки владельца по процессу (`--corrections`). + + Заменяет `retro_stats.py` и `retro_blocks.py`. +- `docs/retro/scripts/retro_leftovers.py` ищет хвосты чеклиста закрытия по состоянию репозиториев: слитые ветки на сервере и локально, worktree, SDD workspace закрытых задач, неархивные changes, отставший или грязный main. +- Корпус хуков разбит на сегменты со своей раскладкой; записи `known` — известные ошибки хуков (`KNOWN-FIXED` — пора снять пометку), doctor показывает их число. `hook_blocks_export.py --since --id-prefix` выгружает блокировки среза, `hook_cases_build.py` собирает корпус из `hook-labels-<дата>.json`. +- `docs/retro/2026-10-11.metrics.json` — метрики ретро 11.10, перенесённые из таблицы. Блокировки — 76 ошибок хука, из них 72 ложные (O-1). Ходы и токены 11.10 считались по строкам и помечены как несравнимые. + ## 0.5.1 — 2026-10-24 - Worktree живут в `/.claude/worktrees/`, а не в соседнем каталоге: их создаёт `EnterWorktree` с именем, ветка `worktree-` переименовывается в `feat/-` (#25). Вход в worktree вне `.claude/worktrees/` спрашивает подтверждение при любых allow-правилах (Claude Code ≥ 2.1.206). Описано в `roles/dev.md` и `HARNESS.md`. - `.claude/worktrees/` — в `.gitignore` проектов: `adopt-project.sh`, `project-template/gitignore`, этот репозиторий. `doctor.sh`: `WARN .claude/worktrees/ не в .gitignore`. diff --git a/CLAUDE.md b/CLAUDE.md index d832615..f73933c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ - `roles/` — общий слой ролей (формат: четыре раздела, см. спеку ролей), `skills/role/` — skill `/role`, `agents/briefing.md` — агент выжимки, `link-roles.sh` — симлинки workspace → репозитории направлений. Ставятся `install.sh` в `~/.claude/{roles,skills/role,agents,harness}`. - `tests/` — тесты пакета на bash: `run.sh` + `test-*.sh`, работают с источниками, `install.sh` ставят во временный `CLAUDE_CONFIG_DIR`. - `caveman/config.json` — выключенный по умолчанию caveman. `examples/` — реальный `CLAUDE.md` проекта-пилота. -- `docs/HARNESS.md` — как устроен цикл фичи, SDD, маркировка, закрытие. `docs/PILOT-2026-09-27.md` — пилот. `docs/retro/` — ретроспектива 11.10.2026 и скрипты подсчёта по транскриптам. `docs/superpowers/specs/` — спеки фич обвязки, `docs/superpowers/plans/` — планы. +- `docs/HARNESS.md` — как устроен цикл фичи, SDD, маркировка, закрытие. `docs/PILOT-2026-09-27.md` — пилот. `docs/retro/` — ретроспективы, их метрики (`<дата>.metrics.json`) и скрипты: `retro_metrics.py` (срез метрик и поправки владельца), `retro_leftovers.py` (хвосты закрытия), `hook_blocks_export.py` и `hook_cases_build.py` (блокировки → корпус); проектный skill `/harness-retro` — `.claude/skills/harness-retro/`. `docs/superpowers/specs/` — спеки фич обвязки, `docs/superpowers/plans/` — планы. - Связь слоёв: хуки читают JSON вызова из stdin и состояние git проекта; скрипты бутстрапа читают `project-template/`; `doctor.sh` читает установленные копии в `~/.claude`, не репозиторий. ## Команды @@ -28,13 +28,13 @@ - `bash skills/harness-doctor/scripts/doctor.sh [проект]` — отчёт и самотест хуков. Только чтение. - `bash adopt-project.sh <папка> [--no-merge] [--push]` — подключить существующий проект. Из терминала, не из Claude Code. - `bash new-project.sh <папка> [ssh-url]` — каркас нового проекта. -- `python3 docs/retro/scripts/retro_stats.py <каталог транскриптов>` и `retro_blocks.py` — статистика сессий и блокировок хуков для ретро; `hook_blocks_export.py <каталог> [...]` — выгрузка блокировок в JSONL (сырьё для тестов хуков); `hook_cases_build.py > skills/harness-doctor/fixtures/hook-cases.jsonl` — сборка размеченного корпуса из выгрузки. +- `/harness-retro <папка проекта> [--since ГГГГ-ММ-ДД]` — ретро обвязки (в сессии в этом репозитории). Под ним: `python3 docs/retro/scripts/retro_metrics.py <папка> [--since …] [--out …] [--markdown --prev …] [--corrections]` — срез метрик; `retro_leftovers.py <папка> [--fetch] [--markdown]` — хвосты закрытия; `hook_blocks_export.py [--since …] [--id-prefix …] <каталог> [...]` — выгрузка блокировок в JSONL; `hook_cases_build.py > skills/harness-doctor/fixtures/hook-cases.jsonl` — сборка корпуса из разметки. - `python3 skills/harness-doctor/scripts/hook_corpus.py hooks [--summary]` — корпус хуков по источникам. `python3 hooks/harness_target.py targets '<команда>' [cwd]` — какие цели записи видит хук. ## Тесты - `tests/run.sh` — автоматические тесты источников (хуки через фиктивный JSON, скрипты во временных каталогах, размеры). Самотест хуков в `doctor.sh` проверяет установленные копии; перед ним `bash install.sh`. - Ручная проверка хука: на main в любом репозитории попросить агента создать файл, ожидается `BLOCKED`. -- Хуки: `test-hook-parse` — разбор команды на цели; `test-hook-guard-main`, `test-hook-require-plan` — сценарии спеки `hook-guards` в песочнице из настоящих репозиториев; `test-hook-corpus` — корпус реальных команд (assert на запись, корпус должен совпадать со сборкой `hook_cases_build.py`); `test-hook-export` — экстрактор. Плюс `role-hint` и скрипты `install.sh`, `doctor.sh`, `link-roles.sh`. +- Хуки: `test-hook-parse` — разбор команды на цели; `test-hook-guard-main`, `test-hook-require-plan` — сценарии спеки `hook-guards` в песочнице из настоящих репозиториев; `test-hook-corpus` — корпус реальных команд (assert на запись, корпус должен совпадать со сборкой `hook_cases_build.py`); `test-hook-export` — экстрактор. Ретро: `test-retro-metrics` (синтетические транскрипты), `test-retro-leftovers` (песочница репозиториев), `test-retro-skill`. Плюс `role-hint` и скрипты `install.sh`, `doctor.sh`, `link-roles.sh`. - Правишь разбор или вердикт — сначала случай в `test-hook-parse`/`test-hook-guard-main`; меняется ожидаемый вердикт реальной команды — разметка в `hook_cases_build.py` и пересборка корпуса. ## Текущая работа @@ -44,7 +44,8 @@ - Сделано: B3 — allow-правило `git push origin --delete` печатают adopt/new-project, проверяет doctor (issue #22, 0.4.2). B4 закрыт в #6 (решение владельца 1). B10 — workspace-хук awp-ca, в части 2 issue #2. - Сделано: allow-правила цикла фичи ставит `install.sh` (issue #24, 0.5.0; пересмотр решения владельца 2). - Сделано: worktree — `EnterWorktree` в `/.claude/worktrees/`, вход без подтверждения (issue #25, 0.5.1). Для awp-ca вопрос открыт в части 2 issue #2. -- Дальше: B12. +- Сделано: B12 — `/harness-retro`: срез метрик, поправки владельца, хвосты закрытия, корпус по сегментам (issue #29, 0.6.0), спека — `openspec/specs/harness-retro/`. +- Дальше: первое ретро `/harness-retro` по полигону — на машине с awp-ca; остальные пункты бэклога — по мере надобности. ## OpenSpec и superpowers - Вход в любую задачу — superpowers:brainstorming. Skills openspec-propose и openspec-explore сами по себе не запускать. diff --git a/README.md b/README.md index 5743a3d..f9438de 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,8 @@ link-roles.sh симлинки ролей/агентов из worksp project-template/workspace/ CLAUDE.md workspace с таблицей репозиториев, roles/dev.md project-template/role-repo/ репозиторий направления: CLAUDE.md, ROLE.md, notes/README.md tests/run.sh тесты пакета (bash): bash tests/run.sh +.claude/skills/harness-retro/ проектный skill /harness-retro: ретро обвязки по транскриптам проекта (запуск из этого репозитория) +docs/retro/scripts/ retro_metrics.py (срез метрик), retro_leftovers.py (хвосты закрытия), выгрузка и разметка блокировок docs/HARNESS.md как это работает: слои, цикл фичи, SDD, дисциплина закрытия docs/PILOT-2026-09-27.md результаты пилота и находки ``` diff --git a/docs/HARNESS.md b/docs/HARNESS.md index d4281ca..0bab5c0 100644 --- a/docs/HARNESS.md +++ b/docs/HARNESS.md @@ -99,6 +99,20 @@ Workspace из нескольких репозиториев (не git, cwd вс Политика памяти (глобальный CLAUDE.md): состояние задачи — в трекере; уроки, решения владельца и рецепты — в `notes/` направления и памяти агентов (в git); в локальной auto-memory — только ловушки машины и индекс со ссылками. При закрытии файл auto-memory задачи сворачивается в строку `registry-closed.md`. +## Ретро + +Регулярное ретро — `/harness-retro <папка проекта или workspace> [--since ГГГГ-ММ-ДД]`, проектный skill этого репозитория (`.claude/skills/harness-retro/`). Запускается в сессии в репозитории обвязки по команде владельца после среза и идёт по циклу фичи: issue, worktree, PR. Результат — черновик `docs/retro/<дата>.md` по шаблону и `docs/retro/<дата>.metrics.json`. + +- `retro_metrics.py` — срез метрик: + - сессии, промпты, инструменты, роли, сабагенты, токены, стартовый контекст; + - блокировки и отказы; + - codegraph против чтения кода; + - память. + + Сравнение с прошлым ретро — таблица «Цифры». Кандидаты в поправки владельца по процессу — `--corrections`. +- Блокировки среза агент размечает по спеке `hook-guards` в `docs/retro/fixtures/hook-labels-<дата>.json`, и они становятся сегментом регрессионного корпуса. Запись с `known` — известная ошибка хуков; doctor показывает их число. +- `retro_leftovers.py` — хвосты чеклиста закрытия по состоянию репозиториев. Он только подсказывает, как убрать; убирает владелец. + ## Стоимость Bootstrap superpowers (~3 КБ) вставляется на каждом старте и после компакции. SDD стоит свежий контекст на задачу и на ревью; подробный план с готовым кодом позволяет отдать большинство задач haiku. Для планов до 5 задач дешевле `executing-plans` (Native): один контекст, одно ревью в конце. diff --git a/docs/retro/2026-10-11.metrics.json b/docs/retro/2026-10-11.metrics.json new file mode 100644 index 0000000..a3e7354 --- /dev/null +++ b/docs/retro/2026-10-11.metrics.json @@ -0,0 +1,44 @@ +{ + "schema": 1, + "period": {"since": "2026-10-02", "until": "2026-10-11"}, + "project": "/Users/andrey.epifancev/projects/awp-ca", + "sources": { + "dirs": ["-Users-andrey-epifancev-projects-awp-ca"], + "sessions": 44, + "bytes": 881852416, + "bad_lines": null, + "note": "перенесено вручную из таблицы «Цифры» docs/retro/2026-10-11.md (#29); 47 транскриптов, 841 МБ" + }, + "metrics": { + "sessions": 44, + "prompts": 613, + "interrupts": null, + "turns": {"value": 12121, "note": "по строкам транскрипта, не по сообщениям"}, + "tool_calls": 6583, + "tools": {"Bash": 4302, "Playwright": 1100, "Read": 346, "Write": 271, "Edit": 233}, + "roles": {"dev": 32, "admin": 7, "qa": 5}, + "subagents": 61, + "subagent_models": {"sonnet": 47, "opus": 9, "не указана": 5}, + "skills": {"brainstorming": 19, "writing-plans": 14, "systematic-debugging": 8, "subagent-driven-development": 4, + "executing-plans": 3, "using-git-worktrees": 3, "test-driven-development": 1, + "finishing-a-development-branch": 1}, + "tokens": { + "output": {"value": 18300000, "note": "по строкам транскрипта"}, + "cache_read": {"value": 3820000000, "note": "по строкам транскрипта"}, + "cache_write": {"value": 145000000, "note": "по строкам транскрипта"} + }, + "compactions": 4, + "start_context": {"median": 56000, "min": 52000, "max": 61000}, + "hook_blocks": 76, + "hook_blocks_by_hook": null, + "false_blocks": {"labelled": 76, "skipped": 0, "false": 72, "share": 0.95, "fixed": 72, "known": 0}, + "classifier_denials": 6, + "classifier_by_reason": {"Git Destructive": 2, "Auto-Mode Bypass": 2, "Interfere With Workloads": 1, "safety-check": 1}, + "owner_denials": 6, + "codegraph": {"init": 43, "explore": 15, "code_reads": null, "explore_share": null}, + "corrections": null, + "recall": 9, + "memory": {"index_bytes": null, "files": 47, "bytes": 183296}, + "cd_main": {"retired": "хуки 0.4.0 не зависят от cwd (RT-2); считалось по именам репозиториев awp-ca, было 84"} + } +} diff --git a/docs/retro/scripts/retro_blocks.py b/docs/retro/scripts/retro_blocks.py deleted file mode 100644 index 9c453d7..0000000 --- a/docs/retro/scripts/retro_blocks.py +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/env python3 -"""Классификация блокировок хуков, отказов auto mode, стартового контекста и ручных напоминаний.""" -import json, sys, glob, os, re, collections - -d = sys.argv[1] -files = sorted(glob.glob(os.path.join(d, "*.jsonl"))) - -RE_REAL = re.compile(r'(^|[^<\d])>{1,2}[^&]|\btee\b|\bsed\s+-i|\bmv\b|\bcp\b|\brm\b|\btouch\b|\bmkdir\b|\bgit\s+(add|commit|push|apply|stash\s+pop)\b|\bnpm\s+(i|install|init)\b|\bpip\s+install\b') -RE_STDERR = re.compile(r'\d>{1,2}\s*/dev/null|\d>&\d') -RE_HEREDOC_PY = re.compile(r"<<\s*'?(PY|EOF)") -RE_HOOK_ERROR = re.compile(r"\s*PreToolUse:\S+ hook error") # только ошибка хука, не вывод команды со словом BLOCKED - -blocks = collections.Counter() -by_hook = collections.Counter() -by_session = collections.Counter() -examples = collections.defaultdict(list) -denials = collections.Counter() -denial_ex = [] -start_ctx = [] -recall_prompts = [] -codegraph_init = 0 -cd_main = 0 -n_prompts_total = 0 - -for f in files: - sid = os.path.basename(f)[:8] - pending = {} # tool_use_id -> (name, input) - first_usage = None - title = "" - for line in open(f, encoding="utf-8", errors="replace"): - try: - j = json.loads(line) - except Exception: - continue - t = j.get("type") - if t == "ai-title" and not title: - title = j.get("aiTitle", "") - if t == "assistant": - m = j.get("message", {}) - u = m.get("usage") or {} - if first_usage is None and u: - first_usage = (u.get("input_tokens", 0), u.get("cache_creation_input_tokens", 0), u.get("cache_read_input_tokens", 0)) - for b in m.get("content", []) or []: - if isinstance(b, dict) and b.get("type") == "tool_use": - pending[b.get("id")] = (b.get("name"), b.get("input") or {}) - if b.get("name") == "Bash": - cmd = (b.get("input") or {}).get("command", "") - if "codegraph init" in cmd: - codegraph_init += 1 - if re.search(r"\bcd\s+\S*(arm-core|docs|tabular-parser|scan-gateway|arm-web)/?\s*($|;|&&)", cmd) and "/.wt/" not in cmd: - cd_main += 1 - if t == "user": - m = j.get("message", {}) - cont = m.get("content", "") - if isinstance(cont, str) and not cont.startswith("<") and not j.get("isMeta"): - n_prompts_total += 1 - if re.search(r"памят|перечитай|сверься|контекст|вспомни|прочитай .*md", cont, re.I): - recall_prompts.append((sid, cont[:140].replace("\n", " "))) - if isinstance(cont, list): - for b in cont: - if not (isinstance(b, dict) and b.get("type") == "tool_result"): - continue - txt = b.get("content", "") - if isinstance(txt, list): - txt = "\n".join(x.get("text", "") for x in txt if isinstance(x, dict)) - name, inp = pending.get(b.get("tool_use_id"), ("?", {})) - if RE_HOOK_ERROR.match(txt) and "BLOCKED" in txt: - if name not in ("Bash", "Edit", "Write", "MultiEdit"): - continue - if "[guard-main]" in txt: - hook = "guard-main(Bash)" if name == "Bash" else "guard-main(Edit/Write)" - elif "[require-plan]" in txt: - hook = "require-plan" - elif "shell write" in txt: - hook = "guard-main(Bash)" - elif "you are on" in txt: - hook = "guard-main(Edit/Write)" - elif "without a plan" in txt: - hook = "require-plan" - elif "commit/push в main" in txt or "ветке main" in txt or "reference-old-arm" in txt or "docs/source" in txt: - hook = "workspace-guard" - else: - hook = "other" - by_hook[hook] += 1 - by_session[sid] += 1 - cmd = inp.get("command", "") if name == "Bash" else inp.get("file_path", "") - if name == "Bash": - if RE_STDERR.search(cmd) and not RE_REAL.sub("", cmd) != cmd: - cls = "stderr-redirect-only" - else: - stripped = RE_STDERR.sub("", cmd) - if RE_REAL.search(stripped): - mm = RE_REAL.search(stripped).group(0) - if re.match(r"^\s*(cat|python3|grep|ls|find|sed -n|head|git (log|status|diff|show|branch|worktree list))", cmd) and ("mkdir" not in cmd and "git commit" not in cmd and "git push" not in cmd and "git add" not in cmd and "rm " not in cmd): - cls = "read-only-but-matched:" + mm.strip()[:12] - else: - cls = "real-write:" + mm.strip()[:12] - else: - cls = "no-pattern(workspace or cwd)" - else: - cls = name - blocks[(hook, cls)] += 1 - if len(examples[(hook, cls)]) < 3: - examples[(hook, cls)].append(f"[{sid}] {cmd[:150]!r}") - if "denied by the Claude Code auto mode classifier" in txt or "denied by a built-in Claude Code safety check" in txt or "The user doesn't want to proceed" in txt: - r = re.search(r"Reason: \[([^\]]+)\]", txt) - key = r.group(1) if r else ("safety-check" if "safety check" in txt else "user-rejected") - denials[key] += 1 - cmd = inp.get("command", "") if name == "Bash" else name - denial_ex.append(f"[{sid}] {key}: {cmd[:140]!r}") - if first_usage: - start_ctx.append((sid, title[:30], sum(first_usage), first_usage)) - -print("== Блокировки по хукам") -for k, v in by_hook.most_common(): - print(f"{k}: {v}") -print("\n== Блокировки по классу") -for k, v in sorted(blocks.items(), key=lambda x: -x[1]): - print(f"{v:3d} {k[0]} / {k[1]}") - for e in examples[k]: - print(" ", e) -print("\n== Сессии с наибольшим числом блокировок") -for k, v in by_session.most_common(8): - print(f"{k}: {v}") -print("\n== Отказы auto mode / safety / пользователь") -for k, v in denials.most_common(): - print(f"{k}: {v}") -for e in denial_ex: - print(" ", e) -print("\n== Стартовый контекст (первый ответ модели): input+cache_create+cache_read") -start_ctx.sort(key=lambda x: -x[2]) -for s, t, tot, fu in start_ctx: - print(f"{s} {tot:7d} (in={fu[0]} create={fu[1]} read={fu[2]}) {t}") -vals = [x[2] for x in start_ctx if x[2] > 0] -vals.sort() -print(f"median={vals[len(vals)//2]} min={vals[0]} max={vals[-1]} n={len(vals)}") -print(f"\n== codegraph init в командах: {codegraph_init}; cd в основной checkout (не .wt): {cd_main}") -print(f"\n== Промпты владельца с просьбой перечитать память/контекст: {len(recall_prompts)} из {n_prompts_total}") -for s, p in recall_prompts: - print(f" [{s}] {p}") diff --git a/docs/retro/scripts/retro_stats.py b/docs/retro/scripts/retro_stats.py deleted file mode 100644 index b9989c1..0000000 --- a/docs/retro/scripts/retro_stats.py +++ /dev/null @@ -1,159 +0,0 @@ -#!/usr/bin/env python3 -"""Статистика по транскриптам сессий Claude Code для ретро обвязки. -Только чтение. Вывод: таблица по сессиям + агрегаты + примеры блокировок.""" -import json, sys, glob, os, re, collections, datetime - -d = sys.argv[1] -files = sorted(glob.glob(os.path.join(d, "*.jsonl"))) -rows = [] -agg = collections.Counter() -blocked_examples = collections.defaultdict(list) -denied_examples = [] -system_subtypes = collections.Counter() -hook_names = collections.Counter() -skills = collections.Counter() -agent_models = collections.Counter() -agent_types = collections.Counter() -first_prompts = {} - -PAT_BLOCK = re.compile(r"BLOCKED[^\n]{0,160}") -PAT_DENY = re.compile(r"(Auto-Mode Bypass|permission denied|user denied|denied by|The user doesn't want|rejected this tool)", re.I) - -def text_of(content): - if isinstance(content, str): - return content - out = [] - if isinstance(content, list): - for b in content: - if isinstance(b, dict): - if b.get("type") == "text": - out.append(b.get("text", "")) - elif b.get("type") == "tool_result": - out.append(text_of(b.get("content", ""))) - return "\n".join(out) - -for f in files: - sid = os.path.basename(f)[:8] - c = collections.Counter() - title = "" - pm = "" - t0 = t1 = None - usage_in = usage_out = cache_r = cache_c = 0 - human_prompts = 0 - first_prompt = "" - for line in open(f, encoding="utf-8", errors="replace"): - try: - j = json.loads(line) - except Exception: - continue - t = j.get("type") - if t == "ai-title" and not title: - title = j.get("aiTitle", "") - if t == "permission-mode": - pm = j.get("permissionMode", "") - ts = j.get("timestamp") - if ts: - try: - tt = datetime.datetime.fromisoformat(ts.replace("Z", "+00:00")) - t0 = tt if t0 is None or tt < t0 else t0 - t1 = tt if t1 is None or tt > t1 else t1 - except Exception: - pass - if t == "system": - system_subtypes[j.get("subtype", "?")] += 1 - if j.get("subtype") == "compact_boundary" or "compact" in str(j.get("subtype", "")): - c["compact"] += 1 - hn = j.get("hookName") or j.get("hook_name") - if hn: - hook_names[hn] += 1 - if t == "user": - m = j.get("message", {}) - cont = m.get("content", "") - if isinstance(cont, str): - if not cont.startswith("<") and j.get("userType", "external") == "external" and not j.get("isMeta"): - human_prompts += 1 - if not first_prompt: - first_prompt = cont[:120].replace("\n", " ") - elif isinstance(cont, list): - has_tr = any(isinstance(b, dict) and b.get("type") == "tool_result" for b in cont) - if not has_tr and not j.get("isMeta"): - txt = text_of(cont) - if txt and not txt.startswith("<"): - human_prompts += 1 - if not first_prompt: - first_prompt = txt[:120].replace("\n", " ") - for b in cont: - if isinstance(b, dict) and b.get("type") == "tool_result": - txt = text_of(b.get("content", "")) - for mm in PAT_BLOCK.findall(txt): - c["blocked"] += 1 - key = re.sub(r"\s+", " ", mm)[:90] - if len(blocked_examples[key]) < 2: - blocked_examples[key].append(sid) - if PAT_DENY.search(txt): - c["denied"] += 1 - if len(denied_examples) < 15: - denied_examples.append((sid, re.sub(r"\s+", " ", txt)[:160])) - # hook feedback in attachments / text - txt = text_of(cont) - if "BLOCKED" in txt and isinstance(cont, str): - c["blocked_text"] += 1 - if t == "assistant": - m = j.get("message", {}) - u = m.get("usage", {}) or {} - usage_in += u.get("input_tokens", 0) or 0 - usage_out += u.get("output_tokens", 0) or 0 - cache_r += u.get("cache_read_input_tokens", 0) or 0 - cache_c += u.get("cache_creation_input_tokens", 0) or 0 - c["assistant"] += 1 - for b in m.get("content", []) or []: - if isinstance(b, dict) and b.get("type") == "tool_use": - n = b.get("name", "") - c["tool"] += 1 - c["t:" + n] += 1 - agg["t:" + n] += 1 - inp = b.get("input", {}) or {} - if n in ("Agent", "Task"): - agent_models[inp.get("model", "default")] += 1 - agent_types[inp.get("subagent_type", "?")] += 1 - if n == "Skill": - skills[inp.get("skill", "?")] += 1 - dur = (t1 - t0).total_seconds() / 3600 if t0 and t1 else 0 - rows.append(dict(sid=sid, date=t0.strftime("%m-%d") if t0 else "?", hours=round(dur, 1), title=title[:38], pm=pm, - prompts=human_prompts, asst=c["assistant"], tools=c["tool"], agents=c["t:Agent"] + c["t:Task"], - blocked=c["blocked"], denied=c["denied"], compact=c["compact"], - out_k=usage_out // 1000, cache_r_m=round(cache_r / 1e6, 1), first=first_prompt[:70])) - for k in ("assistant", "tool", "blocked", "denied", "compact"): - agg[k] += c[k] - agg["prompts"] += human_prompts - agg["out"] += usage_out - agg["cache_r"] += cache_r - agg["cache_c"] += cache_c - agg["hours"] += dur - -rows.sort(key=lambda r: r["date"]) -hdr = ["sid", "date", "hours", "pm", "prompts", "asst", "tools", "agents", "blocked", "denied", "compact", "out_k", "cache_r_m", "title"] -print("\t".join(hdr)) -for r in rows: - print("\t".join(str(r[h]) for h in hdr)) -print("\n== Первые промпты сессий") -for r in rows: - print(f"{r['date']} {r['sid']} | {r['first']}") -print("\n== Агрегаты") -for k in ("prompts", "assistant", "tool", "blocked", "denied", "compact"): - print(f"{k}: {agg[k]}") -print(f"hours: {agg['hours']:.0f}; output tokens: {agg['out']/1e6:.1f}M; cache read: {agg['cache_r']/1e9:.2f}G; cache create: {agg['cache_c']/1e6:.0f}M") -print("\n== Инструменты (top 25)") -for k, v in sorted(((k, v) for k, v in agg.items() if k.startswith("t:")), key=lambda x: -x[1])[:25]: - print(f"{k[2:]}: {v}") -print("\n== Skills"); [print(f"{k}: {v}") for k, v in skills.most_common()] -print("\n== Модели сабагентов"); [print(f"{k}: {v}") for k, v in agent_models.most_common()] -print("\n== Типы сабагентов"); [print(f"{k}: {v}") for k, v in agent_types.most_common()] -print("\n== system subtypes"); [print(f"{k}: {v}") for k, v in system_subtypes.most_common(15)] -print("\n== hookName в system"); [print(f"{k}: {v}") for k, v in hook_names.most_common(15)] -print("\n== Блокировки хуков (уникальные тексты, top 40)") -for k, v in sorted(blocked_examples.items(), key=lambda x: -len(x[1]))[:40]: - print(f"[{','.join(v)}] {k}") -print("\n== Отказы (примеры)") -for s, e in denied_examples: - print(f"[{s}] {e}") diff --git a/tests/test-retro-skill.sh b/tests/test-retro-skill.sh new file mode 100644 index 0000000..43346db --- /dev/null +++ b/tests/test-retro-skill.sh @@ -0,0 +1,32 @@ +# /harness-retro: проектный skill, шаблон, метрики 11.10, удалённые скрипты (#29). +S="$ROOT/.claude/skills/harness-retro" +assert_file "SKILL.md" "$S/SKILL.md" +assert_grep "SKILL.md: name" '^name: harness-retro$' "$S/SKILL.md" +for step in 'retro_metrics\.py' 'hook_blocks_export\.py' 'hook_cases_build\.py' 'retro_leftovers\.py' '--corrections' 'EnterWorktree' 'tea pulls create'; do + assert_grep "SKILL.md упоминает $step" "$step" "$S/SKILL.md" +done +assert_file "template.md" "$S/template.md" +for sec in "## 1. Цифры" "## 2. Находки" "## 3. Поправки владельца" "## 4. Что сработало" "## 5. Бэклог" "## 6. Решения владельца" "## 7. Что не измерялось"; do + assert_grep "шаблон: $sec" "^$sec" "$S/template.md" +done +J="$ROOT/docs/retro/2026-10-11.metrics.json" +assert_eq "метрики 11.10: схема, cd снят, ложные" "$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); m=d["metrics"]; print(d["schema"], "retired" in m["cd_main"], m["false_blocks"]["false"], m["hook_blocks"])' "$J")" "1 True 72 76" +out=$(python3 - "$ROOT/docs/retro/scripts" "$J" <<'PY' +import json, sys +sys.path.insert(0, sys.argv[1]) +import retro_metrics +prev = json.load(open(sys.argv[2])) +doc = {"metrics": {"sessions": 3, "turns": 7, "hook_blocks": 1}, "period": {"since": "2026-10-11", "until": "2026-10-24"}, + "sources": {"sessions": 3, "dirs": [], "bad_lines": 0}} +print(retro_metrics.to_markdown(doc, prev)) +PY +) +assert_grep "сравнение с 11.10: сессии" '^\| Сессий \| 3 \| 44 \| -41 \|$' <(printf '%s' "$out") +assert_grep "сравнение с 11.10: блокировки" '^\| Блокировок хуков \| 1 \| 76 \| -75 \|$' <(printf '%s' "$out") +assert_grep "сравнение с 11.10: cd снят" '^\| cd в основной checkout на main \| — \| — \| не считается' <(printf '%s' "$out") +assert_grep "сравнение с 11.10: ходы считались по строкам" '^\| Ходов модели \| 7 \| 12 121 \(по строкам' <(printf '%s' "$out") +assert_eq "retro_stats.py удалён" "$([ -e "$ROOT/docs/retro/scripts/retro_stats.py" ] && echo есть || echo нет)" "нет" +assert_eq "retro_blocks.py удалён" "$([ -e "$ROOT/docs/retro/scripts/retro_blocks.py" ] && echo есть || echo нет)" "нет" +assert_eq "CLAUDE.md без ссылок на удалённые скрипты" "$(grep -c 'retro_stats\|retro_blocks' "$ROOT/CLAUDE.md")" "0" +assert_grep "CLAUDE.md: retro_metrics в командах" 'retro_metrics\.py' "$ROOT/CLAUDE.md" +assert_grep "HARNESS.md: раздел Ретро" '^## Ретро' "$ROOT/docs/HARNESS.md"