#!/usr/bin/env node /** * Router state enricher for observer episodes. * Reads ~/.claude/runtime/router-state-.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 || cls.recommended_node) || null, recommended_chain: (cls.recommendedChain || cls.recommended_chain || cls.recommended_chain_id) || null, chain_progress: Array.isArray(state.chainProgress) ? state.chainProgress : [], chain_completed: state.chainCompleted === true, }; } /** * Extract the LLM classifier's output for the v4 episode schema (Task 15). * Pulls the subset of classification fields the analyzer / brain-retro skill * cares about. Returns null when the state has no classification (degraded * path, parser running on a transcript with no prehook state). */ export function extractClassifierOutput(state) { const cls = state?.classification; if (!cls || typeof cls !== 'object') return null; return { task_type: cls.task_type ?? cls.taskType ?? null, recommended_node: cls.recommended_node ?? cls.recommendedNode ?? null, recommended_chain: cls.recommended_chain ?? cls.recommendedChain ?? null, recommended_chain_id: cls.recommended_chain_id ?? null, no_skill_found: cls.no_skill_found === true, source: cls.source ?? null, // Factor-analysis signal: classifier's stated rationale + confidence. // Field name varies by prompt schema: new (Phase 2) uses `reason_for_choice`, // legacy uses `reasoning`. Null on regex / prefilter paths. Truncated to // keep episode JSONL line size bounded. reasoning: pickReasoning(cls), confidence: typeof cls.confidence === 'number' ? cls.confidence : null, // Pass 2 metrics (project-brain-factor-analysis-4passes): network latency, // internal retry count, categorized transport error, and the classifier's // own top-3 alternative nodes with rejection rationale. null on regex / // prefilter / cache paths where the LLM was never (or was already) called. latency_ms: typeof cls.latency_ms === 'number' ? cls.latency_ms : null, retry_count_internal: typeof cls.retry_count_internal === 'number' ? cls.retry_count_internal : null, llm_error: cls.llm_error_type ?? null, alternatives_considered: pickAlternatives(cls), }; } function pickReasoning(cls) { const v = cls.reasoning ?? cls.reason_for_choice ?? cls.reason ?? null; if (typeof v !== 'string') return null; return v.slice(0, 600); } function pickAlternatives(cls) { const v = cls.alternatives_considered; if (!Array.isArray(v)) return null; // Cap at top-3 to bound episode JSONL line size; Sonnet sometimes returns 5+. return v.slice(0, 3); }