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>
36 KiB
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.
⚠️ Внешняя зависимость и порядок (читать ПЕРВЫМ)
Этот план НЕ исполняется немедленно. Жёсткая зависимость от epic-плана 2026-05-20-observer-instrument-expansion.md (20 task), который правит тот же tools/observer-transcript-parser.mjs.
Процедура старта (Task 0 ниже):
- Дождаться сообщения «epic 20-task закрыт и push'нут на origin/main».
git fetch origin && git log HEAD..origin/main --oneline— убедиться, что 20 task влиты (Pravila §15.2 pre-flight).- Создать свежий worktree off
origin/main(Task 0). - Исполнять Tasks 1–9 по порядку.
Уточнения к spec (раскрыты при детализации writing-plans)
-
Расположение тестов. Spec §5/§8 называл
tests/observer-chain-*.test.mjs. Реальный паттерн репозитория — тест рядом с модулем:tools/observer-chain-detector.test.mjs. План использует реальный паттерн. -
Семантика контролёра 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-слой).
- каждый
-
Точка врезки в 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:
- Вверху файла добавить импорт:
import { loadChainMap, chainsFor } from './observer-chain-detector.mjs';
- Один раз модульно загрузить карту с защитой от битого JSON:
let CHAIN_MAP = null;
try {
CHAIN_MAP = loadChainMap();
} catch {
CHAIN_MAP = new Map(); // битый/отсутствующий JSON -> chainsFor вернёт null, observer не падает
}
- Найти 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'ов (11–15) — формат должен совпасть.
- 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 L1–L12 hit rate и заменить на:
## 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
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-маркеру.