feat(observer): state enricher helper для эпизодов (stage 3 follow-up 2)

readRouterState(sessionId, {baseDir}) -- pure read state-файла сторожа.
extractRouterFields(state) -- pure извлечение 4 полей для primary_rationale.

Используется парсером эпизодов на следующем шаге (Task 3).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-24 15:42:05 +03:00
parent c7e02eeac9
commit 593f12ae6a
3 changed files with 152 additions and 10 deletions
+10 -10
View File
@@ -1,6 +1,6 @@
# Brain Status (auto-generated)
Last updated: 2026-05-24T08:32:29.613Z
Last updated: 2026-05-24T12:42:18.762Z
| Контролёр | Состояние | Детали |
|---|---|---|
@@ -8,14 +8,14 @@ Last updated: 2026-05-24T08:32:29.613Z
| C2 Cross-ref consistency | ✅ | [cross-ref-checker] OK — 0 drift in 4 files |
| C3 Observer-of-observer | ✅ | [observer-of-observer] OK — last read 0 week(s) ago |
| C4 Сигнальный статус | ✅ | This file (self-reference) |
| C5 Observer-coverage | ⚠️ | 134 episode(s) this month · .git/hooks/post-commit not installed (run: npx lefthook install --force) · 17 missed activation(s) — see /brain-retro |
| C5 Observer-coverage | ⚠️ | 135 episode(s) this month · .git/hooks/post-commit not installed (run: npx lefthook install --force) · 17 missed activation(s) — see /brain-retro |
| C6 Chain map sync | ✅ | [chain-map-checker] OK — 16 chains in sync |
## Метрики (информационные, не алерты)
- Observer evidence: 134 episodes this month, 0 observer_error markers, 5 PII matches before filter
- Legacy v1 episodes (not in factor analysis): 10
- Last /brain-retro: 0 day(s) ago
- Observer evidence: 135 episodes this month, 0 observer_error markers, 6 PII matches before filter
- Legacy v1 episodes (not in factor analysis): 11
- Last /brain-retro: 1 day(s) ago
- Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 17. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store).
## Метрики дисциплины
@@ -32,17 +32,17 @@ Baseline дисциплины роутера (этап 2 router discipline overh
| cleanup | 1 | 0.0% | 0.0% |
| monitoring | 1 | 0.0% | 0.0% |
Router step distribution: 1: 129 ⚠️ suspicious — >90% эпизодов остановились на step=1 (вероятный sentinel-bug парсера)
Router step distribution: 1: 55, 2: 45, 3: 12, 5: 18
Boundaries applied (ADR / границы): 13 of 129 эпизодов (10.1%).
Boundaries applied (ADR / границы): 13 of 130 эпизодов (10.0%).
## Активные многоэтапные проекты
- **Router discipline overhaul** ([spec](../superpowers/specs/2026-05-23-router-discipline-overhaul-design.md))
- Этап 1 (машиночитаемый реестр) ✅ закрыт 2026-05-23 — `docs/registry/nodes.yaml` (83 узла + 16 chains L1-L16), `tools/registry-load.mjs` + `tools/registry-render.mjs` (16 тестов), auto-render Tooling §4.0 + routing-off-phase, lefthook job 17 (warn-only).
- Этап 2 (измерения + классификатор-парсер) ✅ закрыт 2026-05-24 — discipline-metrics (3 среза), brain-retro-analyzer переключён на реестр, STATUS.md блок «Метрики дисциплины», baseline snapshot `docs/observer/baselines/2026-05-24-pre-enforcement.md`. Plan: `docs/superpowers/plans/2026-05-24-router-overhaul-stage-2-measurements.md`.
- Этап 3 (принуждение — хук на routing) ⏸ ждёт «продолжаем» от заказчика после ревью baseline-цифр.
- Этап 4 (уборка правил) — не начат.
- Этап 2 (измерения + классификатор-парсер) ✅ закрыт 2026-05-24 + влит в main 2026-05-24 — discipline-metrics (3 среза), brain-retro-analyzer переключён на реестр, STATUS.md блок «Метрики дисциплины», baseline snapshot `docs/observer/baselines/2026-05-24-pre-enforcement.md`. Plan: `docs/superpowers/plans/2026-05-24-router-overhaul-stage-2-measurements.md`.
- Этап 3 (принуждение — хук на routing) — Phase A+B (классификатор + 3 хука: router-prehook/tool-gate/stop-gate в `.claude/settings.json`) ✅ + влит в main 2026-05-24. Гейт работает в режиме **`warn-only`** (только stderr-предупреждения, никакой блокировки). Bug-fix `bec69aa5`: `deriveRouterStep` в `tools/discipline-metrics.mjs` — шаг роутера теперь выводится из наблюдаемых признаков (был захардкоженной константой 1). CHECKPOINT B: дать warn-only накопить реальные наблюдения (план говорит «минимум 24 часа»), затем Task 9 — переключение в `enforce` + 2 новых метрики (domain-hit-rate / chain-completion). Plan: `docs/superpowers/plans/2026-05-24-router-overhaul-stage-3-enforcement.md`.
- Этап 4 (уборка устаревших правил, deprecation `observer-classification-map.json` → удаление) — не начат.
## Алерт-индикаторы
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env node
/**
* Router state enricher for observer episodes.
* Reads ~/.claude/runtime/router-state-<sessionId>.json and exposes pure
* extraction helpers for primary_rationale enrichment.
*
* Pure-ish — fs is parameterized via options.baseDir for testability.
*
* Per spec: docs/superpowers/specs/2026-05-24-router-stage3-three-fixes-design.md
*/
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
function defaultBaseDir() {
return join(homedir(), '.claude', 'runtime');
}
export function readRouterState(sessionId, options = {}) {
if (!sessionId || typeof sessionId !== 'string') return null;
const baseDir = options.baseDir || defaultBaseDir();
const path = join(baseDir, `router-state-${sessionId}.json`);
if (!existsSync(path)) return null;
try {
const content = readFileSync(path, 'utf-8');
return JSON.parse(content);
} catch {
return null;
}
}
export function extractRouterFields(state) {
if (!state || typeof state !== 'object') {
return { recommended_node: null, recommended_chain: null, chain_progress: [], chain_completed: false };
}
const cls = state.classification || {};
return {
recommended_node: cls.recommendedNode || null,
recommended_chain: cls.recommendedChain || null,
chain_progress: Array.isArray(state.chainProgress) ? state.chainProgress : [],
chain_completed: state.chainCompleted === true,
};
}
+98
View File
@@ -0,0 +1,98 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, writeFileSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { readRouterState } from './observer-state-enricher.mjs';
describe('readRouterState', () => {
let baseDir;
beforeEach(() => {
baseDir = mkdtempSync(join(tmpdir(), 'router-state-test-'));
});
afterEach(() => {
rmSync(baseDir, { recursive: true, force: true });
});
it('returns null when state file does not exist', () => {
expect(readRouterState('abc-123', { baseDir })).toBeNull();
});
it('reads state file when present', () => {
const state = {
sessionId: 'abc-123',
classification: { recommendedNode: '#62', recommendedChain: '#13' },
chainProgress: ['brainstorming'],
chainCompleted: false,
};
writeFileSync(join(baseDir, 'router-state-abc-123.json'), JSON.stringify(state));
const result = readRouterState('abc-123', { baseDir });
expect(result).toEqual(state);
});
it('returns null on malformed JSON', () => {
writeFileSync(join(baseDir, 'router-state-broken.json'), 'not-json');
expect(readRouterState('broken', { baseDir })).toBeNull();
});
it('returns null on missing sessionId', () => {
expect(readRouterState(null, { baseDir })).toBeNull();
expect(readRouterState('', { baseDir })).toBeNull();
});
it('uses ~/.claude/runtime/ as default baseDir', () => {
// Smoke-check: default baseDir resolution doesn't throw.
// Real-file reading covered above with explicit baseDir.
const result = readRouterState('non-existent-session-xyz');
// Either null (file doesn't exist there) or object — both fine.
expect(result === null || typeof result === 'object').toBe(true);
});
});
describe('extractRouterFields', () => {
it('extracts the four fields from state, defaulting to null/empty', async () => {
const { extractRouterFields } = await import('./observer-state-enricher.mjs');
const state = {
classification: { recommendedNode: '#62', recommendedChain: '#13' },
chainProgress: ['brainstorming', 'writing-plans'],
chainCompleted: false,
};
expect(extractRouterFields(state)).toEqual({
recommended_node: '#62',
recommended_chain: '#13',
chain_progress: ['brainstorming', 'writing-plans'],
chain_completed: false,
});
});
it('returns nulls/empty when state is null', async () => {
const { extractRouterFields } = await import('./observer-state-enricher.mjs');
expect(extractRouterFields(null)).toEqual({
recommended_node: null,
recommended_chain: null,
chain_progress: [],
chain_completed: false,
});
});
it('handles missing classification block', async () => {
const { extractRouterFields } = await import('./observer-state-enricher.mjs');
expect(extractRouterFields({ chainProgress: ['x'], chainCompleted: true })).toEqual({
recommended_node: null,
recommended_chain: null,
chain_progress: ['x'],
chain_completed: true,
});
});
it('treats empty string recommendedNode/recommendedChain as null', async () => {
const { extractRouterFields } = await import('./observer-state-enricher.mjs');
expect(extractRouterFields({ classification: { recommendedNode: '', recommendedChain: '' } })).toEqual({
recommended_node: null,
recommended_chain: null,
chain_progress: [],
chain_completed: false,
});
});
});