Files
portal/docs/superpowers/specs/2026-05-20-observer-chain-attribution-design.md
T
Дмитрий 754f5daf5d docs(observer): chain attribution L1-L13 spec + plan + brain-retro #2
Brain-retro #2 (весь май) → кандидат: атрибуция canonical chains L1-L13.
Spec + 9-task TDD plan (chain_ref в primary_rationale, C6 sync-контролёр,
ретрофилл). Исполнение разблокировано — epic observer-instrument-expansion
влит в main. +cspell словарь.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 04:42:41 +03:00

23 KiB
Raw Blame History

Observer Canonical Chain Attribution (L1L13) — Design Spec

Дата: 2026-05-20 Автор: controller Opus 4.7 (через superpowers:brainstorming skill, ответы заказчика → AskUserQuestion) Базовая ветка: feat/project-migration-redesign (формально) → реальное исполнение в свежем worktree off origin/main ПОСЛЕ закрытия epic-плана 2026-05-20-observer-instrument-expansion v1.1 (20 task). Триггер: docs/observer/notes/2026-05-20-brain-retro-v2.md — Candidate №2 «атрибуция canonical chains L1L13 в primary_rationale». Cross-refs: docs/routing-off-phase.md v1.2 (L1–L13 таблица, строки 84–96); docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md v1.2 (Layer 2 heuristic capture, triggers_matched уже извлекает routing-off-phase LN — но как trigger, не как attribute узла); ADR-011 (Brain governance anchor). Schema impact: schema_version остаётся 2chain_ref это опциональное поле. Backward-compatible с v1/v2-эпизодами без него.


1. Проблема

В эпизодах наблюдателя (docs/observer/episodes-YYYY-MM.jsonl, schema v2) поле primary_rationale.node_chosen фиксирует какой узел Claude выбрал (например, "superpowers:verification-before-completion" или "laravel-boost" или "direct"). Но нет ссылки на канонические цепочки L1–L13 из docs/routing-off-phase.md v1.2.

Из-за этого /brain-retro не может ответить на вопрос: «насколько часто Claude действительно ходит по правильным маршрутам цепочек?». В ретро-ноте 2026-05-20 секция «Canonical chains L1L12 hit rate» — пустая: «нет атрибуции chain_ref».

extractTriggers (Layer 2 heuristic из factor-analysis v1.2) уже извлекает упоминания routing-off-phase LN в assistant.text — но это маркер триггера (что Claude упомянул в обосновании), а не атрибуция узла: узел Boost #10 живёт в L7 + L13 независимо от того, упомянул ли Claude routing-таблицу в тексте.

2. Цель

Добавить атрибут chain_ref рядом с node_chosen, который связывает выбранный узел с одной или несколькими каноническими цепочками L1–L13. Это позволит /brain-retro строить гистограмму «L1: N раз, L2: M раз, …, вне цепочек: K раз» по любому периоду.

Граница цели: наблюдатель регистрирует атрибут, не диктует поведение. Если Claude выбрал узел вне L-таблицы — никаких блокировок (Pravila §16.4 «не использован ≠ проблема»).

3. Решения по архитектуре (приняты заказчиком через AskUserQuestion)

# Развилка Выбор Обоснование
1 Что для direct-эпизодов (узел не в L-таблице) chain_ref: null Честно: L1L13 — routing-цепочки для сложных задач; простые правки в них не входят.
2 Узел в нескольких L (Boost в L7+L13, Sentry в L8+L13, adr-kit в L4+L5) Массив всех цепочек ["L7","L13"] Узел действительно в обеих; агрегатор считает обе.
3 Где живёт маппинг узел → цепочки Отдельный JSON-файл + тест сверки с routing-off-phase.md, врезанный в lefthook pre-commit (Вариант Б, не А) Дрейф ловится агрессивно: коммит, расходящийся с .md, блокируется. Pre-commit + Vitest используют одну логику.
4 Ретроактивность для 23 v2-эпизодов мая 2026 Однократный ретрофилл-скрипт ~30 строк, идемпотентный, даёт честную статистику с 19.05.

4. Архитектура высокого уровня

К наблюдателю добавляется один слой — «chain attribution». Работает в трёх местах:

  1. При записи эпизода (Stop-хук → парсер транскрипта): после выбора node_chosen парсер дополнительно вычисляет chain_ref через чистую функцию chainsFor(node). Запись в JSONL — атомарная append-line, как и сейчас.
  2. При коммите (lefthook pre-commit): контролёр C6 сверяет JSON-маппинг с таблицей L1–L13 в routing-off-phase.md. Расхождение → коммит блокируется с человеко-читаемым diff'ом.
  3. При ретро (/brain-retro): brain-retro-analyzer.mjs агрегирует гистограмму factorMatrix.chain_ref + chainHitRate. Шаблон aggregation-template.md заполняет секцию «Canonical chains L1L13 hit rate».

