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

30 KiB
Raw Blame History

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 — последний.