30 KiB
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):
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:
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
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):
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 добавить:
/**
* 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 заменить чтение исхода:
const val = fn(e);
const outcome = resolvedOutcome(e);
И в блоке matrix.chain_ref (строка ~400) заменить:
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
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. Тест:
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 добавить:
/**
* 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
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 уже могут быть; добавить при необходимости):
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:
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:
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:
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
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:
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:
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, заменить текст:
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
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:
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) добавить:
/**
* 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):
const meaningfulOnly = process.argv.includes('--meaningful-only');
В цикле сбора targets добавить условие ПОСЛЕ if (ep.outcome_reviewed) continue;:
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
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 данных
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
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 — последний.