Существующие парсер/Stop-хук/routing-detector/choice-detector — нетронуты. Схема schema_version: 2 сохраняется: chain_ref опционален; старые эпизоды без него по-прежнему валидны.

5. Компоненты

# Файл Роль Тип Объём
1 tools/observer-chain-map.json Таблица узел → массив цепочек, например {"laravel-boost":["L7","L13"], "superpowers:verification-before-completion":[]}. Человеко-читаемый новый ~60 строк
2 tools/observer-chain-detector.mjs Чистая функция chainsFor(nodeChosen) → массив или null. Грузит JSON один раз, кэширует в Map новый ~40 строк
3 tools/observer-transcript-parser.mjs Точка врезки: в формировании primary_rationale (текущая строка 450) — добавить chain_ref: chainsFor(node_chosen) edit existing +2 строки
4 tools/observer-chain-map-checker.mjs Контролёр C6: парсит таблицу L1–L13 из routing-off-phase.md, сверяет с JSON-маппингом. Возвращает diff или OK новый ~80 строк
5 lefthook.yml Новый job chain-map-sync в pre-commit запускает контролёр C6. Блокирует коммит при расхождении edit existing +5 строк
6 tools/observer-retrofill-chain-ref.mjs Одноразовый скрипт: добавляет chain_ref к v2-эпизодам в episodes-*.jsonl. Атомарный (tmp + rename), идемпотентный новый ~30 строк
7 tools/brain-retro-analyzer.mjs Дополняется factorMatrix.chain_ref (гистограмма) и chainHitRate (массив с процентами) edit existing +20 строк
8 .claude/skills/brain-retro/references/aggregation-template.md Раздел «Canonical chains L1L13 hit rate» заполняется реальными данными из аналитика edit existing +5 строк

Тесты (отдельно, 2 файла):

  • tests/observer-chain-detector.test.mjs — юнит-тесты chainsFor: известный узел / multi-chain узел / неизвестный узел / direct / null / undefined / пустая строка. ~6 тестов, ~50 строк.
  • tests/observer-chain-map-sync.test.mjs — интеграционный тест: запускает тот же C6-чекер, парсит routing-off-phase.md, сверяет с JSON. Та же логика что в pre-commit, один источник правды.

Итого: 4 новых файла в tools/, 2 новых теста, 4 точки правок в existing, +1 в lefthook.yml, +1 в SKILL-template.

6. Поток данных

Поток A — запись эпизода (runtime, при каждом Stop-хуке)

Stop-хук → observer-transcript-parser.mjs (existing)
   ↓
   формирует primary_rationale (node_chosen, triggers_matched, …)
   ↓
   вызывает chainsFor(node_chosen)  ← из observer-chain-detector.mjs (NEW)
   ↓                                   читает observer-chain-map.json (cached)
   primary_rationale.chain_ref = ["L7","L13"] | null
   ↓
   append-line в docs/observer/episodes-YYYY-MM.jsonl

Дополнительная задержка — ~1–2 мс (lookup в Map<string, string[]> в памяти). JSON загружается один раз при первом вызове.

Поток B — pre-commit сверка (раз в коммит)

git commit → lefthook → job chain-map-sync (NEW)
   ↓
   node tools/observer-chain-map-checker.mjs
   ↓
   parse routing-off-phase.md (таблица L1L13, строки 8496)
   ↓
   load observer-chain-map.json
   ↓
   diff: (узлы в .md без записи в JSON) ∪ (узлы в JSON без записи в .md) ∪ (L-список расходится)
   ↓
   exit 0 (синхронно) | exit 1 (расхождение + human-readable сообщение с подсказкой)

Поток C — агрегация в /brain-retro (раз в спринт)

node tools/brain-retro-analyzer.mjs episodes-*.jsonl
   ↓
   читает все эпизоды (v2)
   ↓
   группирует по chain_ref (multi-chain эпизоды засчитываются в каждую L)
   ↓
   factorMatrix.chain_ref: {"L1":0, "L7":3, "L13":1, "null":19, …}
   ↓
   chainHitRate: [{chain:"L7", times:3, percent:"13.0%"}, …]
   ↓
   aggregation-template заполняет секцию «L1–L13 hit rate»

Поток D — однократный ретрофилл

node tools/observer-retrofill-chain-ref.mjs [--dry-run]
   ↓
   для каждого episodes-*.jsonl:
     ↓ для каждой строки v2:
       если chain_ref уже есть → skip (idempotent)
       иначе: добавить chain_ref: chainsFor(node_chosen)
   ↓
   атомарная перезапись (write tmp → rename)

Запускается один раз вручную после внедрения. Повторный запуск — 0 changes.

