#!/usr/bin/env node /** * Transcript parser for the brain governance observer. * Deterministically extracts episode fields from a Claude Code session * transcript (JSONL). No LLM — pure parsing. * * Scope: the last turn (from the last real user prompt to end of file) — * one episode == one prompt→response cycle. * * Reasoning fields (triggers_matched / candidates_considered / * boundaries_applied) are NOT recoverable from a transcript and stay []; * their capture is a separate design question (ADR-011 follow-up). * * Security Guidance #40: pure parsing — no exec/execSync. * Per ADR-011 §6 + spec v1.1 §5.2.1. */ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { readRouterState, extractRouterFields, extractClassifierOutput } from './observer-state-enricher.mjs'; import { CLASSIFIER_MODEL } from './router-config.mjs'; import { homedir } from 'node:os'; import { detectChoiceProvenance, detectAskUserQuestionChoice } from './observer-choice-detector.mjs'; import { loadChainMap, chainsFor } from './observer-chain-detector.mjs'; import { buildHookMap, resolveScriptCounts } from './observer-hook-resolver.mjs'; // recommendNode / buildClassificationMap / buildDormancyMap были использованы // для слепого fallback на heuristic recommended_node — убрано 2026-05-26 // (brain-retro #6 follow-up). Импорты сняты как dead code. import { loadRegistry } from './registry-load.mjs'; import { extractV4Signals } from './observer-v4-signals.mjs'; import { JUDGE_PER_CALL_USD } from './cost-pricing.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); let CHAIN_MAP = null; try { CHAIN_MAP = loadChainMap(); } catch { CHAIN_MAP = new Map(); // битый/отсутствующий JSON -> chainsFor вернёт null, observer не падает } let HOOK_MAP = null; function getHookMap() { if (HOOK_MAP) return HOOK_MAP; const read = (p) => { try { return JSON.parse(readFileSync(p, 'utf-8')); } catch { return {}; } }; HOOK_MAP = buildHookMap( read(join(__dirname, '..', '.claude', 'settings.json')), read(join(homedir(), '.claude', 'settings.json')) ); return HOOK_MAP; } /** * Whitelist of router-node names. Used by extractCandidates to filter out * free-form prose bullets (analysis text, procedure steps, code snippets) that * the regex on its own would happily slurp into candidates_considered. * Sources, in order: * - tools/observer-known-nodes.txt — bare names (brainstorming, ccpm, …) * - tools/observer-chain-map.json keys — incl. plugin:skill form * - sentinel "direct" (no-skill marker used by node_chosen) * Tooling IDs (#NN) and arbitrary plugin:skill forms pass via regex below. */ const KNOWN_NODES = (() => { const set = new Set(['direct']); try { const txt = readFileSync(join(__dirname, 'observer-known-nodes.txt'), 'utf8'); for (const line of txt.split('\n')) { const t = line.replace(/#.*$/, '').trim(); if (t) set.add(t); } } catch { // file missing in some test sandboxes — fall back to chain-map keys only } if (CHAIN_MAP) for (const node of CHAIN_MAP.keys()) set.add(node); return set; })(); const TOOLING_ID_RE = /^#\d+$/; const NAMESPACED_SKILL_RE = /^[a-z][a-z0-9-]*:[a-z][a-z0-9-]*(?::[a-z][a-z0-9-]*)?$/; /** * Strip lightweight markdown wrappers (bold, italic, code, trailing punctuation) * before testing against the whitelist. Conservative — we accept that some * weirdly-formatted node names slip through, but free-form prose bullets do not. */ function normalizeCandidate(s) { let t = String(s || '').trim(); // peel outer markdown markers: **x**, *x*, `x`, _x_ while ( (t.startsWith('**') && t.endsWith('**') && t.length > 4) || (t.startsWith('`') && t.endsWith('`') && t.length > 2) || (t.startsWith('*') && t.endsWith('*') && t.length > 2) || (t.startsWith('_') && t.endsWith('_') && t.length > 2) ) { if (t.startsWith('**')) t = t.slice(2, -2).trim(); else t = t.slice(1, -1).trim(); } // drop trailing punctuation (commas, periods, em-dashes) that lists often leave t = t.replace(/[.,;:!?—–-]+$/u, '').trim(); return t; } function isKnownNode(raw) { const t = normalizeCandidate(raw); if (!t) return false; if (KNOWN_NODES.has(t)) return true; if (TOOLING_ID_RE.test(t)) return true; // namespaced plugin:skill we haven't seen yet — accept if shape matches and // contains no whitespace (a free-form bullet with a colon in prose won't pass). if (NAMESPACED_SKILL_RE.test(t)) return true; return false; } const SUPERPOWERS_PREFIX = 'superpowers:'; function parseLines(text) { const entries = []; let broken = 0; let total = 0; // quirk #101 root fix: Claude Code's transcript file accumulates duplicated // context-rebuild snapshots — the same entry is re-printed with the SAME // `uuid`. Without dedup, session_turn / task_size / events double-count and // session_turn becomes non-monotonic across episodes parsed at different // file-growth states. Keep the first occurrence per uuid; entries without a // uuid (synthetic test fixtures) pass through unchanged. const seenUuid = new Set(); for (const line of String(text || '').split('\n')) { const trimmed = line.trim(); if (!trimmed) continue; total += 1; let e; try { e = JSON.parse(trimmed); } catch { broken += 1; // broken line — counted for parse_gap, never thrown continue; } if (e && e.uuid) { if (seenUuid.has(e.uuid)) continue; seenUuid.add(e.uuid); } entries.push(e); } return { entries, broken, total }; } // Synthetic user-role messages — NOT genuine prompts, must not be turn boundaries. // Skill invocation content, local slash-command output/invocation, interrupt markers // are recorded with role:'user' but carry no UserPromptSubmit hook context. const SYNTHETIC_PROMPT_MARKERS = [ 'Base directory for this skill:', '', '', '', '[Request interrupted by user]', ]; function isSyntheticPrompt(text) { const t = String(text || '').trimStart(); return SYNTHETIC_PROMPT_MARKERS.some((m) => t.startsWith(m)); } // A genuine user prompt (turn boundary) — not a tool_result carrier nor a // synthetic skill/command/interrupt message. function isRealUserPrompt(entry) { const msg = entry && entry.message; if (!msg || msg.role !== 'user') return false; const c = msg.content; if (typeof c === 'string') { return c.trim().length > 0 && !isSyntheticPrompt(c); } if (Array.isArray(c)) { const hasToolResult = c.some((b) => b && b.type === 'tool_result'); const hasText = c.some((b) => b && b.type === 'text'); if (!hasText || hasToolResult) return false; const text = c .filter((b) => b && b.type === 'text') .map((b) => b.text || '') .join(' '); return !isSyntheticPrompt(text); } return false; } function findTurnStart(entries) { for (let i = entries.length - 1; i >= 0; i--) { if (isRealUserPrompt(entries[i])) return i; } return 0; } function stripSystemReminders(text) { return String(text || '').replace(/[\s\S]*?<\/system-reminder>/g, ''); } function promptText(entry) { const c = entry && entry.message && entry.message.content; if (typeof c === 'string') return stripSystemReminders(c); if (Array.isArray(c)) { const joined = c .filter((b) => b && b.type === 'text') .map((b) => b.text || '') .join(' '); return stripSystemReminders(joined); } return ''; } export function classifyTask(text) { const t = String(text || '').toLowerCase(); if (/обнови эталон|sync memory|обнови (?:память|memory|memory\.md)/.test(t)) return 'memory-sync'; if (/обнови claude|правк[аи] pravila|update pravila|обнови psr|обнови tooling|нормативка/.test(t)) return 'regulatory-bump'; if (/план|plan\b|спроектируй|design\b|brainstorm|обсудим/.test(t)) return 'planning'; if (/\bpush\b|\bmerge\b|\bdeploy\b|\brelease\b|релиз|тегни/.test(t)) return 'release'; if (/рефактор|refactor/.test(t)) return 'refactor'; if (/баг|bug|почини|исправ|fix\b|сломан|broken/.test(t)) return 'bugfix'; if (/фич|feature|добав|implement|реализ|создай|create|новый|new /.test(t)) return 'feature'; if (/докум|readme|\bdocs?\b/.test(t)) return 'docs'; if (/проанализ|анализ|оцени|review|examine|разбор|посмотри что/.test(t)) return 'analysis'; if (/убери|удали|почисть|cleanup|очисти|drop\s/.test(t)) return 'cleanup'; if (/^\s*статус\b|\bstatus\b|проверь состоян|health/.test(t)) return 'monitoring'; if (/\?|как |что |почему|зачем|why|how |what /.test(t)) return 'question'; return 'other'; } function collectToolUse(entries) { const skills = []; const counts = {}; const errors = []; const idToTool = {}; // First pass — build id→tool name map (tool_results may reference tools across messages) for (const e of entries) { const content = e && e.message && Array.isArray(e.message.content) ? e.message.content : []; for (const b of content) { if (b && b.type === 'tool_use') idToTool[b.id] = b.name || 'unknown'; } } // Second pass — accumulate counts + per-error attribution for (const e of entries) { const content = e && e.message && Array.isArray(e.message.content) ? e.message.content : []; for (const block of content) { if (!block || typeof block !== 'object') continue; if (block.type === 'tool_use') { const name = block.name || 'unknown'; counts[name] = (counts[name] || 0) + 1; if (name === 'Skill') { skills.push((block.input && block.input.skill) || 'unknown'); } } else if (block.type === 'tool_result' && block.is_error === true) { const tool = idToTool[block.tool_use_id] || 'unknown'; const c = block.content; const text = typeof c === 'string' ? c : (Array.isArray(c) ? c.map((b) => (b && typeof b.text === 'string') ? b.text : '').join(' ') : ''); errors.push({ tool, summary: text.slice(0, 80) }); } } } return { skills, counts, errors }; } const FILE_TOOLS = new Set(['Read', 'Edit', 'Write', 'MultiEdit', 'NotebookEdit']); /** * Deterministic environment factors for the turn that starts at turnStartIdx. * economy_level / parallel_session are scanned from the stringified turn; * model / post_compaction / session_turn from structural fields. */ export function extractEnvironment(allEntries, turnStartIdx) { const turn = allEntries.slice(turnStartIdx); const rawTurn = JSON.stringify(turn); const econ = rawTurn.match(/=== ECONOMY MODE:\s*(\d+)\s*%/); const economy_level = econ ? Number(econ[1]) : null; let model = null; for (const e of turn) { if (e && e.message && e.message.model) { model = e.message.model; break; } } // The transcript file accumulates duplicated context-rebuild snapshots // (repeated isCompactSummary entries — see feedback_environment quirk #101). // Counting prompts from i=0 inflates session_turn with those dupes. Count // from the LAST compaction before the turn: session_turn = real prompts // since it, which is monotonic ("turns since last compaction"). let lastCompactIdx = -1; for (let i = 0; i < turnStartIdx && i < allEntries.length; i++) { if (allEntries[i] && allEntries[i].isCompactSummary === true) lastCompactIdx = i; } const post_compaction = lastCompactIdx >= 0; let session_turn = 0; for (let i = lastCompactIdx + 1; i <= turnStartIdx && i < allEntries.length; i++) { if (isRealUserPrompt(allEntries[i])) session_turn += 1; } // Only strong collision evidence — a bare mention of "parallel sessions" is // not a signal (best-effort per spec R2; prefer false-negative over false-positive). // Scope NARROWED to tool_result content (real command output / Bash stderr): prose // mentions in user prompts / assistant text — including analysis text that // references collision phrases — must not trigger. Fixes live FP (episode line 20). const parallel_session = /чужой staged|foreign git index|index\.lock|another git process/i.test(collectToolResultText(turn)) || hasPreFlightFetch(turn); return { economy_level, model, post_compaction, session_turn, parallel_session }; } /** * Pravila §15.2 pre-flight signal (Task 13 PIVOT): Bash-команда turn'а * содержит `git fetch ... && git log HEAD..origin/main ...` — это hard-rule * pre-flight sync перед правкой нормативки в параллельных сессиях. Сильный * сигнал «заказчик ожидает параллельных сессий», аддитивный к F1 collision * detector (parallel_session). Не overwrite — OR-clause. */ function hasPreFlightFetch(turn) { for (const e of turn || []) { const content = e && e.message && Array.isArray(e.message.content) ? e.message.content : []; for (const b of content) { if (b && b.type === 'tool_use' && b.name === 'Bash' && b.input) { const cmd = String(b.input.command || ''); if (/git\s+fetch[^|&;]*&&[^|&;]*git\s+log\s+HEAD\.\.origin\//i.test(cmd)) return true; } } } return false; } /** * Collect text content from tool_result blocks in the turn — the only surface * trusted for parallel_session collision evidence (see extractEnvironment). * Supports both string content and the structured array form * (`content: [{ type: 'text', text }]`). */ function collectToolResultText(turn) { const parts = []; for (const e of turn) { const content = e && e.message && Array.isArray(e.message.content) ? e.message.content : []; for (const b of content) { if (!b || b.type !== 'tool_result') continue; const c = b.content; if (typeof c === 'string') { parts.push(c); } else if (Array.isArray(c)) { for (const sub of c) { if (sub && typeof sub.text === 'string') parts.push(sub.text); } } } } return parts.join('\n'); } // Pass 3 — path-pattern classifier (project-brain-factor-analysis-4passes). // Returns one of: test / config / spec / norm / data / src / other. // Priority order matters (test before src, norm before src, etc). // // Greenfield #3-observer: project-normative stems are config-driven (design // 2026-06-16-greenfield-regex-names-config-design §5). Default = the three Лидерра // stems → byte-identical to the previous hardcoded patterns; a greenfield project // supplies its own via loadConfig().normative_files → docStem (wired in observer-stop-hook). export const DEFAULT_NORMATIVE_STEMS = Object.freeze(['Pravila_raboty_Claude', 'Plugin_stack_rules', 'Tooling']); const _escRe = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); export function classifyFilePath(path, normativeStems = DEFAULT_NORMATIVE_STEMS) { if (!path) return 'other'; const p = String(path).replace(/\\/g, '/'); const base = p.split('/').pop() || p; // 1. tests if (/\.(?:test|spec)\.[a-z0-9]+$/i.test(base)) return 'test'; if (/(?:^|\/)(?:tests?|spec)\//i.test(p)) return 'test'; // 2. normative documents. Universal docs (CLAUDE.md / Открытые_вопросы / MEMORY.md / memory // store) stay hardcoded; project-normative docs match config stems (default = Лидерра quintet). if (/(?:^|\/)CLAUDE\.md$/i.test(p)) return 'norm'; for (const stem of (normativeStems || [])) { if (stem && new RegExp(`(?:^|/)${_escRe(stem)}[^/]*\\.md$`, 'i').test(p)) return 'norm'; } if (/(?:^|\/)Открытые_вопросы[^/]*\.md$/i.test(p)) return 'norm'; if (/(?:^|\/)MEMORY\.md$/i.test(p)) return 'norm'; if (/\/memory\/[^/]+\.md$/i.test(p)) return 'norm'; // 3. spec / plan if (/(?:^|\/)docs\/superpowers\/(?:specs|plans)\//i.test(p)) return 'spec'; // 4. config if (/(?:^|\/)\.env(?:\.|$)/i.test(p)) return 'config'; if (/(?:^|\/)(?:package|composer|tsconfig)\.json$/i.test(base)) return 'config'; if (/\.config\.[a-z0-9]+$/i.test(base)) return 'config'; if (/(?:^|\/)(?:lefthook|\.eslintrc|cspell|stylelint|prettier|pint)[^/]*\.(?:yml|yaml|json|cjs|mjs|js|toml)$/i.test(p)) return 'config'; // 5. data if (/\.(?:jsonl|csv|sql|sqlite)$/i.test(base)) return 'data'; // 6. src if (/(?:^|\/)(?:app|tools|resources|src|lib|db\/migrations)\//i.test(p)) return 'src'; return 'other'; } const FILE_TYPE_CATEGORIES = ['src', 'test', 'config', 'spec', 'norm', 'data', 'other']; export function extractFileTypeDistribution(files, normativeStems = DEFAULT_NORMATIVE_STEMS) { const dist = Object.fromEntries(FILE_TYPE_CATEGORIES.map((c) => [c, 0])); for (const f of files || []) { dist[classifyFilePath(f, normativeStems)] += 1; } return dist; } // Pass 3 — MCP server fingerprint. tool_use[].name follows // `mcp____` where may itself contain single underscores // (e.g. mcp__plugin_brand-voice_box__authenticate). Non-greedy match stops at // the FIRST `__` after the prefix so multi-word server names land whole. export function extractMcpServers(turn) { const servers = new Set(); for (const e of turn || []) { const content = e && e.message && Array.isArray(e.message.content) ? e.message.content : []; for (const b of content) { if (b && b.type === 'tool_use' && typeof b.name === 'string') { const m = b.name.match(/^mcp__(.+?)__/); if (m) servers.add(m[1]); } } } return [...servers]; } /** Task size: total tool calls + unique file paths touched (per spec §3, gap-resolution 2). */ export function extractTaskSize(turn) { let tool_calls = 0; const files = new Set(); for (const e of turn) { const content = e && e.message && Array.isArray(e.message.content) ? e.message.content : []; for (const b of content) { if (b && b.type === 'tool_use') { tool_calls += 1; if (FILE_TOOLS.has(b.name) && b.input) { const p = b.input.file_path || b.input.notebook_path; if (p) files.add(String(p)); } } } } return { tool_calls, files_touched: files.size, files: [...files] }; } /** * Token-usage aggregation across all assistant messages in the turn. * * DESIGN: returns zero-filled object (NOT null) when no `usage` data was * captured. Consumers cannot currently distinguish "actually 0 tokens" from * "no usage data" — accepted trade-off because (a) every assistant message * in real Claude Code transcripts has `usage` (verified B1 brain-retro * 2026-05-20: 6265/6265 messages with usage, 0 partial-stream), and * (b) `task_cost` is not yet read by analyzer/STATUS.md, so the semantic * gap is a future-only concern. Re-evaluate when factor matrix adds cost. * * Captures: 4 base token fields + `iterations` (extended-thinking detector) * + `server_tool_use.{web_search,web_fetch}_requests` counts. * Other usage fields (cache_creation object, inference_geo, service_tier, * speed) — out-of-scope for current analyzer. * * Defensive: skips entries where `usage` is not a plain object (handles * malformed transcript edge cases like `"usage": 42`). */ // Normalize `usage.iterations` to a count. // Claude Code transcripts may emit it as: a number (legacy / no extended-thinking), // an array of step-objects (extended-thinking turns), or a plain object map. // Coerce to a number; non-finite / unknown → 0. Prevents "0[object Object]…" // string concatenation that previously poisoned task_cost.iterations. function iterationsCount(v) { if (typeof v === 'number' && Number.isFinite(v)) return v; if (Array.isArray(v)) return v.length; if (v && typeof v === 'object') return Object.keys(v).length; return 0; } export function extractTokenUsage(turn) { let input = 0, output = 0, cache_read = 0, cache_creation = 0; let web_search = 0, web_fetch = 0, iterations = 0; for (const e of turn || []) { const u = e && e.message && e.message.usage; if (!u || typeof u !== 'object') continue; input += u.input_tokens || 0; output += u.output_tokens || 0; cache_read += u.cache_read_input_tokens || 0; cache_creation += u.cache_creation_input_tokens || 0; iterations += iterationsCount(u.iterations); if (u.server_tool_use) { web_search += u.server_tool_use.web_search_requests || 0; web_fetch += u.server_tool_use.web_fetch_requests || 0; } } return { input_tokens: input, output_tokens: output, cache_read_input_tokens: cache_read, cache_creation_input_tokens: cache_creation, web_search_requests: web_search, web_fetch_requests: web_fetch, iterations, // v4.3 LLM-agent cost fields — always zero at parse time; // populated retroactively by controller scripts / reviewer response. classifier_input_tokens: 0, classifier_output_tokens: 0, self_assessment_input_tokens: 0, self_assessment_output_tokens: 0, reviewer_input_tokens: 0, reviewer_output_tokens: 0, reviewer_subagent_usd: 0, reviewer_direct_fallback_usd: 0, }; } /** * For each AskUserQuestion toolUseResult in the turn, emit one event per question. * answer_kind: 'option' (exact label match), 'custom' (free-text), 'no_answer' (missing/empty). */ /** Collect concatenated text from all assistant text blocks in the turn. */ function assistantTextOfTurn(turn) { const parts = []; for (const e of turn || []) { if (!e || !e.message || e.message.role !== 'assistant') continue; const content = Array.isArray(e.message.content) ? e.message.content : []; for (const b of content) { if (b && b.type === 'text' && typeof b.text === 'string') parts.push(b.text); } } return parts.join('\n'); } 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, ]; /** Heuristic triggers from assistant text. Conservative-broad — false positives OK. */ export function extractTriggers(turn) { const text = assistantTextOfTurn(turn); const out = new Set(); for (const re of TRIGGER_PATTERNS) { const matches = text.match(re); if (matches) for (const m of matches) { const norm = /^L\d+\s+chain$/.test(m) ? `routing-off-phase ${m.split(/\s+/)[0]}` : m; out.add(norm); } } return [...out]; } const CANDIDATE_NUMBERED_RE = /^\s*\d+[.\)]\s+([^\n]+)$/gm; const CANDIDATE_BULLET_RE = /^\s*[-*]\s+([^\n]+)$/gm; /** * Heuristic candidates: ≥2 numbered (preferred) or bulleted items, filtered to * router-node identifiers (see isKnownNode). Free-form prose bullets are * rejected — they belong in the assistant's narrative, not in * primary_rationale.candidates_considered. The opt-in tag * (parseReasoningTag) bypasses this filter; that channel is authoritative. */ export function extractCandidates(turn) { const text = assistantTextOfTurn(turn); const numbered = [...text.matchAll(CANDIDATE_NUMBERED_RE)].map((m) => m[1].trim()); if (numbered.length >= 2) { const filtered = numbered.map(normalizeCandidate).filter(isKnownNode); if (filtered.length >= 2) return filtered; } const bulleted = [...text.matchAll(CANDIDATE_BULLET_RE)].map((m) => m[1].trim()); if (bulleted.length >= 2) { const filtered = bulleted.map(normalizeCandidate).filter(isKnownNode); if (filtered.length >= 2) return filtered; } return []; } const BOUNDARY_PATTERNS = [ /\bADR-\d+(?:\s+§\d+(?:\.\d+)?)?/g, /\bPSR_v1\s+R\d+(?:\.\d+)?/g, /\bPravila\s+§\d+(?:\.\d+)?/g, /\brouting-off-phase\s+L\d+/g, /\bL\d+\s+chain/g, ]; /** Heuristic boundaries — overlaps with triggers, dedup per-array only. */ export function extractBoundaries(turn) { const text = assistantTextOfTurn(turn); const out = new Set(); for (const re of BOUNDARY_PATTERNS) { const matches = text.match(re); if (matches) for (const m of matches) { const norm = /^L\d+\s+chain$/.test(m) ? `routing-off-phase ${m.split(/\s+/)[0]}` : m; out.add(norm); } } return [...out]; } export function extractAskUserQuestionEvents(turn) { const events = []; for (const e of turn || []) { const tur = e && e.toolUseResult; if (!tur || !Array.isArray(tur.questions) || !tur.answers) continue; const qCount = tur.questions.length; for (const q of tur.questions) { const labels = (q.options || []).map((o) => o && o.label).filter((l) => typeof l === 'string'); const answer = tur.answers[q.question]; let answer_kind; if (typeof answer !== 'string' || answer.length === 0) answer_kind = 'no_answer'; else if (labels.some((l) => l.trim() === answer.trim())) answer_kind = 'option'; else answer_kind = 'custom'; events.push({ kind: 'ask_user_question', question_count: qCount, answer_kind }); } } return events; } /** Classify the opening user-prompt sentiment (per spec §6 / gap-resolution 1). */ export function classifyPromptSignal(text) { const t = String(text || '').toLowerCase().trim(); if ( /не совсем|другое|другая|не сходится|wrong direction|не то\b|не так\b|переделай|отбой|\bстоп\b|почему ты|неверно|не верно|это не |не работает|не правильн|сломал|опять|снова не|всё ещё|все ещё|все еще|верни как|откат|\brevert\b|\bundo\b|still not|doesn'?t work|does not work|\bwrong\b/.test( t ) ) { return 'correction'; } if (/^(ок|окей|ok|спасибо|супер|отлично|готово|дальше|идеально|класс|хорошо|принято|well done|\bnice\b)([,\s]|$)/.test(t)) { return 'approval'; } if (/^(?:теперь|далее|следующее)(?=\s|[,.!?:;]|$)|^next\b|^now\b/.test(t)) return 'new_task'; if (classifyTask(t) !== 'other' && t.length > 15) return 'new_task'; return 'neutral'; } const TIME_BURN_THRESHOLD_MS = 900000; // 15 min — turn wall-clock above this = time_burn const PARSE_GAP_RATIO = 0.1; // >10% unparseable lines = parse_gap /** Heuristic retry count: an errored tool whose name is used again later in the turn. */ function detectRetries(turn) { const idToName = {}; const uses = []; turn.forEach((entry, idx) => { const content = entry && entry.message && Array.isArray(entry.message.content) ? entry.message.content : []; for (const b of content) { if (b && b.type === 'tool_use') { idToName[b.id] = b.name; uses.push({ name: b.name, idx }); } } }); const errors = []; turn.forEach((entry, idx) => { const content = entry && entry.message && Array.isArray(entry.message.content) ? entry.message.content : []; for (const b of content) { if (b && b.type === 'tool_result' && b.is_error === true) { errors.push({ name: idToName[b.tool_use_id] || null, idx }); } } }); let retries = 0; for (const err of errors) { if (err.name && uses.some((u) => u.name === err.name && u.idx > err.idx)) retries += 1; } return retries; } /** * Process events for the turn: hook_fired (summary), interrupt, retry, * time_burn, parse_gap. broken/total/durationMs are computed by the caller. */ export function extractProcessEvents(turn, broken, total, durationMs) { const events = []; const hookCounts = {}; let hookErrors = 0; for (const e of turn) { const att = e && e.attachment; if (att && (att.type === 'hook_success' || att.type === 'hook_error')) { const name = att.hookName || 'unknown'; hookCounts[name] = (hookCounts[name] || 0) + 1; if (att.type === 'hook_error') hookErrors += 1; } } if (Object.keys(hookCounts).length > 0) { const scripts = resolveScriptCounts(hookCounts, getHookMap()); events.push({ kind: 'hook_fired', counts: hookCounts, scripts, errors: hookErrors }); } for (const e of turn) { const content = e && e.message && Array.isArray(e.message.content) ? e.message.content : []; const isUser = e && e.message && e.message.role === 'user'; if ( isUser && content.some((b) => b && b.type === 'text' && String(b.text || '').includes('[Request interrupted by user]')) ) { events.push({ kind: 'interrupt' }); } } const retries = detectRetries(turn); for (let i = 0; i < retries; i++) events.push({ kind: 'retry' }); if (durationMs > TIME_BURN_THRESHOLD_MS) { events.push({ kind: 'time_burn', duration_ms: durationMs }); } if (total > 0 && broken / total > PARSE_GAP_RATIO) { events.push({ kind: 'parse_gap', broken, total }); } // unrecovered_error: emitted iff the LAST tool_result in the turn was // is_error=true. Distinguishes "turn ended on failure" from "errors that // were retried away" (e.g., TDD red→green, expected-fail commands). The // analyzer uses this event to flag `blocked` instead of raw error/retry // count — see brain-retro-analyzer.inferOutcome (A-1 fix). let lastToolResultIsError = null; outer: for (let i = turn.length - 1; i >= 0; i--) { const content = turn[i] && turn[i].message && Array.isArray(turn[i].message.content) ? turn[i].message.content : []; for (let j = content.length - 1; j >= 0; j--) { const b = content[j]; if (b && b.type === 'tool_result') { lastToolResultIsError = b.is_error === true; break outer; } } } if (lastToolResultIsError === true) { events.push({ kind: 'unrecovered_error' }); } return events; } const ROUTING_TAG_RE = //; /** Find the routing tag Claude prints when a method was user-directed (spec §4.2). */ export function parseRoutingTag(turn) { for (const e of turn) { const content = e && e.message && Array.isArray(e.message.content) ? e.message.content : []; for (const b of content) { if (b && b.type === 'text' && typeof b.text === 'string') { const m = b.text.match(ROUTING_TAG_RE); if (m) return { kind: m[1], node: m[2], claude_would_have_chosen: m[3] }; } } } return null; } /** * Per-Agent-tool_use event (Task 12) — surfaces subagent dispatches in the * episode `events[]`. Captures subagent_type / model (if explicit in input) * / first 80 chars of description. * * Not the full subagent trace (that lives in ~/.claude/projects/.../subagents/); * just visibility from the parent Claude's perspective. */ export function extractAgentInvocations(turn) { const out = []; for (const e of turn || []) { const content = e && e.message && Array.isArray(e.message.content) ? e.message.content : []; for (const b of content) { if (b && b.type === 'tool_use' && b.name === 'Agent') { const inp = b.input || {}; out.push({ kind: 'subagent_invoked', subagent_type: inp.subagent_type || 'unknown', model: inp.model || null, description: typeof inp.description === 'string' ? inp.description.slice(0, 80) : '', }); } } } return out; } const REASONING_TAG_RE = //; /** * Opt-in reasoning tag (Task 11). Claude may emit at most one such comment * per turn to declare triggers / candidates / boundaries explicitly. Values * are semicolon-separated. When present, parser merges them into the * heuristic-derived arrays via Set-dedupe. */ export function parseReasoningTag(turn) { for (const e of turn || []) { const content = e && e.message && Array.isArray(e.message.content) ? e.message.content : []; for (const b of content) { if (b && b.type === 'text' && typeof b.text === 'string') { const m = b.text.match(REASONING_TAG_RE); if (m) { const split = (s) => s.split(';').map((x) => x.trim()).filter(Boolean); return { triggers: split(m[1]), candidates: split(m[2]), boundaries: split(m[3]) }; } } } } return null; } /** Text of the last real user prompt — used by the Stop-hook routing-gate (Task 5). */ export function extractLastUserPromptText(transcriptText) { const { entries } = parseLines(transcriptText); const start = findTurnStart(entries); return promptText(entries[start]); } /** * Content of the last assistant message strictly before the turn start — * the message that may have offered options to the user (spec §11.5). */ function extractLastAssistantContent(entries, turnStartIdx) { for (let i = turnStartIdx - 1; i >= 0; i--) { const e = entries[i]; if (e && e.message && e.message.role === 'assistant') { const content = e.message.content; if (Array.isArray(content)) return content; if (typeof content === 'string') return content; } } return null; } /** * Parse a transcript JSONL string into an observer episode (schema v2). * @param {string} transcriptText - Raw JSONL transcript contents. * @param {string|null} fallbackSessionId - Used when the transcript has no sessionId. * @returns {object} v2 episode. */ export function parseTranscript(transcriptText, fallbackSessionId = null, options = {}) { const { entries, broken, total } = parseLines(transcriptText); const withSession = entries.find((e) => e && e.sessionId); const sessionId = (withSession && withSession.sessionId) || fallbackSessionId || `unknown-${Date.now()}`; const routerStateBaseDir = options.routerStateBaseDir; const routerState = readRouterState(sessionId, routerStateBaseDir ? { baseDir: routerStateBaseDir } : {}); const routerFields = extractRouterFields(routerState); const start = findTurnStart(entries); const turn = entries.slice(start); const stamps = turn.map((e) => e && e.timestamp).filter(Boolean); const started_at = stamps[0] || new Date().toISOString(); const ended_at = stamps[stamps.length - 1] || started_at; const durationMs = new Date(ended_at) - new Date(started_at); const _v4 = extractV4Signals(sessionId, { startMs: new Date(started_at).getTime(), endMs: new Date(ended_at).getTime(), baseDir: options.runtimeBaseDir || routerStateBaseDir, }); const { skills, counts, errors } = collectToolUse(turn); const events = []; for (const skill of skills) events.push({ kind: 'skill_invoked', skill }); if (Object.keys(counts).length > 0) events.push({ kind: 'tool_summary', counts }); for (const err of errors) { events.push({ kind: 'error', tool: err.tool, summary: err.summary }); } events.push(...extractProcessEvents(turn, broken, total, durationMs)); events.push(...extractAskUserQuestionEvents(turn)); events.push(...extractAgentInvocations(turn)); const usedSuperpowers = skills.some((s) => String(s).startsWith(SUPERPOWERS_PREFIX)); const prompt = promptText(entries[start]); const lastAsstContent = extractLastAssistantContent(entries, start); const choice = detectChoiceProvenance(prompt, lastAsstContent) || detectAskUserQuestionChoice(turn); let decision_provenance; if (choice) { decision_provenance = choice; } else { const tag = parseRoutingTag(turn); decision_provenance = tag && tag.kind === 'user_directed_method' ? { kind: 'user_directed_method', claude_would_have_chosen: tag.claude_would_have_chosen } : { kind: 'autonomous', claude_would_have_chosen: null }; } // Phase 2 Task 15 — schema v4.0. Adds classifier_output (LLM-first decision // record), degraded_mode (LLM→regex fallback flag), and // environment.classifier_model. Phase 3 (Tasks 16-20) will bump schema_minor // for execution_trace, self_assessment, embedding etc. const _state = readRouterState(sessionId); const _classifierOutput = extractClassifierOutput(_state); const _degraded = _state?.classification?.degraded === true; const _envBase = extractEnvironment(entries, start); const _classifierModel = _classifierOutput?.source === 'llm' ? CLASSIFIER_MODEL : null; return { schema_version: 4, schema_minor: 4, task_id: sessionId, task_ref: sessionId, timestamps: { started_at, ended_at }, path_type: usedSuperpowers ? 'regulated' : 'improvised', outcome: 'unknown', // v4.3: reviewed outcome — always null at write time, filled by /brain-retro reviewer. outcome_reviewed: null, outcome_reviewed_source: null, // v4.3: embedding of first user prompt — null at parse time (sync parser cannot // await model load); populated asynchronously by the Stop-hook after parseTranscript. prompt_embedding_base64: null, prompt_signal: classifyPromptSignal(prompt), decision_provenance, environment: { ..._envBase, classifier_model: _classifierModel }, task_size: extractTaskSize(turn), // A1 (2026-05-26): merge router-state.task_cost (classifier LLM tokens) on top of // extractTokenUsage (assistant per-turn tokens). State-file fields win for the // classifier_/self_assessment_/reviewer_ block; assistant input_tokens/output_tokens // come from extractTokenUsage and stay intact. // NB: routerState (line 855) honours routerStateBaseDir option; _state at line 898 // does not (always default dir). Use routerState here so tests with custom temp dir // see the merged values. task_cost: { ...extractTokenUsage(turn), ...((routerState && routerState.task_cost) || {}), judge_spend_usd: _v4.judge_calls * JUDGE_PER_CALL_USD, }, v4_signals: _v4, // Pass 3 — dynamics meta-block (project-brain-factor-analysis-4passes). // prompt_length_chars: strlen of first user prompt (engagement / clarity proxy). // mcp_servers_used: unique mcp____* fingerprints in this turn. // file_type_distribution: per-bucket counts of unique paths touched. task_meta: (() => { const ts = extractTaskSize(turn); return { prompt_length_chars: typeof prompt === 'string' ? prompt.length : 0, mcp_servers_used: extractMcpServers(turn), file_type_distribution: extractFileTypeDistribution(ts.files, options.normativeStems), }; })(), classifier_output: _classifierOutput, degraded_mode: _degraded, primary_rationale: (() => { const tag = parseReasoningTag(turn); const merge = (heur, fromTag) => [...new Set([...heur, ...fromTag])]; // recommended_node ОБЯЗАН отражать реальный сигнал классификатора: // - routerFields.recommended_node (state-файл от LLM / regex fallback / prefilter inherited), // - либо null, если классификатор не сработал. // Старый classifMapNode fallback на keyword-regex (`recommendNode(classifyTask, ...)`) запекал // ложные «рекомендации» в episode JSONL — brain-retro #6 показала 60-70% false-positive в анализе // «direct_ignored_rec». Убрано 2026-05-26. return { step: 1, node_chosen: skills.length > 0 ? skills[0] : 'direct', chain_ref: chainsFor(skills.length > 0 ? skills[0] : 'direct', CHAIN_MAP), triggers_matched: merge(extractTriggers(turn), tag ? tag.triggers : []), candidates_considered: merge(extractCandidates(turn), tag ? tag.candidates : []), boundaries_applied: merge(extractBoundaries(turn), tag ? tag.boundaries : []), hard_floor: usedSuperpowers ? { invoked: true, rules: ['Pravila §12'] } : { invoked: false, rules: [] }, task_classification: classifyTask(prompt), recommended_node: routerFields.recommended_node, recommended_chain: routerFields.recommended_chain, chain_progress: routerFields.chain_progress, chain_completed: routerFields.chain_completed, }; })(), events, }; }