# Observer Canonical Chain Attribution (L1–L13) — 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](../../observer/notes/2026-05-20-brain-retro-v2.md) — Candidate №2 «атрибуция canonical chains L1–L13 в primary_rationale».
**Cross-refs:** [docs/routing-off-phase.md](../../routing-off-phase.md) v1.2 (L1–L13 таблица, строки 84–96); [docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md](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` остаётся `2` — `chain_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](../../routing-off-phase.md) v1.2.

Из-за этого `/brain-retro` не может ответить на вопрос: «насколько часто Claude действительно ходит по правильным маршрутам цепочек?». В ретро-ноте 2026-05-20 секция «Canonical chains L1–L12 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` | Честно: L1–L13 — 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 L1–L13 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 L1–L13 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 (таблица L1–L13, строки 84–96)
   ↓
   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: 2` — `chain_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 строки 84–96](../../routing-off-phase.md#L84). Извлечение узлов из ячейки «Цепочка» — однократное руками при первой реализации, дальше контролёр C6 синхронизирует.

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

```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](../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 L1–L13 hit rate» с реальными цифрами.
5. STATUS.md добавлена строка «C6 Chain map sync: ✅ — last sync OK».
6. Финальная регрессия `npm run test:tools` ≥ 331 + N (где N — число новых тестов из §8) GREEN.
