From 0733ffd9c9f8d043132ec91b25a0c5311778f51d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 16:51:34 +0400 Subject: [PATCH] =?UTF-8?q?docs:=20=D1=80=D0=B5=D1=82=D1=80=D0=BE=D1=81?= =?UTF-8?q?=D0=BF=D0=B5=D0=BA=D1=82=D0=B8=D0=B2=D0=B0=20=D0=BE=D0=B1=D0=B2?= =?UTF-8?q?=D1=8F=D0=B7=D0=BA=D0=B8=2011.10.2026=20=D0=BF=D0=BE=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D0=B8=D0=B3=D0=BE=D0=BD=D1=83=20awp-ca?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Находки RT-1…RT-20, бэклог B1…B12, решения владельцу; скрипты подсчёта по транскриптам (retro_stats.py, retro_blocks.py) как основа регулярного ретро. Co-Authored-By: Claude Fable 5.1 --- docs/retro/2026-10-11.md | 183 +++++++++++++++++++++++++++++ docs/retro/scripts/retro_blocks.py | 135 +++++++++++++++++++++ docs/retro/scripts/retro_stats.py | 159 +++++++++++++++++++++++++ 3 files changed, 477 insertions(+) create mode 100644 docs/retro/2026-10-11.md create mode 100644 docs/retro/scripts/retro_blocks.py create mode 100644 docs/retro/scripts/retro_stats.py diff --git a/docs/retro/2026-10-11.md b/docs/retro/2026-10-11.md new file mode 100644 index 0000000..6a9d133 --- /dev/null +++ b/docs/retro/2026-10-11.md @@ -0,0 +1,183 @@ +# Ретроспектива обвязки claude-harness — 11.10.2026 + +Период: 02.10–11.10.2026, полигон — workspace `awp-ca` (АРМ КА: пять git-репозиториев рядом, workspace сам не git). +Предыдущая точка сравнения — `PILOT-2026-09-27.md` (один репозиторий, одна фича). + +Источники: 47 транскриптов сессий Claude Code (`~/.claude/projects/-Users-andrey-epifancev-projects-awp-ca/*.jsonl`, 841 МБ), +47 файлов auto-memory того же проекта (179 КБ), код хуков, `HARNESS.md`, `PILOT-2026-09-27.md`, `CHANGELOG.md`. +Количественная часть считалась скриптами `scripts/retro_stats.py` и `scripts/retro_blocks.py` (только чтение транскриптов). +Качественная часть — прочтение всей памяти целиком. + +Маркировка находок ретро: `RT-n`. Приоритет: P1 — теряет время каждую неделю или ломает процесс; P2 — трение; P3 — наблюдение. +Статус находки проставляется при закрытии бэклога (fixed / deferred / rejected), как и для любых других маркеров обвязки. + +## 1. Цифры + +| Показатель | Значение | +|---|---| +| Сессий (непустых) | 44 за 10 дней; 3 сессии больше 20 МБ транскрипта | +| Промптов владельца | 613 | +| Ходов модели / вызовов инструментов | 12 121 / 6 583 (Bash 4 302, Playwright ≈1 100, Read 346, Write 271, Edit 233) | +| Запусков сабагентов | 61 (sonnet 47, opus 9, модель не указана 5) | +| Вызовов skills | brainstorming 19, writing-plans 14, systematic-debugging 8, SDD 4, executing-plans 3, using-git-worktrees 3, TDD 1, finishing-a-development-branch 1, verification-before-completion 0, requesting/receiving-code-review 0 | +| Токены | вывод 18,3 М; чтение из кэша 3,82 Г; запись в кэш 145 М | +| Компакций контекста | 4 (в двух сессиях) | +| Стартовый контекст до первого промпта | медиана 56 К токенов (мин 52 К, макс 61 К); наши инструкции ≈39 КБ текста ≈ 13–16 К токенов, остальное — системный промпт и схемы инструментов | +| Блокировок хуков | 103 (guard-main Bash 91, guard-main Edit/Write 8, require-plan 3, прочие 1) | +| Отказов классификатора auto mode | 6 (Git Destructive 2, Auto-Mode Bypass 2, Interfere With Workloads 1, safety-check 1); отказов владельца — 6 | +| `codegraph init` в командах / вызовов `codegraph_explore` | 43 / 15 | +| `cd` в основной checkout на main (не `.wt/`) | 84 | +| Промптов «перечитай память/контекст» | 9 из 613 | + +Сессии по ролям (эвристика по инструментам и первым промптам): разработка — 32 сессии, 377 промптов; администрирование +(Jira, Гант, спринт) — 7 сессий, 122 промпта, все за 10–11.10; QA/фронт (Playwright > 30 вызовов) — 5 сессий, 81 промпт. +Администрирование и QA — треть промптов, при этом вся обвязка и 44 из 47 файлов памяти написаны под разработчика. + +## 2. Находки + +### Хуки + +**RT-1 (P1). Три четверти блокировок guard-main — ложные.** Из 91 блокировки Bash: +36 — команда только чтения с `2>/dev/null` (регулярное выражение хука считает `N>` записью); +27 — команда без единого паттерна записи в репозиторий (чтение памяти, `grep`, `cat`), заблокирована потому, что cwd оболочки +стоял на main-checkout; 8 блокировок Edit/Write пришлись на файлы вне любого репозитория (scratchpad сессии, файлы памяти) — +хук смотрит на cwd, а не на путь файла. Ещё около 20 — шаги самого процесса, которые хук не узнал: `git -C worktree add` +(исключение есть только для литерала `git worktree`), `mkdir -p .wt/<задача>` в не-git workspace, архивный +`git add openspec && git commit` (проверка staged идёт до `add`). Реальных перехватов записи в main за 10 дней — единицы +(`sed -i` по docs на main в сессии d60ad490). Нарушений main при этом не было ни одного: хук защищает, но ценой шума. +Корень: решение принимается по cwd оболочки, а не по цели записи. То же даёт RT-2. +Источники: `retro_blocks.py`, память `workspace-hooks-and-worktrees`, `q1-2025-run`, `osv-inn-mapping-rework`, `console-files-state`. + +**RT-2 (P1). Общий cwd оболочки с фоновым SDD-контроллером.** Один `cd` в основной checkout из главной сессии переводит +контроллер и всех его implementer-ов в состояние `BLOCKED: shell write on main`, и они останавливаются. Правило «не cd, пока +контроллер работает» записано в шести файлах памяти, то есть повторяется, а не устранено. Устраняется вместе с RT-1: хук, +не зависящий от cwd, не ломается от чужого `cd`. Источники: память `slice-2a/2c/3a/3b-state`, `auto-confirm-64`, `arm-web-demo-prototype`. + +**RT-3 (P1). Пункт 4 чеклиста закрытия (удалить ветку на сервере) стабильно не выполняется.** `git push origin --delete` +блокируется guard-main при cwd на main и отклоняется классификатором auto mode как Git Destructive / Auto-Mode Bypass +(4 из 6 отказов классификатора за период). Висящие серверные ветки упомянуты в 10 файлах памяти; владелец удаляет их руками. +Найденный опытным путём обход (push из worktree на не-main ветке) нигде в обвязке не описан. Один раз агент обошёл хук +через Gitea API, за что получил замечание (`feedback-no-harness-bypass`). + +**RT-4 (P1). Правило «архив OpenSpec прямо в main» не работает в многорепозиторном workspace.** Глобальный guard-main +пропускает openspec-only коммиты, workspace-хук awp-ca запрещает любой commit/push в main без исключений. Итог: около 15 +отдельных архивных PR, а поведение «то проходит, то нет» описано в памяти как урок. CLAUDE.md и фактическая практика расходятся. + +**RT-5 (P2). Workspace-хук срабатывает на слово `commit`/`push` в любом месте команды**, включая `echo`, и на `reference-old-arm` +в командах удаления симлинков при сносе worktree (владелец удаляет симлинки сам: `slice-3a-state`, `hotfolder-direct-write-state`, +`core-33-golden-tests`). + +**RT-6 (P2). У хуков нет регрессионных тестов.** `doctor.sh` делает самотест на нескольких случаях, но ни один из 103 реальных +инцидентов не стал тестом. Транскрипты содержат готовый набор: команда + вердикт хука + факт, была ли это запись в main. + +### SDD, сабагенты, классификатор + +**RT-7 (P2). После одного отказа хука классификатор auto mode отклоняет у сабагента последующие коммиты как «Auto-Mode Bypass».** +Владелец добавил allow-правила `git -C .wt/*`, `git add`, `git commit` вручную. Следствие RT-1: меньше ложных отказов — меньше каскадов. + +**RT-8 (P2). Поднятый стенд ломает integration/e2e-тесты сабагентов** (общая queue group NATS на `recognition.result`). +Повторилось минимум 5 раз, каждый раз оформлено как deferred-находка, системно не починено. Агенту гасить стенд нельзя +(Interfere With Workloads), владелец делает через `!`. Это проектная находка, но обвязка должна давать место для «предусловий +окружения перед SDD» (один раз проверить, а не пять раз откладывать). + +**RT-9 (P3). haiku-implementer подписывает коммиты своим trailer вместо указанного в плане** (пилот, R-5/R-7 в `console-files-state`, +O-5 в `slice-3a-state`); переписать нельзя из-за запрета force-push. Третий повтор одной находки. + +**RT-10 (P3). Остановленный владельцем контроллер не возобновляется**, нужен новый контроллер «на добивку» (`slice-3b-state`). +Ledger при этом работает: возобновление без потерь подтверждено и на пилоте, и в срезах. + +**RT-11 (P3). Агент один раз спросил режим исполнения вопреки CLAUDE.md** (`feedback-execution-mode-from-claude-md`), и один раз +начал задачу «не по харнесу» (сессия e08f3f0b, владелец перезапустил задачу). Процессные правила в 9 КБ глобального CLAUDE.md +плюс 16 КБ проектного читаются не всегда; это аргумент за короткие ролевые файлы (RT-14). + +### codegraph + +**RT-12 (P2). 43 `codegraph init` против 15 вызовов `codegraph_explore` за 10 дней**; Bash — 4 302. В памяти нет ни одного +свидетельства, что codegraph ускорил задачу; есть только «init сделан» как ритуал на каждый worktree и пустой ответ на пилоте. +При этом правило «implementer вызывает codegraph_explore вместо grep/Read» стоит в глобальном CLAUDE.md. Сервер отстаёт по версии +(1.6.0 при доступной 1.6.2). + +### Память и контекст + +**RT-13 (P1). Auto-memory стала журналом состояния срезов и дублирует Gitea.** 30 из 47 файлов имеют статус «ЗАКРЫТ» и +фиксируют «issue #N закрыта, PR #M слит, ветка удалена» — то, что по замыслу обвязки помнит Gitea. Факты внутри файлов +расходятся с индексом (`slice-4-state`: в индексе «ЗАКРЫТ», в файле «ждут мержа»; `logger-diagnose-state`: ветки «не удалены» +в тексте, «удалены» в индексе). Один файл (`jira-backlog-actualization`, 15 КБ) — журнал версий артефакта v27…v37. +Уникальные знания, ради которых память нужна, занимают малую долю: уроки по хукам, три файла фидбека владельца, рецепты QA +(стенд, Playwright, скриншоты), структура Jira и ловушки REST, решения владельца по OQ. + +**RT-14 (P1). Одна обвязка на четыре роли.** Разработчик, администратор проекта, аналитик требований и QA работают в одном +проекте с одним CLAUDE.md, одним индексом памяти и одним процессом. В админ-сессии грузятся инварианты DBML и поток +superpowers→Gitea→OpenSpec; в дев-сессии — Jira-память. Специализированных агентов в обвязке нет вовсе (ни `~/.claude/agents`, +ни `.claude/agents`), SDD-контроллер описан текстом. Дизайн решения согласован отдельно (skill `/role`, файлы ролей, агенты +с собственной памятью, реорганизация индекса); здесь фиксируется как находка. + +**RT-15 (P3). Стартовый контекст 56 К токенов, из них наши инструкции 13–16 К.** Роли срежут 8–10 К в не-дев сессиях, но +главный выигрыш от ролей — не токены, а отсутствие чужих правил. Платформенная часть (≈40 К) не наша. + +### Процесс и чеклисты + +**RT-16 (P2). Bounded-путь не требует обновления спек.** Bounded-PR arm-core #52/#53 изменили поведение API без правки +`openspec/specs`; понадобился отдельный аудит (#60, четыре PR). Для bounded нужен один пункт: «меняется поведение API/схемы — +спека обновляется в том же PR». + +**RT-17 (P2). Сборка многорепозиторного worktree делается руками каждый раз**: симлинки fixtures/CLAUDE.md/.claude/reference-old-arm, +`.bounded`, venv, симлинк `node_modules`, `codegraph init`. Ловушки: `worktree remove` отказывает из-за симлинка `node_modules`; +относительный путь в `git worktree add` считается от репозитория, не от cwd. Около 25 bounded-задач и 7 срезов — столько же ручных сборок. + +**RT-18 (P3). Skills завершения не используются**: finishing-a-development-branch 1 раз, verification-before-completion и +requesting-code-review 0 раз. Закрытие и ревью идут по чеклисту CLAUDE.md и через SDD. Это не обязательно плохо, но значит, +что превентивные проверки (verification) для bounded-задач не выполняются формально. + +**RT-19 (P3). harness-check и adopt-project не оставили следов в awp-ca**: workspace не git, хук молчит. Для режима +«workspace из нескольких репозиториев» у обвязки нет ни проверки, ни бутстрапа; `doctor.sh` его тоже не знает. + +**RT-20 (P3). Follow-up issues растут быстрее, чем закрываются**: за период создано около 40 follow-up issue по чеклисту п. 6 +(core, tp, sg, docs, web). Механизм работает, но нет ритуала их разбора. Это кандидат на роль администратора. + +## 3. Что сработало (сохранить) + +- **guard-main защитил main**: за 10 дней ни одного коммита в main мимо PR. Все потери — ложные срабатывания, не пропуски. +- **Ledger SDD** переживает обрыв контроллера; возобновление без потерь подтверждено повторно. +- **Маркировка находок и follow-up issue**: находки не теряются между сессиями, каждая имеет статус. +- **`design.md` «Decisions made without discussion»**: десятки решений на срез, прослеживаемы после удаления ledger и чата. +- **Финальное ревью ветки на opus** реально находит дефекты (I-1…I-4 в #42, волна C-1/I-1/M-1…M-8 в 3b). +- **Golden-тесты** как гарант парсеров: 138/138, 113 снимков, порог `MIN_SNAPSHOTS`. +- **Чеклист закрытия** выполняется целиком в большинстве случаев; слабое место — только п. 4 (RT-3). +- **Фидбек владельца, записанный в память** (три файла), изменил поведение: сверка с макетом, запрет обхода хуков, режим исполнения. +- **Спека как источник истины** при рулингах (F-1 на пилоте, решения №15/№16 в 3a). + +## 4. Бэклог улучшений (по приоритету) + +| # | Что | Находки | Оценка | +|---|---|---|---| +| B1 | guard-main: решать по цели записи, не по cwd. Игнорировать `N>`/`N>&`; для Edit/Write брать ветку репозитория файла; для Bash — `git -C ` или пути в команде; записи вне git-репозиториев не блокировать; исключения для `git -C … worktree add`, `mkdir -p .wt/`; архивная проверка по путям в команде | RT-1, RT-2, RT-7 | 1 день + тесты | +| B2 | Регрессионные тесты хуков на реальных командах из транскриптов (103 случая → фикстуры с ожидаемым вердиктом); запуск из `doctor.sh` | RT-6 | 0,5 дня | +| B3 | Закрытие п. 4: явный рецепт в CLAUDE.md и HARNESS.md (push --delete из worktree на ветке фичи) либо allow-правило в проектных настройках по решению владельца; перестать перекладывать на владельца | RT-3 | 0,5 дня | +| B4 | Политика архива OpenSpec: либо «всегда отдельным PR» (как фактически), либо workspace-хук учится исключению openspec-only. Привести CLAUDE.md к выбранному | RT-4 | 0,5 дня | +| B5 | Роли: skill `/role`, файлы ролей (общие в обвязке, проектные в `.claude/roles/`), агенты с собственной памятью (`jira-admin`, `qa-runner`, `briefing`), подсказка роли по `session_title` | RT-14, RT-11, RT-15 | 2–3 дня | +| B6 | Политика памяти: состояние задачи живёт в Gitea (комментарий к issue при закрытии), в память — только уроки, фидбек, знания роли; при «закрывай» файл задачи сворачивается в строку реестра; индекс по ролям; одноразовая чистка 47 файлов | RT-13 | 1 день + чистка | +| B7 | Скрипт сборки/сноса многорепозиторного worktree (`wt-new`, `wt-rm`): симлинки, `.bounded`, venv, `node_modules`, (codegraph по флагу); режим «workspace из репозиториев» в `doctor.sh` и `harness-check` | RT-17, RT-19 | 1 день | +| B8 | codegraph: вывести из обязательного потока (без `init` на worktree и без правила «вместо grep»), оставить опциональным; решение владельца, нужны ли замеры | RT-12 | 0,5 дня | +| B9 | Bounded-чеклист: пункт про спеки при изменении API/схемы; verification-before-completion перед PR | RT-16, RT-18 | 0,5 дня | +| B10 | Workspace-хук: искать `git … commit/push` как команду, а не как слово; `reference-old-arm` только для `rm`-целей, не для симлинков | RT-5 | 0,5 дня | +| B11 | SDD: раздел «предусловия окружения» в плане (стенд погашен, порты свободны); trailer коммита проверяется ревьюером задачи | RT-8, RT-9 | 0,5 дня | +| B12 | Регулярное ретро: skill `/harness-retro` на этих скриптах, метрики: доля ложных блокировок, отказы классификатора, стартовый контекст, невыполненные пункты закрытия, размер памяти; триггер — по команде после среза | все | 1 день | + +Порядок, который предлагается: B1+B2 (убирают главный источник трения и каскад RT-7), затем B5+B6 (роли и память — одно +изменение, так как индекс памяти перестраивается под роли), затем B3/B4/B10 (мелкие правила), B12, остальное по мере надобности. + +## 5. Решения, которые нужны от владельца + +1. Архив OpenSpec: отдельный PR всегда (рекомендуется, так уже делается) или исключение в workspace-хуке. +2. Удаление серверных веток при закрытии: allow-правило `git push origin --delete` в проектных настройках или рецепт «из worktree». +3. codegraph: оставить опциональным (рекомендуется) или провести замер пользы на одном срезе. +4. Память: согласие на политику «состояние — в Gitea, в память — уроки и знания роли» и на одноразовую чистку 47 файлов. +5. Роли: состав агентов на первом шаге (предложено: `jira-admin`, `qa-runner`, `briefing`; аналитику — файл роли без агента). + +## 6. Что не удалось измерить + +- Время, потерянное на каждую ложную блокировку: транскрипт не содержит длительности исправления, только факт. +- Польза codegraph: нет ни одного замера «с ним / без него». +- Доля решений в `design.md`, которые владелец потом пересмотрел: нужен разбор архивов OpenSpec, не делался. +- Поведение памяти вне git (slug по cwd) не описано в документации Claude Code; проверено только наблюдением. diff --git a/docs/retro/scripts/retro_blocks.py b/docs/retro/scripts/retro_blocks.py new file mode 100644 index 0000000..21481a8 --- /dev/null +++ b/docs/retro/scripts/retro_blocks.py @@ -0,0 +1,135 @@ +#!/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)") + +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 "BLOCKED" in txt and ("hook" in txt.lower() or "BLOCKED:" in txt[:400]): + if name not in ("Bash", "Edit", "Write", "MultiEdit"): + continue + if "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 new file mode 100644 index 0000000..b9989c1 --- /dev/null +++ b/docs/retro/scripts/retro_stats.py @@ -0,0 +1,159 @@ +#!/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}")