Files
portal/docs/superpowers/plans/2026-05-30-brain-factor-analysis-completeness.md
T

628 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Brain factor-analysis completeness — 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:** Оживить и достроить факторный анализ «мозга»: судейский исход вместо догадки, честный флаг деградации классификатора, честное извлечение дисциплины (без раздувания собственной метрики), и pivot-функция «любые срезы».
**Architecture:** 4 точечных изменения в существующих чистых (pure) модулях, каждое через TDD. Новых обязательных полей схемы нет; одно производное поле `routing_signals` считается анализатором. Порядок: §3.2/§3.3 независимы; §3.1 (наполнить `outcome_reviewed`) предшествует §3.4 (движок предпочитает его). Ни одно изменение не трогает поведение блокирующих хуков (проверено в спеке §4.1).
**Tech Stack:** Node ESM (`.mjs`), vitest (tools-only), чистые функции без fs/exec где возможно.
**Спек:** `docs/superpowers/specs/2026-05-30-brain-factor-analysis-completeness-design.md`
**Verify-формула (sentinel):** после кодовых задач — vitest tools-only GREEN per memory `feedback_vitest_sentinel_recipe.md`.
---
## Task 1: Razor-точный флаг деградации классификатора (спек §3.2)
**Files:**
- Modify: `tools/router-classifier.mjs` (ветка `if (!llmResult)`, строки ~653-664)
- Test: `tools/router-classifier.test.mjs`
- [ ] **Step 1: Написать падающий тест на parse_null → degraded:true**
В `tools/router-classifier.test.mjs` (импорт `classify` уже есть в блоке строки 209):
```javascript
describe('classify degraded flag (spec §3.2)', () => {
const registry = { skills: [], chains: [] }; // минимальный; classifyByRegex терпит пустой
it('parse_null (LLM called, garbage back) → degraded:true', async () => {
let metricsSeen = false;
const llmCall = async ({ onMetrics }) => {
onMetrics({ latency_ms: 1234, retry_count_internal: 0 });
metricsSeen = true;
return null; // распарсилось в null = мусор
};
const r = await classify('почини баг в роутере', registry, { llmCall });
expect(metricsSeen).toBe(true);
expect(r.degraded).toBe(true);
expect(r.llm_error_type).toBe('parse_null');
});
it('no_key (LLM never called) → degraded:false', async () => {
const llmCall = async () => null; // onMetrics НЕ вызывается → metrics остаётся null
const r = await classify('почини баг в роутере', registry, { llmCall });
expect(r.degraded).toBe(false);
expect(r.llm_error_type).toBe('no_key');
});
});
```
- [ ] **Step 2: Запустить — убедиться, что падает**
Run: `npx vitest run tools/router-classifier.test.mjs -t "degraded flag"`
Expected: FAIL — `r.degraded` is `undefined` (поле не выставляется в ветке `!llmResult`), оба assert на degraded падают.
- [ ] **Step 3: Минимальная правка**
В `tools/router-classifier.mjs`, ветка `if (!llmResult)` — добавить строку `degraded`:
```javascript
if (!llmResult) {
// Layer 3 — regex fallback on no key (metrics null) / unparseable response.
// parse_null (metrics set ⇒ LLM was called, returned garbage) = real silent
// degradation. no_key (metrics null ⇒ LLM never called) = штатный regex, NOT degraded.
const r = classifyByRegex(prompt, registry);
return {
...r,
llm_error_type: metrics ? 'parse_null' : 'no_key',
latency_ms: metrics?.latency_ms ?? null,
retry_count_internal: metrics?.retry_count_internal ?? null,
degraded: metrics ? true : false,
};
}
```
- [ ] **Step 4: Запустить — убедиться, что проходит**
Run: `npx vitest run tools/router-classifier.test.mjs -t "degraded flag"`
Expected: PASS (2 теста).
- [ ] **Step 5: Прогнать весь файл (нет регрессии)**
Run: `npx vitest run tools/router-classifier.test.mjs`
Expected: PASS все (новые 2 + существующие).
- [ ] **Step 6: Commit**
```bash
git add tools/router-classifier.mjs tools/router-classifier.test.mjs
git commit -m "fix(router): degraded:true on silent LLM->regex fallback (parse_null), false on no_key"
```
---
## Task 2: `resolvedOutcome` хелпер + предпочтение судейского вердикта (спек §3.4 баг B)
**Files:**
- Modify: `tools/brain-retro-analyzer.mjs` (рядом с `buildFactorMatrix`, строки ~383-408)
- Test: `tools/brain-retro-analyzer.test.mjs`
- [ ] **Step 1: Написать падающий тест на resolvedOutcome + factorMatrix**
В `tools/brain-retro-analyzer.test.mjs` (там уже импортируется `buildFactorMatrix`; добавить `resolvedOutcome` в импорт из `./brain-retro-analyzer.mjs`):
```javascript
describe('resolvedOutcome (spec §3.4 bug B)', () => {
it('prefers outcome_reviewed over _inferredOutcome', () => {
expect(resolvedOutcome({ outcome_reviewed: 'rework', _inferredOutcome: 'success' })).toBe('rework');
});
it('falls back to _inferredOutcome when no reviewed verdict', () => {
expect(resolvedOutcome({ _inferredOutcome: 'success' })).toBe('success');
});
it('defaults to unknown when neither present', () => {
expect(resolvedOutcome({})).toBe('unknown');
});
it('buildFactorMatrix buckets by reviewed verdict, not heuristic', () => {
const eps = [
{ schema_version: 4, outcome_reviewed: 'rework', _inferredOutcome: 'success',
primary_rationale: { node_chosen: 'direct' } },
];
const m = buildFactorMatrix(eps);
expect(m.node_chosen.direct.rework).toBe(1);
expect(m.node_chosen.direct.success).toBeUndefined();
});
});
```
- [ ] **Step 2: Запустить — убедиться, что падает**
Run: `npx vitest run tools/brain-retro-analyzer.test.mjs -t "resolvedOutcome"`
Expected: FAIL — `resolvedOutcome is not a function` (ещё не экспортирована); factorMatrix-тест падает, т.к. сейчас матрица читает `_inferredOutcome` → попадёт в `success`.
- [ ] **Step 3: Добавить хелпер и применить в buildFactorMatrix**
В `tools/brain-retro-analyzer.mjs` перед `buildFactorMatrix` добавить:
```javascript
/**
* Resolve an episode's outcome for the factor engine: prefer the reviewer's
* judged verdict (outcome_reviewed), fall back to the tone-based heuristic
* (_inferredOutcome), then 'unknown'. Spec §3.4 bug B.
*/
export function resolvedOutcome(e) {
return (e && e.outcome_reviewed) || (e && e._inferredOutcome) || 'unknown';
}
```
В `buildFactorMatrix` заменить чтение исхода:
```javascript
const val = fn(e);
const outcome = resolvedOutcome(e);
```
И в блоке `matrix.chain_ref` (строка ~400) заменить:
```javascript
const outcome = resolvedOutcome(e);
```
- [ ] **Step 4: Запустить — убедиться, что проходит**
Run: `npx vitest run tools/brain-retro-analyzer.test.mjs -t "resolvedOutcome"`
Expected: PASS (4 теста).
- [ ] **Step 5: Прогнать весь файл (нет регрессии существующих matrix-тестов)**
Run: `npx vitest run tools/brain-retro-analyzer.test.mjs`
Expected: PASS все. (Существующие тесты используют `_inferredOutcome` без `outcome_reviewed` → resolvedOutcome падает на эвристику, поведение прежнее.)
- [ ] **Step 6: Commit**
```bash
git add tools/brain-retro-analyzer.mjs tools/brain-retro-analyzer.test.mjs
git commit -m "feat(brain-retro): resolvedOutcome — factor matrix prefers judged verdict over heuristic"
```
---
## Task 3: `crossTab` — pivot «любые срезы» (спек §3.4)
**Files:**
- Modify: `tools/brain-retro-analyzer.mjs` (после `buildFactorMatrix`)
- Test: `tools/brain-retro-analyzer.test.mjs`
- [ ] **Step 1: Написать падающий тест на crossTab**
Добавить `crossTab` в импорт из `./brain-retro-analyzer.mjs`. Тест:
```javascript
describe('crossTab (spec §3.4)', () => {
const eps = [
{ schema_version: 4, outcome_reviewed: 'success',
environment: { economy_level: 0 }, primary_rationale: { node_chosen: 'direct' } },
{ schema_version: 4, outcome_reviewed: 'rework',
environment: { economy_level: 0 }, primary_rationale: { node_chosen: 'direct' } },
{ schema_version: 4, outcome_reviewed: 'success',
environment: { economy_level: 100 }, primary_rationale: { node_chosen: 'superpowers:brainstorming' } },
];
it('cross-tabs two axes into outcome distributions', () => {
const t = crossTab(eps, 'economy_level', 'node_chosen');
expect(t['0|direct']).toEqual({ success: 1, rework: 1 });
expect(t['100|superpowers:brainstorming']).toEqual({ success: 1 });
});
it('unknown axis name → empty object, no throw', () => {
expect(crossTab(eps, 'no_such_axis', 'node_chosen')).toEqual({});
});
it('optional third axis produces triple key', () => {
const t = crossTab(eps, 'economy_level', 'node_chosen', 'node_chosen');
expect(t['0|direct|direct']).toEqual({ success: 1, rework: 1 });
});
});
```
- [ ] **Step 2: Запустить — убедиться, что падает**
Run: `npx vitest run tools/brain-retro-analyzer.test.mjs -t "crossTab"`
Expected: FAIL — `crossTab is not a function`.
- [ ] **Step 3: Реализовать crossTab**
В `tools/brain-retro-analyzer.mjs` после `buildFactorMatrix` добавить:
```javascript
/**
* Pivot any 2 (or 3) factor axes against the resolved outcome (spec §3.4).
* Reuses FACTOR_FNS. Returns { "valA|valB[|valC]": { outcome: count, ... } }.
* Unknown axis name → {} (no throw). Outcome via resolvedOutcome (judged>heuristic).
*/
export function crossTab(episodes, axisA, axisB, axisC = null) {
const fnA = FACTOR_FNS[axisA];
const fnB = FACTOR_FNS[axisB];
const fnC = axisC ? FACTOR_FNS[axisC] : null;
if (!fnA || !fnB || (axisC && !fnC)) return {};
const out = {};
for (const e of episodes || []) {
const parts = [fnA(e), fnB(e)];
if (fnC) parts.push(fnC(e));
const key = parts.join('|');
const outcome = resolvedOutcome(e);
out[key] = out[key] || {};
out[key][outcome] = (out[key][outcome] || 0) + 1;
}
return out;
}
```
- [ ] **Step 4: Запустить — убедиться, что проходит**
Run: `npx vitest run tools/brain-retro-analyzer.test.mjs -t "crossTab"`
Expected: PASS (3 теста).
- [ ] **Step 5: Прогнать весь файл**
Run: `npx vitest run tools/brain-retro-analyzer.test.mjs`
Expected: PASS все.
- [ ] **Step 6: Commit**
```bash
git add tools/brain-retro-analyzer.mjs tools/brain-retro-analyzer.test.mjs
git commit -m "feat(brain-retro): crossTab — pivot any 2-3 factor axes against outcome"
```
---
## Task 4: Честное извлечение дисциплины — Слой A (спек §3.3)
**Files:**
- Modify: `tools/observer-transcript-parser.mjs` (`extractTriggers` строки ~536-547, `extractBoundaries` ~583-594, `primary_rationale` блок ~928-953)
- Test: `tools/observer-transcript-parser.test.mjs`
Цель: (1) расширить `triggers_matched`/`boundaries_applied` по ДОСЛОВНЫМ цитатам (имена узлов, hard-floor/hard-rule) — это всё ещё честный текст ответа; (2) добавить ОТДЕЛЬНОЕ производное поле `primary_rationale.routing_signals` из объективных данных (`chain_ref`/`recommended_node`), НЕ вливая его в triggers.
- [ ] **Step 1: Написать падающий тест на расширенные триггеры + routing_signals**
В `tools/observer-transcript-parser.test.mjs` (импорт `parseTranscript`, `extractTriggers` уже могут быть; добавить при необходимости):
```javascript
describe('discipline extraction Layer A (spec §3.3)', () => {
it('extractTriggers catches node names and hard-floor verbatim', () => {
const turn = [{ message: { role: 'assistant', content: [
{ type: 'text', text: 'Применяю superpowers:brainstorming и узел #19, это hard-floor.' },
] } }];
const t = extractTriggers(turn);
expect(t).toContain('superpowers:brainstorming');
expect(t).toContain('#19');
expect(t.some((x) => /hard-floor/i.test(x))).toBe(true);
});
it('routing_signals separate from triggers_matched (objective data, not prose)', () => {
// transcript with a Skill invocation that maps to a chain → chain_ref non-empty
const transcript = JSON.stringify({
sessionId: 's1', timestamp: '2026-05-30T10:00:00Z',
message: { role: 'user', content: 'сделай фичу X с тремя шагами' },
}) + '\n' + JSON.stringify({
sessionId: 's1', timestamp: '2026-05-30T10:01:00Z',
message: { role: 'assistant', content: [
{ type: 'tool_use', name: 'Skill', input: { skill: 'superpowers:writing-plans' } },
] },
});
const ep = parseTranscript(transcript, 's1');
expect(Array.isArray(ep.primary_rationale.routing_signals)).toBe(true);
// triggers_matched stays prose-only — no chain_ref leakage into it
expect(ep.primary_rationale.triggers_matched.every((t) => typeof t === 'string')).toBe(true);
});
});
```
- [ ] **Step 2: Запустить — убедиться, что падает**
Run: `npx vitest run tools/observer-transcript-parser.test.mjs -t "discipline extraction Layer A"`
Expected: FAIL — узлы/hard-floor не ловятся текущими `TRIGGER_PATTERNS`; `routing_signals` отсутствует в `primary_rationale`.
- [ ] **Step 3: Расширить паттерны и добавить routing_signals**
В `tools/observer-transcript-parser.mjs`:
(a) В `TRIGGER_PATTERNS` (строка ~526) добавить паттерны для имён узлов и hard-floor:
```javascript
const TRIGGER_PATTERNS = [
/\bPravila\s+§\d+(?:\.\d+)?/g,
/\bADR-\d+/g,
/\bPSR_v1\s+R\d+(?:\.\d+)?/g,
/\brouting-off-phase\s+L\d+/g,
/\bL\d+\s+chain/g,
/\bhard-(?:floor|rule)\b/gi,
/\b[a-z][a-z0-9-]*:[a-z][a-z0-9-]*\b/g, // namespaced skill e.g. superpowers:brainstorming
/(?:^|\s)#\d{1,3}\b/g, // tooling node id e.g. #19
];
```
NB: после `text.match` для `#NN` остаётся ведущий пробел — нормализовать в `extractTriggers` через `.trim()` при `out.add`:
```javascript
if (matches) for (const m of matches) {
const cleaned = m.trim();
const norm = /^L\d+\s+chain$/.test(cleaned) ? `routing-off-phase ${cleaned.split(/\s+/)[0]}` : cleaned;
out.add(norm);
}
```
(b) В блоке `primary_rationale` (строка ~937, объект `return {`) добавить отдельное поле ПОСЛЕ `boundaries_applied`:
```javascript
boundaries_applied: merge(extractBoundaries(turn), tag ? tag.boundaries : []),
routing_signals: (() => {
const sig = [];
const cr = chainsFor(skills.length > 0 ? skills[0] : 'direct', CHAIN_MAP);
if (Array.isArray(cr) && cr.length) sig.push('chain_ref');
if (routerFields.recommended_node) sig.push('recommended_node');
return sig;
})(),
```
- [ ] **Step 4: Запустить — убедиться, что проходит**
Run: `npx vitest run tools/observer-transcript-parser.test.mjs -t "discipline extraction Layer A"`
Expected: PASS (2 теста).
- [ ] **Step 5: Прогнать весь файл (нет регрессии парсера)**
Run: `npx vitest run tools/observer-transcript-parser.test.mjs`
Expected: PASS все. Если существующий тест проверял точный набор triggers — обновить ожидание (новые паттерны могут добавить узлы); это ожидаемо, не регресс.
- [ ] **Step 6: Commit**
```bash
git add tools/observer-transcript-parser.mjs tools/observer-transcript-parser.test.mjs
git commit -m "feat(observer): Layer A discipline extraction — node/hard-floor triggers + separate routing_signals"
```
---
## Task 5: Честный watchdog — Слой B (спек §3.3)
**Files:**
- Modify: `tools/discipline-metrics.mjs` (`routerStepReached` строки ~81-91)
- Modify: `tools/status-md-generator.mjs` (формулировка suspicious, строка ~332-334)
- Test: `tools/discipline-metrics.test.mjs`
Цель: НЕ ослаблять порог. `suspicious` остаётся при том же условии, но смысл честный: «низкая дисциплина ИЛИ нет объективных сигналов», а не «баг парсера». Учесть `routing_signals` — если они есть, эпизод не считается «застрявшим вслепую».
- [ ] **Step 1: Написать падающий тест**
В `tools/discipline-metrics.test.mjs`:
```javascript
describe('routerStepReached honest watchdog (spec §3.3 Layer B)', () => {
const e = (pr) => ({ schema_version: 4, primary_rationale: pr });
it('suspicious when >90% step=1 AND no routing_signals', () => {
const eps = Array.from({ length: 10 }, () =>
e({ task_classification: 'other', triggers_matched: [], node_chosen: 'direct', chain_ref: null, routing_signals: [] }));
const r = routerStepReached(eps);
expect(r.suspicious).toBe(true);
});
it('NOT suspicious when routing_signals present despite empty triggers', () => {
const eps = Array.from({ length: 10 }, () =>
e({ task_classification: 'other', triggers_matched: [], node_chosen: 'direct', chain_ref: null, routing_signals: ['recommended_node'] }));
const r = routerStepReached(eps);
expect(r.suspicious).toBe(false);
});
});
```
- [ ] **Step 2: Запустить — убедиться, что падает**
Run: `npx vitest run tools/discipline-metrics.test.mjs -t "honest watchdog"`
Expected: FAIL — второй тест падает: текущий `routerStepReached` не смотрит `routing_signals`, поднимает suspicious в обоих случаях.
- [ ] **Step 3: Учесть routing_signals в suspicious**
В `tools/discipline-metrics.mjs`, `routerStepReached`:
```javascript
export function routerStepReached(episodes) {
const distribution = {};
let total = 0;
let withRoutingSignals = 0;
for (const e of valid(episodes)) {
const key = String(deriveRouterStep(e.primary_rationale));
distribution[key] = (distribution[key] || 0) + 1;
total += 1;
const sig = (e.primary_rationale || {}).routing_signals;
if (Array.isArray(sig) && sig.length > 0) withRoutingSignals += 1;
}
const stuckAt1 = (distribution['1'] || 0) / Math.max(total, 1);
const signalRate = withRoutingSignals / Math.max(total, 1);
// Honest watchdog (spec §3.3 Layer B): low textual discipline AND no objective
// routing signals. Objective signals present → not "stuck blind" → not suspicious.
const suspicious = total >= 5 && stuckAt1 > 0.9 && signalRate < 0.1;
return { distribution, total, suspicious, signalRate };
}
```
- [ ] **Step 4: Обновить формулировку в STATUS**
В `tools/status-md-generator.mjs` строка ~332, заменить текст:
```javascript
const suspicious = discipline.routerStep?.suspicious
? ' ⚠️ low routing discipline — >90% step=1 И нет объективных сигналов роутинга (chain_ref/recommended_node)'
: '';
```
- [ ] **Step 5: Запустить тесты**
Run: `npx vitest run tools/discipline-metrics.test.mjs -t "honest watchdog"`
Expected: PASS (2 теста).
- [ ] **Step 6: Прогнать оба файла (нет регрессии)**
Run: `npx vitest run tools/discipline-metrics.test.mjs tools/status-md-generator.test.mjs`
Expected: PASS все. Если существующий status-md тест ассертил старый текст suspicious — обновить на новую формулировку.
- [ ] **Step 7: Commit**
```bash
git add tools/discipline-metrics.mjs tools/status-md-generator.mjs tools/discipline-metrics.test.mjs
git commit -m "feat(brain): honest watchdog — suspicious only when no objective routing signals; truthful STATUS wording"
```
---
## Task 6: `--meaningful-only` фильтр в batch-reviewer (спек §3.1)
**Files:**
- Modify: `tools/brain-retro-batch-reviewer.mjs` (фильтр targets, строки ~33-44; argv ~21)
- Test: `tools/brain-retro-batch-reviewer.test.mjs` (создать, если отсутствует — проверить Glob)
NB: основной модуль — CLI-скрипт с top-level await. Чтобы юнит-тестировать фильтр, вынести его в чистую экспортируемую функцию.
- [ ] **Step 1: Написать падающий тест на isMeaningfulEpisode**
Создать/дополнить `tools/brain-retro-batch-reviewer.test.mjs`:
```javascript
import { describe, it, expect } from 'vitest';
import { isMeaningfulEpisode } from './brain-retro-batch-reviewer.mjs';
describe('isMeaningfulEpisode (spec §3.1)', () => {
it('node_chosen != direct → meaningful', () => {
expect(isMeaningfulEpisode({ primary_rationale: { node_chosen: 'superpowers:brainstorming' }, task_size: { tool_calls: 2 } })).toBe(true);
});
it('direct + large task (tool_calls>=20) → meaningful', () => {
expect(isMeaningfulEpisode({ primary_rationale: { node_chosen: 'direct' }, task_size: { tool_calls: 25 } })).toBe(true);
});
it('direct + small task → NOT meaningful', () => {
expect(isMeaningfulEpisode({ primary_rationale: { node_chosen: 'direct' }, task_size: { tool_calls: 3 } })).toBe(false);
});
it('missing fields → NOT meaningful (safe default)', () => {
expect(isMeaningfulEpisode({})).toBe(false);
});
});
```
- [ ] **Step 2: Запустить — убедиться, что падает**
Run: `npx vitest run tools/brain-retro-batch-reviewer.test.mjs`
Expected: FAIL — `isMeaningfulEpisode is not a function` (не экспортирована).
- [ ] **Step 3: Вынести и экспортировать чистую функцию + применить флаг**
В `tools/brain-retro-batch-reviewer.mjs` ДО CLI-блока (перед чтением argv) добавить:
```javascript
/**
* Meaningful = a real routing decision worth reviewing (spec §3.1):
* node_chosen != 'direct' (skill/node was used) OR a large task (tool_calls >= 20).
* Pure — chatter (direct + small) is skipped to save reviewer cost.
*/
export function isMeaningfulEpisode(ep) {
const node = ep?.primary_rationale?.node_chosen;
if (node && node !== 'direct') return true;
const calls = Number(ep?.task_size?.tool_calls) || 0;
return calls >= 20;
}
```
В argv-парсинге добавить флаг (после `concStr`):
```javascript
const meaningfulOnly = process.argv.includes('--meaningful-only');
```
В цикле сбора `targets` добавить условие ПОСЛЕ `if (ep.outcome_reviewed) continue;`:
```javascript
if (meaningfulOnly && !isMeaningfulEpisode(ep)) continue;
```
(флаг `--meaningful-only` не должен попасть в позиционные argv: он уже отфильтруется `parseInt` для limit/conc, т.к. идёт как именованный; но для чистоты — оставить позиционные `[limitStr, concStr]` как есть, они читаются по индексу 4/5, а `--meaningful-only` встанет индексом 6+, не мешает).
- [ ] **Step 4: Запустить — убедиться, что проходит**
Run: `npx vitest run tools/brain-retro-batch-reviewer.test.mjs`
Expected: PASS (4 теста).
- [ ] **Step 5: Commit**
```bash
git add tools/brain-retro-batch-reviewer.mjs tools/brain-retro-batch-reviewer.test.mjs
git commit -m "feat(brain-retro): --meaningful-only filter for batch reviewer (node!=direct OR large task)"
```
---
## Task 7: Прогон reviewer по содержательным эпизодам мая (спек §3.1, наполнение данных)
**Files:**
- Read/Modify (data): `docs/observer/episodes-2026-05.jsonl` (in-place построчно — пишет сам reviewer)
NB: требует `ROUTER_LLM_KEY` в окружении. Это НЕ кодовая задача — прогон утилиты для наполнения данных. Должен идти ПОСЛЕ Task 6 (флаг) и предшествовать пользе от Task 2/3 (движок предпочтёт `outcome_reviewed`).
- [ ] **Step 1: Сухой подсчёт — сколько содержательных непроверенных**
Run: `npx vitest run` НЕ нужен. Подсчёт — через уже существующий dry-вывод reviewer'а (он печатает `total in period unreviewed` в stderr). Запуск с лимитом 0 для оценки:
Run: `node tools/brain-retro-batch-reviewer.mjs docs/observer/episodes-2026-05.jsonl 2026-05-01T00:00:00Z 0 5 --meaningful-only`
Expected: stderr `[batch-reviewer] total in period unreviewed: N, processing first 0 ...` — N даёт ориентир (ожидаемо ~60-90).
- [ ] **Step 2: Подтвердить у заказчика бюджет прогона**
Сообщить N и оценку стоимости (N × ~$0.05-0.07 на Opus, или дешевле если `REVIEWER_MODEL`=Sonnet). Дождаться «да».
- [ ] **Step 3: Прогон**
Run: `node tools/brain-retro-batch-reviewer.mjs docs/observer/episodes-2026-05.jsonl 2026-05-01T00:00:00Z 200 5 --meaningful-only`
Expected: stderr прогресс-строки; финал `done: M reviewed, K errors`. Файл перезаписан построчно (только отревьюенные строки изменены).
- [ ] **Step 4: Verify наполнения**
Через Grep: `"outcome_reviewed":"` в `docs/observer/episodes-2026-05.jsonl` — count вырос с 0 до ~M.
И `"review":{` — count ~M (был 0).
- [ ] **Step 5: Commit данных**
```bash
git add docs/observer/episodes-2026-05.jsonl
git commit -m "data(observer): backfill reviewer verdicts on meaningful May episodes"
```
---
## Task 8: Регрессия + перегенерация STATUS + нормативка
**Files:**
- Regenerate: `docs/observer/STATUS.md` (через генератор)
- Modify: `CLAUDE.md` (§6 +абзац, §9 +entry — через `/claude-md-management:revise-claude-md`)
- [ ] **Step 1: Полная регрессия tools-only**
Run: `npx vitest run tools/` (исключая широкий прогон per sentinel-формулы; см. memory `feedback_vitest_sentinel_recipe.md`)
Expected: GREEN, прежнее число тестов + новые (~15: 2+4+3+2+2+4 минус пересечения).
- [ ] **Step 2: Перегенерировать STATUS.md и глазами проверить ожившие блоки**
Run: `node tools/status-md-generator.mjs`
Expected: `[status-md-generator] OK`. В `docs/observer/STATUS.md`: блок «Reviewer findings» больше не «нет проверенных», «Метрики дисциплины» с новым столбцом/честной формулировкой suspicious.
- [ ] **Step 3: Commit STATUS**
```bash
git add docs/observer/STATUS.md
git commit -m "chore(observer): regenerate STATUS with revived reviewer + honest discipline blocks"
```
- [ ] **Step 4: Нормативка CLAUDE.md**
Через `/claude-md-management:revise-claude-md`: §6 +абзац (что сделано), §9 +entry. §0 cross-refs НЕ менять (infrastructure layer, не tooling-канон/ADR/off-phase — см. спек §6).
- [ ] **Step 5: Финальный verify + push**
Прогнать sentinel, затем push (docs+code) — через AskUser git-approval per gate.
---
## Self-Review (выполнено при написании плана)
**1. Покрытие спека:** §3.1→Task 6+7; §3.2→Task 1; §3.3 Слой A→Task 4, Слой B→Task 5; §3.4 crossTab→Task 3, resolvedOutcome(баг B)→Task 2; §4.1/§4.2 (соседи/конфликт интересов)→учтены в дизайне Task 4/5 (routing_signals отдельно, watchdog не ослаблен). §2 (YAGNI — #4/эмбеддинги/тег) — намеренно нет задач. ✅ Все требования покрыты.
**2. Placeholder scan:** нет TBD/«handle edge cases» — весь код в шагах конкретный. ✅
**3. Type consistency:** `resolvedOutcome` определён в Task 2, используется в Task 3 (`crossTab`) — сигнатура совпадает. `isMeaningfulEpisode` — Task 6, используется в Task 7 (CLI). `routing_signals` — пишется Task 4 (parser), читается Task 5 (`routerStepReached`). ✅
**Порядок исполнения:** Task 1 / 2 / 3 / 4 / 5 / 6 независимы по коду (можно параллельно), НО Task 7 (прогон данных) требует Task 6, и польза Task 2/3 проявляется только после Task 7. Task 8 — последний.