Files
portal/docs/superpowers/plans/2026-05-20-observer-chain-attribution.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

36 KiB
Raw Blame History

Observer Canonical Chain Attribution (L1L13) 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.


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

Этот план НЕ исполняется немедленно. Жёсткая зависимость от epic-плана 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:

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) — но включены в маппинг на будущее, если детектор узлов расширится.

{
  "_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:

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:

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
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; адаптировать фабрику транскрипта под существующие хелперы файла):

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. Вверху файла добавить импорт:
import { loadChainMap, chainsFor } from './observer-chain-detector.mjs';
  1. Один раз модульно загрузить карту с защитой от битого JSON:
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' и добавить рядом строку. Должно получиться:
        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
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:

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:

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
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:

    # 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'ов (1115) — формат должен совпасть.

  • Step 2: Red-green smoke

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

# временно добавить несуществующую цепочку в 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.

# откатить
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
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:

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:

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
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:

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":

// внутри построения 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
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:

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:

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
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 L1L12 hit rate и заменить на:

## Canonical chains L1L13 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
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:

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

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

  • Step 4: Commit ретрофилла данных
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

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-маркеру.