# Observer Canonical Chain Attribution (L1–L13) Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Добавить опциональное поле `chain_ref` в эпизоды наблюдателя, связывающее `node_chosen` с каноническими цепочками L1–L13, чтобы `/brain-retro` мог считать «hit rate цепочек».

**Architecture:** Один новый слой над наблюдателем — статический JSON-маппинг `node_chosen → [LN]`, чистая функция-детектор, врезка в парсер транскрипта, контролёр C6 сверки JSON↔`routing-off-phase.md` (в lefthook pre-commit + Vitest), одноразовый ретрофилл существующих эпизодов, агрегация в анализаторе. Всё детерминированно — 0 LLM-вызовов.

**Tech Stack:** Node 20+ ESM, Vitest 4.1.5, pure fs/regex (Security Guidance #40 — никаких shell-вызовов в parser/hook). Раннер: `npm run test:tools` (`cd app && npx vitest run --config vitest.config.tools.mjs`). Тесты лежат рядом с модулями: `tools/<name>.test.mjs`.

**Spec:** [docs/superpowers/specs/2026-05-20-observer-chain-attribution-design.md](../specs/2026-05-20-observer-chain-attribution-design.md).

---

## ⚠️ Внешняя зависимость и порядок (читать ПЕРВЫМ)

**Этот план НЕ исполняется немедленно.** Жёсткая зависимость от epic-плана [2026-05-20-observer-instrument-expansion.md](2026-05-20-observer-instrument-expansion.md) (20 task), который правит тот же `tools/observer-transcript-parser.mjs`.

**Процедура старта (Task 0 ниже):**

1. Дождаться сообщения «epic 20-task закрыт и push'нут на origin/main».
2. `git fetch origin && git log HEAD..origin/main --oneline` — убедиться, что 20 task влиты (Pravila §15.2 pre-flight).
3. Создать свежий worktree off `origin/main` (Task 0).
4. Исполнять Tasks 1–9 по порядку.

---

## Уточнения к spec (раскрыты при детализации writing-plans)

1. **Расположение тестов.** Spec §5/§8 называл `tests/observer-chain-*.test.mjs`. Реальный паттерн репозитория — тест рядом с модулем: `tools/observer-chain-detector.test.mjs`. План использует реальный паттерн.

2. **Семантика контролёра C6 — по L-номерам, не по именам узлов.** Имена в таблице `routing-off-phase.md` — человеческие display-names (`Boost MCP`, `Trail of Bits`, `Semgrep MCP`), а `node_chosen` в эпизодах — технические skill-id (`superpowers:test-driven-development`, `claude-md-management:claude-md-improver`, `direct`). Прямая построчная сверка имён хрупкая. Поэтому C6 v1 сверяет **множества L-номеров**:
   - каждый `LN`, упомянутый в JSON, существует в `.md` (нет ссылок на несуществующую цепочку);
   - каждый `LN` из таблицы `.md` присутствует хотя бы в одной записи JSON (цепочка не «потеряна» при добавлении новой L в .md).

   Это ловит главный класс дрейфа («добавили L14 в .md — JSON про неё не знает»). Точечная сверка «узел X в L7» через display-name-алиасы — out of scope v1 (можно как future-слой).

3. **Точка врезки в parser.** На `origin/main` это `return { … node_chosen: skills.length > 0 ? skills[0] : 'direct', … }` (≈строка 696). После epic-плана номер строки сдвинется — **искать по grep-маркеру** `node_chosen: skills.length > 0 ? skills[0] : 'direct'`, не по номеру.

---

## File Structure

| Файл | Тип | Ответственность |
|---|---|---|
| `tools/observer-chain-map.json` | новый (data) | Маппинг `node_chosen` (реальное значение) → массив `["LN"]`. Только узлы, входящие в L1–L13 |
| `tools/observer-chain-detector.mjs` | новый | `loadChainMap(path)` + чистая `chainsFor(node, map)` → массив \| `null` |
| `tools/observer-chain-detector.test.mjs` | новый | Юнит-тесты `chainsFor` |
| `tools/observer-transcript-parser.mjs` | edit | Врезка `chain_ref` в `primary_rationale` |
| `tools/observer-transcript-parser.test.mjs` | edit | +тест что эпизод несёт `chain_ref` |
| `tools/observer-chain-map-checker.mjs` | новый | C6: `parseChainsFromMd()` + `checkSync()` + CLI |
| `tools/observer-chain-map-checker.test.mjs` | новый | Тесты парсера .md + sync-сверки |
| `lefthook.yml` | edit | Job 16 `observer-chain-map-checker` в pre-commit |
| `tools/observer-retrofill-chain-ref.mjs` | новый | Одноразовый ретрофилл `chain_ref` в JSONL |
| `tools/observer-retrofill-chain-ref.test.mjs` | новый | Тесты идемпотентности + dry-run |
| `tools/brain-retro-analyzer.mjs` | edit | `factorMatrix.chain_ref` + `chainHitRate` |
| `tools/brain-retro-analyzer.test.mjs` | edit | +тест агрегации chain_ref |
| `tools/status-md-generator.mjs` | edit | Строка «C6 Chain map sync» |
| `tools/status-md-generator.test.mjs` | edit | +тест строки C6 |
| `.claude/skills/brain-retro/references/aggregation-template.md` | edit | Заполнить секцию «L1–L13 hit rate» |

---

## Task 0: Pre-flight + worktree (организационный, не код)

**Files:** нет правок кода.

- [ ] **Step 1: Убедиться, что epic-план влит**

Run:

```bash
git fetch origin
git log --oneline -5 origin/main
git log HEAD..origin/main --oneline | grep -i "observer-instrument-expansion\|Task 21\|Task 20" || echo "epic не найден — НЕ СТАРТОВАТЬ"
```

Expected: видны коммиты закрытия epic 20-task на origin/main. Если нет — остановиться, сообщить владельцу.

- [ ] **Step 2: Создать worktree off origin/main**

Использовать `superpowers:using-git-worktrees`. Целевая база — `origin/main` (свежий, после epic). Ветка `feat/observer-chain-attribution`.

- [ ] **Step 3: Verify базовая регрессия зелёная**

Run: `npm run test:tools`
Expected: PASS (≥ baseline после epic, например 350+/350+). Записать число baseline для финальной сверки.

---

## Task 1: chain-map JSON + детектор `chainsFor`

**Files:**

- Create: `tools/observer-chain-map.json`
- Create: `tools/observer-chain-detector.mjs`
- Test: `tools/observer-chain-detector.test.mjs`

- [ ] **Step 1: Создать JSON-маппинг**

`tools/observer-chain-map.json` — только узлы, входящие в L1–L13. Имена ключей = реальные значения `node_chosen` (skill-id). NB: значения `node_chosen` берутся из первого `skill_invoked` (`skills[0]`); для skill-узлов это `plugin:skill` или `skill`. MCP-узлы (Boost/Sentry/Redis) в `node_chosen` не появляются (они не skill_invoked) — но включены в маппинг на будущее, если детектор узлов расширится.

```json
{
  "_note": "node_chosen -> L-цепочки. Только узлы, входящие хотя бы в одну L1-L13. Узлы вне цепочек (direct, прочее) НЕ включаются -> chainsFor вернёт null. Имена ключей = реальные значения primary_rationale.node_chosen. Синхронизируется с docs/routing-off-phase.md через контролёр C6 (tools/observer-chain-map-checker.mjs).",
  "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:adr": ["L4", "L5"],
  "adr-kit:judge": ["L5"],
  "operations": ["L4"],
  "architecture-patterns:architecture-patterns": ["L5"],
  "deptrac": ["L5"],
  "security-review": ["L6"],
  "superpowers:systematic-debugging": ["L8"],
  "ccpm": ["L9"],
  "product-management:brainstorm": ["L9"],
  "promptfoo": ["L10"],
  "data-scientist": ["L10"],
  "claude-api": ["L10"],
  "skill-creator:skill-creator": ["L11"],
  "hookify:hookify": ["L11"],
  "plugin-dev:create-plugin": ["L11"],
  "claude-md-management:claude-md-improver": ["L12"],
  "claude-md-management:revise-claude-md": ["L12"],
  "billing-audit": ["L13"],
  "ru-tax-accounting": ["L13"]
}
```

- [ ] **Step 2: Написать failing-тест детектора**

`tools/observer-chain-detector.test.mjs`:

```javascript
import { describe, it, expect } from 'vitest';
import { loadChainMap, chainsFor } from './observer-chain-detector.mjs';

const map = loadChainMap();

describe('chainsFor', () => {
  it('returns chain array for a single-chain node', () => {
    expect(chainsFor('billing-audit', map)).toEqual(['L13']);
  });

  it('returns all chains for a multi-chain node', () => {
    expect(chainsFor('discovery-interview', map)).toEqual(['L1', 'L2']);
  });

  it('returns null for direct', () => {
    expect(chainsFor('direct', map)).toBeNull();
  });

  it('returns null for an unknown node', () => {
    expect(chainsFor('totally-unknown-xyz', map)).toBeNull();
  });

  it('returns null for empty/null/undefined', () => {
    expect(chainsFor('', map)).toBeNull();
    expect(chainsFor(null, map)).toBeNull();
    expect(chainsFor(undefined, map)).toBeNull();
  });

  it('ignores the _note metadata key', () => {
    expect(chainsFor('_note', map)).toBeNull();
  });
});
```

- [ ] **Step 3: Запустить тест — убедиться, что падает**

Run: `cd app && npx vitest run --config vitest.config.tools.mjs ../tools/observer-chain-detector.test.mjs`
Expected: FAIL — `loadChainMap is not a function` / module not found.

- [ ] **Step 4: Реализовать детектор**

`tools/observer-chain-detector.mjs`:

```javascript
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const __dirname = dirname(fileURLToPath(import.meta.url));
const DEFAULT_MAP_PATH = join(__dirname, 'observer-chain-map.json');

/** Load the node->chains map. Throws on missing/invalid JSON (caller handles). */
export function loadChainMap(path = DEFAULT_MAP_PATH) {
  const raw = JSON.parse(readFileSync(path, 'utf8'));
  const map = new Map();
  for (const [node, chains] of Object.entries(raw)) {
    if (node === '_note') continue;
    if (Array.isArray(chains) && chains.length > 0) map.set(node, chains);
  }
  return map;
}

/** node_chosen -> array of L-chains, or null if not in any chain. */
export function chainsFor(node, map) {
  if (!node || typeof node !== 'string') return null;
  const chains = map.get(node);
  return chains && chains.length > 0 ? chains : null;
}
```

- [ ] **Step 5: Запустить тест — убедиться, что проходит**

Run: `cd app && npx vitest run --config vitest.config.tools.mjs ../tools/observer-chain-detector.test.mjs`
Expected: PASS (6 tests).

- [ ] **Step 6: Commit**

```bash
git add tools/observer-chain-map.json tools/observer-chain-detector.mjs tools/observer-chain-detector.test.mjs
git commit -m "feat(observer): chain-map JSON + chainsFor detector (L1-L13 attribution)"
```

---

## Task 2: Врезка `chain_ref` в парсер транскрипта

**Files:**

- Modify: `tools/observer-transcript-parser.mjs` (grep-маркер `node_chosen: skills.length > 0 ? skills[0] : 'direct'`)
- Test: `tools/observer-transcript-parser.test.mjs`

- [ ] **Step 1: Написать failing-тест**

Добавить в `tools/observer-transcript-parser.test.mjs` (в подходящий describe для primary_rationale; адаптировать фабрику транскрипта под существующие хелперы файла):

```javascript
it('attaches chain_ref for a node that belongs to a chain', () => {
  // транскрипт с skill_invoked = 'billing-audit' (адаптировать под фабрику файла)
  const episode = parseTranscript(transcriptWithSkill('billing-audit'));
  expect(episode.primary_rationale.chain_ref).toEqual(['L13']);
});

it('sets chain_ref null for a direct episode', () => {
  const episode = parseTranscript(transcriptWithNoSkill());
  expect(episode.primary_rationale.chain_ref).toBeNull();
});
```

NB: точные имена хелперов (`parseTranscript` / фабрики) взять из существующего теста — не выдумывать. Если фабрики нет — собрать минимальный transcript-объект вручную по образцу соседних тестов.

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `cd app && npx vitest run --config vitest.config.tools.mjs ../tools/observer-transcript-parser.test.mjs -t chain_ref`
Expected: FAIL — `chain_ref` undefined.

- [ ] **Step 3: Реализовать врезку**

В `tools/observer-transcript-parser.mjs`:

1. Вверху файла добавить импорт:

```javascript
import { loadChainMap, chainsFor } from './observer-chain-detector.mjs';
```

1. Один раз модульно загрузить карту с защитой от битого JSON:

```javascript
let CHAIN_MAP = null;
try {
  CHAIN_MAP = loadChainMap();
} catch {
  CHAIN_MAP = new Map(); // битый/отсутствующий JSON -> chainsFor вернёт null, observer не падает
}
```

1. Найти grep-маркер `node_chosen: skills.length > 0 ? skills[0] : 'direct'` и добавить рядом строку. Должно получиться:

```javascript
        node_chosen: skills.length > 0 ? skills[0] : 'direct',
        chain_ref: chainsFor(skills.length > 0 ? skills[0] : 'direct', CHAIN_MAP),
```

- [ ] **Step 4: Запустить — убедиться, что проходит**

Run: `cd app && npx vitest run --config vitest.config.tools.mjs ../tools/observer-transcript-parser.test.mjs`
Expected: PASS (включая существующие тесты файла — ни один не сломан).

- [ ] **Step 5: Commit**

```bash
git add tools/observer-transcript-parser.mjs tools/observer-transcript-parser.test.mjs
git commit -m "feat(observer): emit chain_ref in primary_rationale"
```

---

## Task 3: Контролёр C6 — sync JSON ↔ routing-off-phase.md

**Files:**

- Create: `tools/observer-chain-map-checker.mjs`
- Test: `tools/observer-chain-map-checker.test.mjs`

- [ ] **Step 1: Написать failing-тест**

`tools/observer-chain-map-checker.test.mjs`:

```javascript
import { describe, it, expect } from 'vitest';
import { parseChainsFromMd, checkSync } from './observer-chain-map-checker.mjs';

const SAMPLE_MD = [
  '| # | Цепочка | Зачем |',
  '|---|---|---|',
  '| L1 | `discovery-interview` (FEATURE) → `brainstorming` | text |',
  '| L2 | `audit-portal` | text |',
  '| L13 | `billing-audit` (#62) + `Pest` | text |',
].join('\n');

describe('parseChainsFromMd', () => {
  it('extracts the set of L-numbers from the table', () => {
    expect(parseChainsFromMd(SAMPLE_MD)).toEqual(new Set(['L1', 'L2', 'L13']));
  });
});

describe('checkSync', () => {
  it('passes when JSON L-numbers subset of md and md subset of json-union', () => {
    const mdSet = new Set(['L1', 'L2', 'L13']);
    const jsonMap = { a: ['L1'], b: ['L2'], c: ['L13'] };
    expect(checkSync(jsonMap, mdSet).ok).toBe(true);
  });

  it('fails when JSON references a chain absent from md', () => {
    const mdSet = new Set(['L1', 'L2']);
    const jsonMap = { a: ['L1'], b: ['L99'] };
    const res = checkSync(jsonMap, mdSet);
    expect(res.ok).toBe(false);
    expect(res.jsonOnly).toContain('L99');
  });

  it('fails when md has a chain not covered by any JSON entry', () => {
    const mdSet = new Set(['L1', 'L2', 'L14']);
    const jsonMap = { a: ['L1'], b: ['L2'] };
    const res = checkSync(jsonMap, mdSet);
    expect(res.ok).toBe(false);
    expect(res.mdOnly).toContain('L14');
  });
});
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `cd app && npx vitest run --config vitest.config.tools.mjs ../tools/observer-chain-map-checker.test.mjs`
Expected: FAIL — module not found.

- [ ] **Step 3: Реализовать чекер**

`tools/observer-chain-map-checker.mjs`:

```javascript
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const __dirname = dirname(fileURLToPath(import.meta.url));
const MD_PATH = join(__dirname, '..', 'docs', 'routing-off-phase.md');
const JSON_PATH = join(__dirname, 'observer-chain-map.json');

/** Extract the set of L-numbers ("L1".."L13") from the routing-off-phase.md table. */
export function parseChainsFromMd(md) {
  const set = new Set();
  for (const line of md.split(/\r?\n/)) {
    const m = /^\|\s*(L\d+)\s*\|/.exec(line.trim());
    if (m) set.add(m[1]);
  }
  return set;
}

/** Compare JSON L-numbers against the md set, both directions. */
export function checkSync(jsonMap, mdSet) {
  const jsonSet = new Set();
  for (const [node, chains] of Object.entries(jsonMap)) {
    if (node === '_note') continue;
    if (Array.isArray(chains)) for (const c of chains) jsonSet.add(c);
  }
  const jsonOnly = [...jsonSet].filter((c) => !mdSet.has(c)); // ссылки на несуществующие L
  const mdOnly = [...mdSet].filter((c) => !jsonSet.has(c));   // потерянные цепочки
  return { ok: jsonOnly.length === 0 && mdOnly.length === 0, jsonOnly, mdOnly };
}

/** CLI entry — exit 1 on drift with a human-readable message. */
function main() {
  const md = readFileSync(MD_PATH, 'utf8');
  const jsonMap = JSON.parse(readFileSync(JSON_PATH, 'utf8'));
  const mdSet = parseChainsFromMd(md);
  if (mdSet.size === 0) {
    console.error('[chain-map-checker] не нашёл ни одной L-строки в routing-off-phase.md — формат таблицы изменился?');
    process.exit(1);
  }
  const res = checkSync(jsonMap, mdSet);
  if (res.ok) {
    console.log(`[chain-map-checker] OK — ${mdSet.size} chains in sync`);
    process.exit(0);
  }
  console.error('[chain-map-checker] дрейф маппинга chain-map <-> routing-off-phase.md:');
  if (res.jsonOnly.length) console.error(`  JSON ссылается на отсутствующие в .md цепочки: ${res.jsonOnly.join(', ')}`);
  if (res.mdOnly.length) console.error(`  В .md есть цепочки без записи в JSON: ${res.mdOnly.join(', ')} — добавьте узлы в tools/observer-chain-map.json`);
  process.exit(1);
}

if (process.argv[1]?.endsWith('observer-chain-map-checker.mjs')) {
  main();
}
```

- [ ] **Step 4: Запустить — убедиться, что проходит**

Run: `cd app && npx vitest run --config vitest.config.tools.mjs ../tools/observer-chain-map-checker.test.mjs`
Expected: PASS (4 tests).

- [ ] **Step 5: Smoke CLI на реальных файлах**

Run: `node tools/observer-chain-map-checker.mjs`
Expected: `[chain-map-checker] OK — 13 chains in sync` (exit 0). Если FAIL — привести JSON (Task 1) в соответствие с реальной таблицей `.md` (это раскроет реальные расхождения первого маппинга).

- [ ] **Step 6: Commit**

```bash
git add tools/observer-chain-map-checker.mjs tools/observer-chain-map-checker.test.mjs
git commit -m "feat(observer): C6 chain-map-checker (JSON vs routing-off-phase.md sync)"
```

---

## Task 4: lefthook job 16 + red-green smoke

**Files:**

- Modify: `lefthook.yml` (после job 15 observer-coverage-checker)

- [ ] **Step 1: Добавить job**

В `lefthook.yml`, в секцию pre-commit после job 15:

```yaml
    # 16. observer-chain-map-checker — brain governance C6 (chain attribution).
    # Сверяет tools/observer-chain-map.json с таблицей L1-L13 в
    # docs/routing-off-phase.md. Падает при дрейфе (несуществующая L в JSON
    # или потерянная цепочка из .md).
    - name: observer-chain-map-checker
      run: node tools/observer-chain-map-checker.mjs
      fail_text: |
        observer-chain-map-checker: дрейф chain-map <-> routing-off-phase.md.
        Обновите tools/observer-chain-map.json под таблицу L1-L13.
```

NB: точный синтаксис (`fail_text` vs `interactive` vs `|| true`) скопировать с соседних observer-job'ов (11–15) — формат должен совпасть.

- [ ] **Step 2: Red-green smoke**

Run (намеренная рассинхронизация):

```bash
# временно добавить несуществующую цепочку в JSON
node -e "const f='tools/observer-chain-map.json';const fs=require('fs');const j=JSON.parse(fs.readFileSync(f,'utf8'));j['__test_drift__']=['L99'];fs.writeFileSync(f,JSON.stringify(j,null,2));"
node tools/observer-chain-map-checker.mjs; echo "exit=$?"
```

Expected: exit=1, сообщение про `L99`.

```bash
# откатить
git checkout tools/observer-chain-map.json
node tools/observer-chain-map-checker.mjs; echo "exit=$?"
```

Expected: exit=0, `OK — 13 chains in sync`.

- [ ] **Step 3: Commit**

```bash
git add lefthook.yml
git commit -m "chore(lefthook): wire C6 observer-chain-map-checker (job 16)"
```

---

## Task 5: Ретрофилл существующих эпизодов

**Files:**

- Create: `tools/observer-retrofill-chain-ref.mjs`
- Test: `tools/observer-retrofill-chain-ref.test.mjs`

- [ ] **Step 1: Написать failing-тест**

`tools/observer-retrofill-chain-ref.test.mjs`:

```javascript
import { describe, it, expect } from 'vitest';
import { retrofillLine } from './observer-retrofill-chain-ref.mjs';
import { loadChainMap } from './observer-chain-detector.mjs';

const map = loadChainMap();

describe('retrofillLine', () => {
  it('adds chain_ref to a v2 episode with a known node', () => {
    const ep = { schema_version: 2, primary_rationale: { node_chosen: 'billing-audit' } };
    const out = retrofillLine(ep, map);
    expect(out.primary_rationale.chain_ref).toEqual(['L13']);
  });

  it('sets chain_ref null for a direct v2 episode', () => {
    const ep = { schema_version: 2, primary_rationale: { node_chosen: 'direct' } };
    expect(retrofillLine(ep, map).primary_rationale.chain_ref).toBeNull();
  });

  it('is idempotent — does not overwrite existing chain_ref', () => {
    const ep = { schema_version: 2, primary_rationale: { node_chosen: 'direct', chain_ref: ['L1'] } };
    expect(retrofillLine(ep, map).primary_rationale.chain_ref).toEqual(['L1']);
  });

  it('skips v1 episodes (no schema_version 2)', () => {
    const ep = { foo: 'bar' };
    expect(retrofillLine(ep, map)).toEqual({ foo: 'bar' });
  });
});
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `cd app && npx vitest run --config vitest.config.tools.mjs ../tools/observer-retrofill-chain-ref.test.mjs`
Expected: FAIL — module not found.

- [ ] **Step 3: Реализовать**

`tools/observer-retrofill-chain-ref.mjs`:

```javascript
import { readFileSync, writeFileSync, renameSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { loadChainMap, chainsFor } from './observer-chain-detector.mjs';

const __dirname = dirname(fileURLToPath(import.meta.url));
const OBS_DIR = join(__dirname, '..', 'docs', 'observer');

/** Add chain_ref to a single parsed episode object (pure). Idempotent. */
export function retrofillLine(ep, map) {
  if (!ep || ep.schema_version !== 2 || !ep.primary_rationale) return ep;
  if ('chain_ref' in ep.primary_rationale) return ep; // idempotent
  ep.primary_rationale.chain_ref = chainsFor(ep.primary_rationale.node_chosen, map);
  return ep;
}

/** Process one JSONL file atomically (tmp + rename). Returns {changed, total}. */
export function retrofillFile(path, map, { dryRun = false } = {}) {
  const lines = readFileSync(path, 'utf8').split(/\r?\n/);
  let changed = 0, total = 0;
  const out = lines.map((line) => {
    if (!line.trim()) return line;
    total++;
    const ep = JSON.parse(line);
    const before = ep.primary_rationale && 'chain_ref' in ep.primary_rationale;
    const next = retrofillLine(ep, map);
    const after = next.primary_rationale && 'chain_ref' in next.primary_rationale;
    if (!before && after) changed++;
    return JSON.stringify(next);
  });
  if (!dryRun && changed > 0) {
    const tmp = `${path}.tmp`;
    writeFileSync(tmp, out.join('\n'), 'utf8');
    renameSync(tmp, path);
  }
  return { changed, total };
}

function main() {
  const dryRun = process.argv.includes('--dry-run');
  const map = loadChainMap();
  const files = readdirSync(OBS_DIR).filter((f) => /^episodes-\d{4}-\d{2}\.jsonl$/.test(f));
  for (const f of files) {
    const { changed, total } = retrofillFile(join(OBS_DIR, f), map, { dryRun });
    console.log(`${dryRun ? '[dry-run] ' : ''}${f}: ${changed}/${total} lines would get chain_ref`);
  }
}

if (process.argv[1]?.endsWith('observer-retrofill-chain-ref.mjs')) main();
```

- [ ] **Step 4: Запустить — убедиться, что проходит**

Run: `cd app && npx vitest run --config vitest.config.tools.mjs ../tools/observer-retrofill-chain-ref.test.mjs`
Expected: PASS (4 tests).

- [ ] **Step 5: Commit**

```bash
git add tools/observer-retrofill-chain-ref.mjs tools/observer-retrofill-chain-ref.test.mjs
git commit -m "feat(observer): one-shot chain_ref retrofill script (idempotent, atomic)"
```

---

## Task 6: Агрегация в brain-retro-analyzer

**Files:**

- Modify: `tools/brain-retro-analyzer.mjs` (в формирование `factorMatrix`)
- Test: `tools/brain-retro-analyzer.test.mjs`

- [ ] **Step 1: Написать failing-тест**

Добавить в `tools/brain-retro-analyzer.test.mjs`:

```javascript
it('aggregates chain_ref into factorMatrix (multi-chain counted in each)', () => {
  const episodes = [
    { schema_version: 2, primary_rationale: { node_chosen: 'discovery-interview', chain_ref: ['L1','L2'] } /* + поля, нужные analyzer */ },
    { schema_version: 2, primary_rationale: { node_chosen: 'direct', chain_ref: null } },
  ];
  const result = analyze(episodes); // имя функции взять из существующего теста
  expect(result.factorMatrix.chain_ref.L1).toBeDefined();
  expect(result.factorMatrix.chain_ref.L2).toBeDefined();
  expect(result.factorMatrix.chain_ref.null).toBeDefined();
});
```

NB: имена `analyze` / shape входа подогнать под существующий тест файла (там уже есть фикстуры эпизодов — переиспользовать форму).

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `cd app && npx vitest run --config vitest.config.tools.mjs ../tools/brain-retro-analyzer.test.mjs -t chain_ref`
Expected: FAIL — `factorMatrix.chain_ref` undefined.

- [ ] **Step 3: Реализовать**

В `tools/brain-retro-analyzer.mjs`, где строится `factorMatrix`, добавить ось `chain_ref`. Multi-chain эпизод инкрементит каждую L; `null` → ключ `"null"`:

```javascript
// внутри построения factorMatrix, по аналогии с другими осями:
matrix.chain_ref = {};
for (const ep of v2Episodes) {
  const cr = ep.primary_rationale?.chain_ref;
  const outcome = ep._inferredOutcome ?? 'unknown';
  const keys = Array.isArray(cr) && cr.length ? cr : ['null'];
  for (const k of keys) {
    matrix.chain_ref[k] = matrix.chain_ref[k] || {};
    matrix.chain_ref[k][outcome] = (matrix.chain_ref[k][outcome] || 0) + 1;
  }
}
```

NB: точные имена переменных (`matrix`, `v2Episodes`, поле inferred outcome) взять из реального кода — он уже строит другие оси, скопировать паттерн.

- [ ] **Step 4: Запустить — убедиться, что проходит**

Run: `cd app && npx vitest run --config vitest.config.tools.mjs ../tools/brain-retro-analyzer.test.mjs`
Expected: PASS (включая существующие тесты).

- [ ] **Step 5: Commit**

```bash
git add tools/brain-retro-analyzer.mjs tools/brain-retro-analyzer.test.mjs
git commit -m "feat(brain-retro): aggregate chain_ref into factorMatrix"
```

---

## Task 7: STATUS.md строка C6

**Files:**

- Modify: `tools/status-md-generator.mjs`
- Test: `tools/status-md-generator.test.mjs`

- [ ] **Step 1: Написать failing-тест**

Добавить в `tools/status-md-generator.test.mjs`:

```javascript
it('includes a C6 chain-map row', () => {
  const md = generateStatus(/* фикстура как в существующих тестах */);
  expect(md).toMatch(/C6 Chain map sync/);
});
```

NB: имя `generateStatus` и форму входа взять из существующего теста.

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `cd app && npx vitest run --config vitest.config.tools.mjs ../tools/status-md-generator.test.mjs -t C6`
Expected: FAIL.

- [ ] **Step 3: Реализовать**

В `tools/status-md-generator.mjs`, в таблицу контролёров добавить строку C6 (по аналогии с C5). Поскольку чекер запускается в lefthook, статус в STATUS.md — информационный: вызвать `checkSync` через импорт и отразить ok/drift:

```javascript
import { parseChainsFromMd, checkSync } from './observer-chain-map-checker.mjs';
// ... при сборке таблицы контролёров:
let c6 = '✅';
let c6detail = '[chain-map-checker] OK';
try {
  const md = readFileSync(join(OBS_ROOT, '..', 'routing-off-phase.md'), 'utf8'); // путь привести к реальному
  const jsonMap = JSON.parse(readFileSync(CHAIN_MAP_PATH, 'utf8'));
  const res = checkSync(jsonMap, parseChainsFromMd(md));
  if (!res.ok) { c6 = '🔴'; c6detail = `drift: ${[...res.jsonOnly, ...res.mdOnly].join(', ')}`; }
} catch (e) { c6 = '⚠️'; c6detail = `checker error: ${e.message}`; }
// добавить строку в таблицу: | C6 Chain map sync | ${c6} | ${c6detail} |
```

NB: пути (`OBS_ROOT`, `CHAIN_MAP_PATH`) и формат строки таблицы взять из реального кода генератора.

- [ ] **Step 4: Запустить — убедиться, что проходит**

Run: `cd app && npx vitest run --config vitest.config.tools.mjs ../tools/status-md-generator.test.mjs`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add tools/status-md-generator.mjs tools/status-md-generator.test.mjs
git commit -m "feat(status-md): surface C6 chain-map sync row"
```

---

## Task 8: aggregation-template секция hit rate

**Files:**

- Modify: `.claude/skills/brain-retro/references/aggregation-template.md`

- [ ] **Step 1: Заменить пустую секцию**

Найти `## Canonical chains L1–L12 hit rate` и заменить на:

```markdown
## Canonical chains L1–L13 hit rate (from analyzer `factorMatrix.chain_ref`)

| chain | times | outcome split | notes |
|---|---|---|---|

Каждый узел может входить в несколько L (multi-chain эпизод засчитан в каждую).
`null` = эпизоды вне цепочек (direct + узлы вне L1-L13) — **не проблема** per
`memory/feedback_brain_unused_tools_not_problem`.
```

- [ ] **Step 2: Commit**

```bash
git add .claude/skills/brain-retro/references/aggregation-template.md
git commit -m "docs(brain-retro): fill L1-L13 hit rate template section"
```

---

## Task 9: Финальная регрессия + ретрофилл + verification

**Files:** нет правок кода.

- [ ] **Step 1: Полная регрессия tools**

Run: `npm run test:tools`
Expected: PASS, число ≥ baseline (Task 0 Step 3) + новые тесты (≈ +16). 0 сломанных существующих.

- [ ] **Step 2: Ретрофилл dry-run**

Run: `node tools/observer-retrofill-chain-ref.mjs --dry-run`
Expected: для `episodes-2026-05.jsonl` — N/M строк получат chain_ref (N = число v2-эпизодов с known node).

- [ ] **Step 3: Ретрофилл реальный + идемпотентность**

Run:

```bash
node tools/observer-retrofill-chain-ref.mjs
node tools/observer-retrofill-chain-ref.mjs
```

Expected: первый — `changed > 0`; второй — `0/M` (идемпотентно).

- [ ] **Step 4: Commit ретрофилла данных**

```bash
git add docs/observer/episodes-2026-05.jsonl
git commit -m "chore(observer): retrofill chain_ref on existing May episodes"
```

- [ ] **Step 5: Verification-before-completion**

Использовать `superpowers:verification-before-completion`. Проверить acceptance criteria spec §13:

- 6 новых файлов созданы, тесты зелёные;
- lefthook job 16 red-green работает (Task 4);
- ретрофилл идемпотентен (Step 3);
- `node tools/observer-chain-map-checker.mjs` → OK;
- STATUS.md содержит строку C6.

- [ ] **Step 6: Финальный push**

```bash
git push origin feat/observer-chain-attribution:main
```

NB: gitleaks pre-push + полная регрессия по политике §15; push-паттерн `<ветка>:main` (FF).

---

## Self-Review (выполнено при написании плана)

**Spec coverage:** §4 архитектура → Tasks 1–8; §5 компоненты 1-8 → Tasks 1–8 (по одному); §6 потоки A/B/C/D → Tasks 2/4/6/5; §7 ошибки → defensive try/catch в Task 2 Step 3 (битый JSON) + Task 3 (формат .md) + Task 5 (идемпотентность); §8 тесты → Tasks 1/3/5/6/7; §10 порядок → Task 0; §13 acceptance → Task 9 Step 5. Все секции покрыты.

**Placeholder scan:** код приведён во всех code-степах. Места «NB: имя взять из существующего теста» — намеренные адаптационные точки (фабрики транскриптов и имена функций analyzer/generateStatus зависят от финальной базы после epic-плана и не могут быть зафиксированы заранее), не placeholder-логика.

**Type consistency:** `loadChainMap()`/`chainsFor(node, map)` — единые сигнатуры в Tasks 1/2/5/6. `parseChainsFromMd()`/`checkSync(jsonMap, mdSet)` → `{ ok, jsonOnly, mdOnly }` — единые в Tasks 3/4/7. `retrofillLine(ep, map)`/`retrofillFile(path, map, opts)` — Task 5. `chain_ref` форма (массив \| null) консистентна везде.

**Раскрытые при детализации уточнения** (в разделе «Уточнения к spec» вверху): тесты в `tools/` не `tests/`; C6 по L-номерам не по именам узлов; врезка по grep-маркеру.