Что НЕ меняется:

  • schema_version: 2chain_ref опциональное поле.
  • 5 v1-эпизодов мая 2026 — пропускаются (нет schema_version: 2 и нет primary_rationale).
  • Stop-хук, parser-логика выбора node_chosen, routing-detector, choice-detector — не трогаем.
  • Никаких новых hard-blockers для Claude. Pre-commit блокирует только дрейф маппинга.

7. Обработка ошибок

# Сценарий Поведение
1 Узел не найден в маппинге (chainsFor("foo-bar-baz")) Возвращает null. Эпизод пишется. Нормальная ситуация для direct/новых skills/строкового шума
2 observer-chain-map.json отсутствует или битый Парсер ловит исключение, пишет observer_error маркер + chain_ref: null. Эпизод всё равно записывается — наблюдатель не падает. Контролёр C5 (observer-coverage-checker) увидит маркер и подсветит в STATUS.md
3 .md правят, JSON не обновили (дрейф) Pre-commit chain-map-sync падает с понятным diff'ом: «в .md есть ru-tax-accounting → L13, в JSON нет — добавьте». Узлы из новых L-цепочек до добавления в JSON не атрибутируются (см. п. 1)
4 Формат таблицы в .md изменён (новая колонка, переименование L) Парсер chain-map-checker.mjs падает с указанием строки: «не могу распарсить L7». Vitest даёт ту же ошибку до коммита
5 Retrofill-скрипт прерван на середине Запись атомарная (tmp + rename) — файл всегда консистентен. Идемпотентность — повторный запуск пропускает уже обработанные строки
6 JSON ссылается на несуществующую L (например L99) C6 ловит: «JSON ссылается на L99, в .md нет». exit 1
7 v1-эпизоды (5 строк мая) Пропускаются — у них нет schema_version: 2 и primary_rationale

8. Тестирование

Юнит-тесты (tests/observer-chain-detector.test.mjs):

  • chainsFor("laravel-boost")["L7","L13"]
  • chainsFor("superpowers:verification-before-completion")null (узел НЕ входит ни в одну L1–L13 → его нет в JSON-маппинге → null, как и для direct)
  • chainsFor("direct")null
  • chainsFor("unknown-node")null
  • chainsFor(""), chainsFor(null), chainsFor(undefined)null
  • ~6 тестов, ~50 строк.

Единообразие формы (решено в self-review): только две формы результата — непустой массив ["LN", …] (узел есть в JSON) или null (узла нет в JSON: direct / узел вне цепочек / неизвестный / шум). Пустой массив [] НЕ используется — нет узлов «в маппинге, но без цепочек».

Интеграционный тест синхронизации (tests/observer-chain-map-sync.test.mjs):

  • Использует тот же chain-map-checker.mjs, что и pre-commit.
  • Падает, если кто-то добавил строку в .md без обновления JSON.
  • Сообщение теста = сообщение pre-commit job.

Сценарный тест парсера (опционально, если бюджет позволяет):

  • Подаёт фиктивный транскрипт с известным skill (Boost) → проверяет, что в результирующем эпизоде есть chain_ref: ["L7","L13"].
  • Подаёт транскрипт без skill (direct) → chain_ref: null.
  • Подаёт с битым JSON → пишется observer_error + chain_ref: null.

Smoke ретрофилла (ручной шаг после реализации):

  • node tools/observer-retrofill-chain-ref.mjs --dry-run → видим планируемые изменения.
  • Если OK — без --dry-run. Идемпотентность проверяется повторным запуском (должно быть 0 changes).

Что НЕ тестируем:

  • Скорость (1–2 мс заведомо некритично для Stop-хука).
  • Все 60+ узлов руками — JSON + sync-тест уже это покрывают.

9. Initial JSON-маппинг (на момент дизайна, routing-off-phase.md v1.2)

Источник истины — таблица L1–L13 в docs/routing-off-phase.md строки 8496. Извлечение узлов из ячейки «Цепочка» — однократное руками при первой реализации, дальше контролёр C6 синхронизирует.

Примерный shape (демонстрация формата; финальный JSON генерируется при реализации):

