628 lines
30 KiB
Markdown
628 lines
30 KiB
Markdown
# 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 — последний.
|