Files
brain/tools/observer-transcript-parser.mjs
T

980 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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:',
'<local-command-stdout>',
'<local-command-caveat>',
'<command-name>',
'[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(/<system-reminder>[\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__<server>__<tool>` where <server> 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 <!-- reasoning --> 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 =
/<!--\s*routing:\s*provenance=([\w_]+)\s+node=(\S+)\s+counterfactual=(\S+)\s*-->/;
/** 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 =
/<!--\s*reasoning:\s*triggers="([^"]*)"\s+candidates="([^"]*)"\s+boundaries="([^"]*)"\s*-->/;
/**
* 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__<server>__* 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,
};
}