{
  "_note": "Только узлы, входящие хотя бы в одну L1-L13. Узлы вне цепочек (direct, verification-before-completion, прочие skills вне L) НЕ включаются — chainsFor вернёт null.",
  "discovery-interview": ["L1","L2"],
  "superpowers:brainstorming": ["L1"],
  "superpowers:writing-plans": ["L1"],
  "superpowers:subagent-driven-development": ["L1"],
  "audit-portal": ["L2"],
  "process-analysis": ["L3"],
  "process-modeling": ["L3","L4"],
  "mermaid": ["L4"],
  "adr-kit": ["L4","L5"],
  "operations": ["L4"],
  "architecture-patterns": ["L5"],
  "deptrac": ["L5"],
  "trail-of-bits": ["L6"],
  "semgrep-mcp": ["L6"],
  "security-guidance": ["L6"],
  "security-review": ["L6"],
  "openapi-mcp-server": ["L7"],
  "api-docs": ["L7"],
  "laravel-boost": ["L7","L13"],
  "superpowers:systematic-debugging": ["L8"],
  "sentry-mcp": ["L8","L13"],
  "redis-mcp": ["L8","L13"],
  "ccpm": ["L9"],
  "product-management": ["L9"],
  "github-mcp": ["L9"],
  "promptfoo": ["L10"],
  "data-scientist": ["L10"],
  "claude-api": ["L10"],
  "skill-creator": ["L11"],
  "hookify": ["L11"],
  "plugin-dev": ["L11"],
  "claude-md-management": ["L12"],
  "billing-audit": ["L13"],
  "pest": ["L13"],
  "ru-tax-accounting": ["L13"]
}

Note для реализации: имена узлов в JSON должны точно соответствовать значениям, которые парсер записывает в node_chosen. Если узел в node_chosen — это "superpowers:verification-before-completion", то в JSON ключ такой же. Это валидируется C6 + sync-тестом.

10. Внешние зависимости и порядок исполнения

Жёсткая зависимость: этот spec исполняется ПОСЛЕ закрытия и push'а в origin/main эпик-плана docs/superpowers/plans/2026-05-20-observer-instrument-expansion.md v1.1 (20 атомарных коммитов).

Причина: epic 20-task правит observer-transcript-parser.mjs в Tasks #1, #2, #4, #6, #7, #8, #9, #12, #13. Моя врезка chain_ref тоже в этом файле. Параллельное исполнение → merge-конфликт почти гарантирован.

Процедура запуска работы по этому spec:

  1. Дождаться push'а epic 20-task на origin/main (контролёр или владелец сообщает «epic закрыт, push сделан»).
  2. git fetch origin && git log HEAD..origin/main --oneline — убедиться, что 20 task реально влиты.
  3. Создать свежий worktree .claude/worktrees/observer-chain-attribution/ off origin/main (новая ветка feat/observer-chain-attribution).
  4. Перейти к superpowers:writing-plans — детальный TDD-план по компонентам §5.
  5. Исполнить через superpowers:subagent-driven-development (Sonnet/Opus only per Pravila §15.1).
  6. Финальный push: git push origin feat/observer-chain-attribution:main (FF-merge).

11. Влияние на нормативку

Не требует правок Pravila / PSR_v1 / Tooling / CLAUDE.md / ADR.

  • chain_ref — опциональное расширение schema v2, не нормативный сдвиг (как task_cost в epic-плане v1.1 — там тоже без правок Pravila).
  • L1–L13 уже формализованы в routing-off-phase.md v1.2 (правлено 20.05 при finance-tooling).
  • Контролёр C6 — добавление нового lefthook job, по аналогии с C1/C2/C3/C4/C5; в STATUS.md появится строка «C6 Chain map sync». Это организационная правка, не нормативная.

Опционально: при желании заказчика — micro-правка в Pravila §16.2 «Схема эпизода v2» с упоминанием chain_ref как опционального атрибута. Делается одним коммитом через claude-md-management. Не блокирует основную работу.

12. Из scope исключено (NOT this spec)

  • chain_divergence event — заявлен в factor-analysis v1.2 §10 как phase-2 / agent-based (нужен LLM-судья «правильная ли цепочка»). Не в этом spec'е. chain_ref — это атрибутирование, а не суждение.
  • triggers_matched: routing-off-phase L7 heuristic — уже реализуется в epic-плане v1.1 Task #6 (reasoning capture). chain_ref — отдельный атрибут параллельно, не дубль.
  • Real-time блокировка «ушёл вне цепочки» — спорная idea, противоречит Pravila §16.4 «не использован ≠ проблема». NOT this spec.
  • Авто-правки нормативки по результатам hit rate — фантазия v3+. NOT this spec.

13. Acceptance criteria

Spec считается реализованным когда:

  1. Все 6 новых файлов из §5 созданы, тесты проходят локально и в CI.
  2. Pre-commit job chain-map-sync врезан в lefthook.yml, smoke-проверка red-green работает (намеренная рассинхронизация JSON ↔ .md → коммит блокируется; правильная синхронизация → проходит).
  3. node tools/observer-retrofill-chain-ref.mjs --dry-run показывает планируемые изменения для всех v2-эпизодов в episodes-2026-05.jsonl; запуск без --dry-run добавляет chain_ref; повторный запуск → 0 changes.
  4. /brain-retro следующего spring печатает непустую секцию «Canonical chains L1L13 hit rate» с реальными цифрами.
  5. STATUS.md добавлена строка «C6 Chain map sync: — last sync OK».
  6. Финальная регрессия npm run test:tools ≥ 331 + N (где N — число новых тестов из §8) GREEN.