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

1969 lines
84 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.
import { describe, it, expect } from 'vitest';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
parseTranscript,
extractEnvironment,
extractTaskSize,
classifyPromptSignal,
extractProcessEvents,
parseRoutingTag,
extractLastUserPromptText,
classifyTask,
extractTokenUsage,
extractMcpServers,
extractFileTypeDistribution,
classifyFilePath,
} from './observer-transcript-parser.mjs';
describe('v4_signals + judge_spend_usd in episode', () => {
it('episode carries v4_signals block (default zero/null when no runtime files)', () => {
const ep = parseTranscript(JSON.stringify({
sessionId: 'no-runtime-sess',
message: { role: 'user', content: 'сделай фичу X' },
timestamp: '2026-05-31T10:00:00.000Z',
}), null, { routerStateBaseDir: tmpdir() });
expect(ep.v4_signals).toEqual({
rationalization_flag_count: 0, judge_verdict: null, safe_baseline_action: null, judge_calls: 0,
});
expect(typeof ep.task_cost.judge_spend_usd).toBe('number');
expect(ep.schema_minor).toBe(4);
});
});
// Build a JSONL transcript string from entry objects.
function jsonl(entries) {
return entries.map((e) => JSON.stringify(e)).join('\n');
}
function userPrompt(text, ts, sessionId = 's1') {
return { type: 'user', message: { role: 'user', content: text }, timestamp: ts, sessionId };
}
function assistantTurn(blocks, ts, sessionId = 's1') {
return { type: 'assistant', message: { role: 'assistant', content: blocks }, timestamp: ts, sessionId };
}
function toolResult(toolUseId, isError, ts, sessionId = 's1') {
return {
type: 'user',
message: {
role: 'user',
content: [{ type: 'tool_result', tool_use_id: toolUseId, content: 'r', is_error: isError }],
},
timestamp: ts,
sessionId,
};
}
describe('parseTranscript', () => {
it('extracts task_id from sessionId in transcript', () => {
const t = jsonl([userPrompt('add a feature', '2026-05-19T10:00:00Z', 'sess-xyz')]);
expect(parseTranscript(t).task_id).toBe('sess-xyz');
});
it('falls back to provided sessionId when transcript has none', () => {
const t = jsonl([
{ type: 'user', message: { role: 'user', content: 'hi' }, timestamp: '2026-05-19T10:00:00Z' },
]);
expect(parseTranscript(t, 'fallback-id').task_id).toBe('fallback-id');
});
it('extracts timestamps from the last turn', () => {
const t = jsonl([
userPrompt('first', '2026-05-19T09:00:00Z'),
userPrompt('second turn', '2026-05-19T10:00:00Z'),
assistantTurn([{ type: 'text', text: 'done' }], '2026-05-19T10:05:00Z'),
]);
const ep = parseTranscript(t);
expect(ep.timestamps.started_at).toBe('2026-05-19T10:00:00Z');
expect(ep.timestamps.ended_at).toBe('2026-05-19T10:05:00Z');
});
it('emits skill_invoked events for Skill tool_use', () => {
const t = jsonl([
userPrompt('do tdd', '2026-05-19T10:00:00Z'),
assistantTurn(
[{ type: 'tool_use', id: 't1', name: 'Skill', input: { skill: 'superpowers:test-driven-development' } }],
'2026-05-19T10:01:00Z'
),
]);
const skillEvents = parseTranscript(t).events.filter((e) => e.kind === 'skill_invoked');
expect(skillEvents).toHaveLength(1);
expect(skillEvents[0].skill).toBe('superpowers:test-driven-development');
});
it('emits a tool_summary event with per-tool counts', () => {
const t = jsonl([
userPrompt('work', '2026-05-19T10:00:00Z'),
assistantTurn(
[
{ type: 'tool_use', id: 't1', name: 'Read', input: {} },
{ type: 'tool_use', id: 't2', name: 'Read', input: {} },
{ type: 'tool_use', id: 't3', name: 'Bash', input: {} },
],
'2026-05-19T10:01:00Z'
),
]);
const summary = parseTranscript(t).events.find((e) => e.kind === 'tool_summary');
expect(summary.counts).toEqual({ Read: 2, Bash: 1 });
});
it('node_chosen is the first skill invoked', () => {
const t = jsonl([
userPrompt('go', '2026-05-19T10:00:00Z'),
assistantTurn(
[{ type: 'tool_use', id: 't1', name: 'Skill', input: { skill: 'superpowers:brainstorming' } }],
'2026-05-19T10:01:00Z'
),
]);
expect(parseTranscript(t).primary_rationale.node_chosen).toBe('superpowers:brainstorming');
});
it('node_chosen is "direct" when no skill invoked', () => {
const t = jsonl([
userPrompt('go', '2026-05-19T10:00:00Z'),
assistantTurn([{ type: 'tool_use', id: 't1', name: 'Read', input: {} }], '2026-05-19T10:01:00Z'),
]);
expect(parseTranscript(t).primary_rationale.node_chosen).toBe('direct');
});
it('attaches chain_ref for a node that belongs to a chain', () => {
const t = jsonl([
userPrompt('go', '2026-05-19T10:00:00Z'),
assistantTurn(
[{ type: 'tool_use', id: 't1', name: 'Skill', input: { skill: 'billing-audit' } }],
'2026-05-19T10:01:00Z'
),
]);
expect(parseTranscript(t).primary_rationale.chain_ref).toEqual(['L13']);
});
it('sets chain_ref null for a direct episode', () => {
const t = jsonl([
userPrompt('go', '2026-05-19T10:00:00Z'),
assistantTurn([{ type: 'tool_use', id: 't1', name: 'Read', input: {} }], '2026-05-19T10:01:00Z'),
]);
expect(parseTranscript(t).primary_rationale.chain_ref).toBeNull();
});
it('hard_floor invoked when a superpowers skill is used', () => {
const t = jsonl([
userPrompt('go', '2026-05-19T10:00:00Z'),
assistantTurn(
[{ type: 'tool_use', id: 't1', name: 'Skill', input: { skill: 'superpowers:writing-plans' } }],
'2026-05-19T10:01:00Z'
),
]);
const hf = parseTranscript(t).primary_rationale.hard_floor;
expect(hf.invoked).toBe(true);
expect(hf.rules).toContain('Pravila §12');
});
it('hard_floor not invoked for a non-superpowers skill', () => {
const t = jsonl([
userPrompt('go', '2026-05-19T10:00:00Z'),
assistantTurn(
[{ type: 'tool_use', id: 't1', name: 'Skill', input: { skill: 'claude-md-management:claude-md-improver' } }],
'2026-05-19T10:01:00Z'
),
]);
expect(parseTranscript(t).primary_rationale.hard_floor.invoked).toBe(false);
});
it('classifies task from the user prompt text', () => {
const cls = (text) =>
parseTranscript(jsonl([userPrompt(text, '2026-05-19T10:00:00Z')])).primary_rationale.task_classification;
expect(cls('почини баг в логине')).toBe('bugfix');
expect(cls('добавь новую фичу экспорта')).toBe('feature');
expect(cls('отрефактори этот модуль')).toBe('refactor');
expect(cls('как это работает?')).toBe('question');
expect(cls('обнови документацию readme')).toBe('docs');
});
it('scopes to the last turn — earlier turns are excluded', () => {
const t = jsonl([
userPrompt('turn 1', '2026-05-19T09:00:00Z'),
assistantTurn(
[{ type: 'tool_use', id: 't1', name: 'Skill', input: { skill: 'superpowers:brainstorming' } }],
'2026-05-19T09:01:00Z'
),
userPrompt('turn 2', '2026-05-19T10:00:00Z'),
assistantTurn([{ type: 'tool_use', id: 't2', name: 'Read', input: {} }], '2026-05-19T10:01:00Z'),
]);
const ep = parseTranscript(t);
expect(ep.events.filter((e) => e.kind === 'skill_invoked')).toHaveLength(0);
expect(ep.primary_rationale.node_chosen).toBe('direct');
});
it('does not treat a tool_result message as a turn boundary', () => {
const t = jsonl([
userPrompt('the only real prompt', '2026-05-19T10:00:00Z'),
assistantTurn([{ type: 'tool_use', id: 't1', name: 'Bash', input: {} }], '2026-05-19T10:01:00Z'),
toolResult('t1', false, '2026-05-19T10:02:00Z'),
assistantTurn([{ type: 'tool_use', id: 't2', name: 'Edit', input: {} }], '2026-05-19T10:03:00Z'),
]);
const summary = parseTranscript(t).events.find((e) => e.kind === 'tool_summary');
expect(summary.counts).toEqual({ Bash: 1, Edit: 1 });
});
it('emits error events for tool_result with is_error', () => {
const t = jsonl([
userPrompt('go', '2026-05-19T10:00:00Z'),
assistantTurn([{ type: 'tool_use', id: 't1', name: 'Bash', input: {} }], '2026-05-19T10:01:00Z'),
toolResult('t1', true, '2026-05-19T10:02:00Z'),
]);
const errs = parseTranscript(t).events.filter((e) => e.kind === 'error');
expect(errs).toHaveLength(1);
expect(errs[0].tool).toBe('Bash');
expect(errs[0].summary).toBe('r');
});
it('skips broken JSONL lines without throwing', () => {
const t = [
JSON.stringify(userPrompt('go', '2026-05-19T10:00:00Z')),
'{ broken json',
JSON.stringify(assistantTurn([{ type: 'tool_use', id: 't1', name: 'Read', input: {} }], '2026-05-19T10:01:00Z')),
].join('\n');
expect(() => parseTranscript(t)).not.toThrow();
expect(parseTranscript(t).events.find((e) => e.kind === 'tool_summary').counts).toEqual({ Read: 1 });
});
it('path_type is regulated with a superpowers skill, improvised otherwise', () => {
const reg = jsonl([
userPrompt('go', '2026-05-19T10:00:00Z'),
assistantTurn(
[{ type: 'tool_use', id: 't1', name: 'Skill', input: { skill: 'superpowers:systematic-debugging' } }],
'2026-05-19T10:01:00Z'
),
]);
const imp = jsonl([
userPrompt('go', '2026-05-19T10:00:00Z'),
assistantTurn([{ type: 'tool_use', id: 't1', name: 'Read', input: {} }], '2026-05-19T10:01:00Z'),
]);
expect(parseTranscript(reg).path_type).toBe('regulated');
expect(parseTranscript(imp).path_type).toBe('improvised');
});
it('returns safe defaults for an empty transcript', () => {
const ep = parseTranscript('');
expect(ep.task_id).toBeTruthy();
expect(ep.primary_rationale.node_chosen).toBe('direct');
expect(ep.events).toEqual([]);
expect(ep.outcome).toBe('unknown');
expect(ep.schema_version).toBe(4);
expect(ep.schema_minor).toBe(4);
});
it('produces a complete 7-field primary_rationale', () => {
const ep = parseTranscript(jsonl([userPrompt('go', '2026-05-19T10:00:00Z')]));
const r = ep.primary_rationale;
for (const f of [
'step',
'node_chosen',
'triggers_matched',
'candidates_considered',
'boundaries_applied',
'hard_floor',
'task_classification',
]) {
expect(r[f]).toBeDefined();
}
});
});
describe('extractEnvironment', () => {
it('reads economy_level from the ECONOMY MODE marker', () => {
const entries = [
userPrompt('=== ECONOMY MODE: 0% (пользователь указал явно) ===\nfix it', '2026-05-19T10:00:00Z'),
];
expect(extractEnvironment(entries, 0).economy_level).toBe(0);
});
it('economy_level is null when no marker present', () => {
const entries = [userPrompt('just do it', '2026-05-19T10:00:00Z')];
expect(extractEnvironment(entries, 0).economy_level).toBeNull();
});
it('reads model from an assistant message', () => {
const entries = [
userPrompt('go', '2026-05-19T10:00:00Z'),
{ type: 'assistant', message: { role: 'assistant', model: 'claude-opus-4-7', content: [] }, timestamp: '2026-05-19T10:01:00Z', sessionId: 's1' },
];
expect(extractEnvironment(entries, 0).model).toBe('claude-opus-4-7');
});
it('post_compaction is true when an isCompactSummary entry precedes the turn', () => {
const entries = [
{ type: 'user', isCompactSummary: true, message: { role: 'user', content: 'summary' }, timestamp: '2026-05-19T09:00:00Z' },
userPrompt('the real turn', '2026-05-19T10:00:00Z'),
];
expect(extractEnvironment(entries, 1).post_compaction).toBe(true);
});
it('post_compaction is false with no compaction marker', () => {
const entries = [userPrompt('turn one', '2026-05-19T09:00:00Z'), userPrompt('turn two', '2026-05-19T10:00:00Z')];
expect(extractEnvironment(entries, 1).post_compaction).toBe(false);
});
it('session_turn counts real user prompts up to and including the turn start', () => {
const entries = [
userPrompt('one', '2026-05-19T09:00:00Z'),
userPrompt('two', '2026-05-19T09:30:00Z'),
userPrompt('three', '2026-05-19T10:00:00Z'),
];
expect(extractEnvironment(entries, 2).session_turn).toBe(3);
});
it('session_turn counts only prompts after the last compaction', () => {
const entries = [
userPrompt('old 1', '2026-05-19T08:00:00Z'),
userPrompt('old 2', '2026-05-19T08:30:00Z'),
{ type: 'user', isCompactSummary: true, message: { role: 'user', content: 'summary' }, timestamp: '2026-05-19T09:00:00Z' },
userPrompt('after 1', '2026-05-19T09:30:00Z'),
userPrompt('after 2 — turn', '2026-05-19T10:00:00Z'),
];
expect(extractEnvironment(entries, 4).session_turn).toBe(2);
});
it('session_turn counts from the LAST compaction when several are present', () => {
const entries = [
{ type: 'user', isCompactSummary: true, message: { role: 'user', content: 's1' }, timestamp: '2026-05-19T08:00:00Z' },
userPrompt('mid 1', '2026-05-19T08:30:00Z'),
{ type: 'user', isCompactSummary: true, message: { role: 'user', content: 's2' }, timestamp: '2026-05-19T09:00:00Z' },
userPrompt('after 1', '2026-05-19T09:30:00Z'),
userPrompt('after 2 — turn', '2026-05-19T10:00:00Z'),
];
expect(extractEnvironment(entries, 4).session_turn).toBe(2);
});
});
describe('extractTaskSize', () => {
it('counts tool calls and unique file paths', () => {
const turn = [
assistantTurn(
[
{ type: 'tool_use', id: 't1', name: 'Read', input: { file_path: '/a.js' } },
{ type: 'tool_use', id: 't2', name: 'Edit', input: { file_path: '/a.js' } },
{ type: 'tool_use', id: 't3', name: 'Write', input: { file_path: '/b.js' } },
{ type: 'tool_use', id: 't4', name: 'Bash', input: {} },
],
'2026-05-19T10:01:00Z'
),
];
const size = extractTaskSize(turn);
expect(size.tool_calls).toBe(4);
expect(size.files_touched).toBe(2);
expect(size.files.sort()).toEqual(['/a.js', '/b.js']);
});
it('returns zeros for an empty turn', () => {
expect(extractTaskSize([])).toEqual({ tool_calls: 0, files_touched: 0, files: [] });
});
});
describe('classifyPromptSignal', () => {
it('detects corrections', () => {
expect(classifyPromptSignal('не то, переделай')).toBe('correction');
expect(classifyPromptSignal('почему ты это сделал')).toBe('correction');
});
it('detects widened correction phrases', () => {
expect(classifyPromptSignal('не работает экспорт')).toBe('correction');
expect(classifyPromptSignal('сломал тесты, верни как было')).toBe('correction');
expect(classifyPromptSignal('опять не та колонка')).toBe('correction');
expect(classifyPromptSignal('всё ещё падает')).toBe('correction');
expect(classifyPromptSignal('откати последнюю правку')).toBe('correction');
expect(classifyPromptSignal('this is still not working')).toBe('correction');
expect(classifyPromptSignal('revert that change')).toBe('correction');
});
it('detects approvals', () => {
expect(classifyPromptSignal('ок, спасибо')).toBe('approval');
expect(classifyPromptSignal('готово, дальше')).toBe('approval');
});
it('detects a new task', () => {
expect(classifyPromptSignal('добавь новую фичу экспорта в CSV')).toBe('new_task');
});
it('falls back to neutral', () => {
expect(classifyPromptSignal('hmm')).toBe('neutral');
});
});
describe('extractProcessEvents', () => {
it('emits a hook_fired summary with per-hook counts and error count', () => {
const turn = [
{ attachment: { type: 'hook_success', hookName: 'PreToolUse:Read' } },
{ attachment: { type: 'hook_success', hookName: 'PreToolUse:Read' } },
{ attachment: { type: 'hook_error', hookName: 'Stop:observer' } },
];
const ev = extractProcessEvents(turn, 0, 0, 0).find((e) => e.kind === 'hook_fired');
expect(ev.counts).toEqual({ 'PreToolUse:Read': 2, 'Stop:observer': 1 });
expect(ev.errors).toBe(1);
});
it('emits an interrupt event for [Request interrupted by user]', () => {
const turn = [
{ message: { role: 'user', content: [{ type: 'text', text: '[Request interrupted by user]' }] } },
];
expect(extractProcessEvents(turn, 0, 0, 0).filter((e) => e.kind === 'interrupt')).toHaveLength(1);
});
it('emits a retry event when an errored tool is used again later', () => {
const turn = [
{ message: { role: 'assistant', content: [{ type: 'tool_use', id: 'u1', name: 'Bash', input: {} }] } },
{ message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'u1', is_error: true }] } },
{ message: { role: 'assistant', content: [{ type: 'tool_use', id: 'u2', name: 'Bash', input: {} }] } },
];
expect(extractProcessEvents(turn, 0, 0, 0).filter((e) => e.kind === 'retry')).toHaveLength(1);
});
it('emits a time_burn event when the turn exceeds the threshold', () => {
const ev = extractProcessEvents([], 0, 0, 1000000).find((e) => e.kind === 'time_burn');
expect(ev.duration_ms).toBe(1000000);
});
it('emits a parse_gap event when the broken-line ratio is above threshold', () => {
const ev = extractProcessEvents([], 3, 10, 0).find((e) => e.kind === 'parse_gap');
expect(ev).toEqual({ kind: 'parse_gap', broken: 3, total: 10 });
});
it('emits nothing for a clean empty turn', () => {
expect(extractProcessEvents([], 0, 0, 0)).toEqual([]);
});
it('emits unrecovered_error when the LAST tool_result in the turn is is_error', () => {
const turn = [
{ message: { role: 'assistant', content: [{ type: 'tool_use', id: 'u1', name: 'Bash', input: {} }] } },
{ message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'u1', is_error: true }] } },
];
expect(extractProcessEvents(turn, 0, 0, 0).filter((e) => e.kind === 'unrecovered_error')).toHaveLength(1);
});
it('does NOT emit unrecovered_error when the turn ends on a successful tool_result', () => {
const turn = [
{ message: { role: 'assistant', content: [{ type: 'tool_use', id: 'u1', name: 'Bash', input: {} }] } },
{ message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'u1', is_error: true }] } },
{ message: { role: 'assistant', content: [{ type: 'tool_use', id: 'u2', name: 'Bash', input: {} }] } },
{ message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'u2', is_error: false }] } },
];
expect(extractProcessEvents(turn, 0, 0, 0).filter((e) => e.kind === 'unrecovered_error')).toHaveLength(0);
});
it('does NOT emit unrecovered_error for a turn with no tool_results at all', () => {
const turn = [
{ message: { role: 'assistant', content: [{ type: 'text', text: 'just talking' }] } },
];
expect(extractProcessEvents(turn, 0, 0, 0).filter((e) => e.kind === 'unrecovered_error')).toHaveLength(0);
});
});
describe('parseRoutingTag', () => {
it('parses a user_directed_method routing tag from assistant text', () => {
const turn = [
assistantTurn(
[{ type: 'text', text: 'ok\n<!-- routing: provenance=user_directed_method node=discovery-interview counterfactual=brainstorming -->' }],
'2026-05-19T10:01:00Z'
),
];
expect(parseRoutingTag(turn)).toEqual({
kind: 'user_directed_method',
node: 'discovery-interview',
claude_would_have_chosen: 'brainstorming',
});
});
it('returns null when no tag is present', () => {
const turn = [assistantTurn([{ type: 'text', text: 'plain answer' }], '2026-05-19T10:01:00Z')];
expect(parseRoutingTag(turn)).toBeNull();
});
});
describe('parseTranscript — v4 episode (Phase 2 Task 15 bump)', () => {
it('produces schema_version 4 and all v2+ fields', () => {
const t = jsonl([
userPrompt('=== ECONOMY MODE: 0% ===\nдобавь фичу', '2026-05-19T10:00:00Z', 'sess-v2'),
assistantTurn([{ type: 'tool_use', id: 't1', name: 'Read', input: { file_path: '/x.js' } }], '2026-05-19T10:01:00Z', 'sess-v2'),
]);
const ep = parseTranscript(t);
expect(ep.schema_version).toBe(4);
expect(ep.schema_minor).toBe(4);
expect('classifier_output' in ep).toBe(true);
expect('degraded_mode' in ep).toBe(true);
expect('classifier_model' in ep.environment).toBe(true);
expect(ep.task_ref).toBe('sess-v2');
expect(ep.outcome).toBe('unknown');
expect(ep.prompt_signal).toBe('new_task');
expect(ep.decision_provenance).toEqual({ kind: 'autonomous', claude_would_have_chosen: null });
expect(ep.environment.economy_level).toBe(0);
expect(ep.task_size).toEqual({ tool_calls: 1, files_touched: 1, files: ['/x.js'] });
});
it('records decision_provenance from a routing tag', () => {
const t = jsonl([
userPrompt('запусти discovery-interview', '2026-05-19T10:00:00Z', 'sess-tag'),
assistantTurn(
[{ type: 'text', text: '<!-- routing: provenance=user_directed_method node=discovery-interview counterfactual=brainstorming -->' }],
'2026-05-19T10:01:00Z',
'sess-tag'
),
]);
const ep = parseTranscript(t);
expect(ep.decision_provenance.kind).toBe('user_directed_method');
expect(ep.decision_provenance.claude_would_have_chosen).toBe('brainstorming');
});
});
describe('extractLastUserPromptText', () => {
it('returns the text of the last real user prompt', () => {
const t = jsonl([
userPrompt('first turn', '2026-05-19T09:00:00Z'),
userPrompt('second and last', '2026-05-19T10:00:00Z'),
]);
expect(extractLastUserPromptText(t)).toBe('second and last');
});
});
describe('parseTranscript — user_chose_from_options', () => {
it('classifies as user_chose_from_options when last assistant offered options and user picked one', () => {
const lines = [
JSON.stringify({
type: 'user',
timestamp: '2026-05-19T10:00:00.000Z',
sessionId: 's1',
message: { role: 'user', content: 'continue task' },
}),
JSON.stringify({
type: 'assistant',
timestamp: '2026-05-19T10:00:01.000Z',
sessionId: 's1',
message: {
role: 'assistant',
content: [{ type: 'text', text: 'Choose:\n1. First option\n2. Second option\n3. Third option' }],
},
}),
JSON.stringify({
type: 'user',
timestamp: '2026-05-19T10:00:10.000Z',
sessionId: 's1',
message: { role: 'user', content: '2 делаем' },
}),
JSON.stringify({
type: 'assistant',
timestamp: '2026-05-19T10:00:11.000Z',
sessionId: 's1',
message: { role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
}),
].join('\n');
const ep = parseTranscript(lines, 'fallback');
expect(ep.decision_provenance).toEqual({
kind: 'user_chose_from_options',
node: 'Second option',
options_offered: ['First option', 'Second option', 'Third option'],
claude_would_have_chosen: 'First option',
});
});
it('falls back to autonomous when last assistant had no options', () => {
const lines = [
JSON.stringify({
type: 'assistant',
timestamp: '2026-05-19T10:00:00.000Z',
sessionId: 's1',
message: { role: 'assistant', content: [{ type: 'text', text: 'just a paragraph' }] },
}),
JSON.stringify({
type: 'user',
timestamp: '2026-05-19T10:00:10.000Z',
sessionId: 's1',
message: { role: 'user', content: '2 делаем' },
}),
JSON.stringify({
type: 'assistant',
timestamp: '2026-05-19T10:00:11.000Z',
sessionId: 's1',
message: { role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
}),
].join('\n');
const ep = parseTranscript(lines, 'fallback');
expect(ep.decision_provenance).toEqual({ kind: 'autonomous', claude_would_have_chosen: null });
});
it('routing-tag user_directed_method preserved when no choice detected', () => {
const lines = [
JSON.stringify({
type: 'user',
timestamp: '2026-05-19T10:00:00.000Z',
sessionId: 's1',
message: { role: 'user', content: 'запусти brainstorming' },
}),
JSON.stringify({
type: 'assistant',
timestamp: '2026-05-19T10:00:01.000Z',
sessionId: 's1',
message: {
role: 'assistant',
content: [{ type: 'text', text: 'ok\n<!-- routing: provenance=user_directed_method node=brainstorming counterfactual=writing-plans -->' }],
},
}),
].join('\n');
const ep = parseTranscript(lines, 'fallback');
expect(ep.decision_provenance.kind).toBe('user_directed_method');
});
});
describe('parseTranscript — synthetic user messages skipped', () => {
it('captures economy_level from genuine prompt even when skill-content message follows', () => {
const lines = [
JSON.stringify({
type: 'user',
timestamp: '2026-05-19T10:00:00.000Z',
sessionId: 's1',
message: { role: 'user', content: 'почини баг в парсере' },
}),
JSON.stringify({
type: 'attachment',
timestamp: '2026-05-19T10:00:00.500Z',
sessionId: 's1',
attachment: {
type: 'hook_additional_context',
hookName: 'UserPromptSubmit',
content: ['=== ECONOMY MODE: 5% (тест) ===\nинструкции режима...'],
},
}),
JSON.stringify({
type: 'assistant',
timestamp: '2026-05-19T10:00:01.000Z',
sessionId: 's1',
message: { role: 'assistant', content: [{ type: 'text', text: 'смотрю' }] },
}),
JSON.stringify({
type: 'user',
timestamp: '2026-05-19T10:00:02.000Z',
sessionId: 's1',
message: { role: 'user', content: 'Base directory for this skill: C:\\path\\skill\n\n# Some Skill\nbody' },
}),
JSON.stringify({
type: 'assistant',
timestamp: '2026-05-19T10:00:03.000Z',
sessionId: 's1',
message: { role: 'assistant', content: [{ type: 'text', text: 'готово' }] },
}),
].join('\n');
const ep = parseTranscript(lines, 'fallback');
expect(ep.environment.economy_level).toBe(5);
expect(ep.primary_rationale.task_classification).toBe('bugfix');
});
it('extractLastUserPromptText skips skill-content and returns genuine prompt', () => {
const lines = [
JSON.stringify({
type: 'user',
timestamp: '2026-05-19T10:00:00.000Z',
sessionId: 's1',
message: { role: 'user', content: 'добавь колонку Город' },
}),
JSON.stringify({
type: 'user',
timestamp: '2026-05-19T10:00:02.000Z',
sessionId: 's1',
message: { role: 'user', content: 'Base directory for this skill: C:\\x\n# Skill body' },
}),
].join('\n');
expect(extractLastUserPromptText(lines)).toBe('добавь колонку Город');
});
it('extractLastUserPromptText skips local-command output and interrupt markers', () => {
const lines = [
JSON.stringify({
type: 'user',
timestamp: '2026-05-19T10:00:00.000Z',
sessionId: 's1',
message: { role: 'user', content: 'сделай X' },
}),
JSON.stringify({
type: 'user',
timestamp: '2026-05-19T10:00:01.000Z',
sessionId: 's1',
message: { role: 'user', content: '[Request interrupted by user]' },
}),
JSON.stringify({
type: 'user',
timestamp: '2026-05-19T10:00:02.000Z',
sessionId: 's1',
message: { role: 'user', content: '<local-command-stdout>some output</local-command-stdout>' },
}),
].join('\n');
expect(extractLastUserPromptText(lines)).toBe('сделай X');
});
});
describe('parseTranscript — AskUserQuestion in-turn choice', () => {
it('classifies user_chose_from_options when an AskUserQuestion option was clicked', () => {
const lines = [
JSON.stringify({
type: 'user',
timestamp: '2026-05-19T10:00:00.000Z',
sessionId: 's1',
message: { role: 'user', content: 'построй фичу' },
}),
JSON.stringify({
type: 'assistant',
timestamp: '2026-05-19T10:00:01.000Z',
sessionId: 's1',
message: {
role: 'assistant',
content: [{ type: 'tool_use', name: 'AskUserQuestion', input: {} }],
},
}),
JSON.stringify({
type: 'user',
timestamp: '2026-05-19T10:00:05.000Z',
sessionId: 's1',
message: { role: 'user', content: [{ type: 'tool_result', content: 'User has answered...' }] },
toolUseResult: {
questions: [
{
question: 'Каким режимом?',
options: [{ label: 'Subagent-Driven' }, { label: 'Inline execution' }],
},
],
answers: { 'Каким режимом?': 'Inline execution' },
},
}),
JSON.stringify({
type: 'assistant',
timestamp: '2026-05-19T10:00:06.000Z',
sessionId: 's1',
message: { role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
}),
].join('\n');
const ep = parseTranscript(lines, 'fallback');
expect(ep.decision_provenance).toEqual({
kind: 'user_chose_from_options',
node: 'Inline execution',
options_offered: ['Subagent-Driven', 'Inline execution'],
claude_would_have_chosen: 'Subagent-Driven',
});
});
});
describe('parseTranscript — uuid-dedup for duplicated snapshots (quirk #101)', () => {
// 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 become
// non-monotonic across episodes parsed at different file-growth states.
// Root fix: dedup by uuid in parseLines.
it('collapses duplicated-uuid user prompts (session_turn counts once)', () => {
const dup = 'aaaa-bbbb-cccc';
const lines = [
JSON.stringify({
uuid: dup,
type: 'user',
message: { role: 'user', content: 'hi' },
timestamp: '2026-05-19T10:00:00Z',
sessionId: 's1',
}),
JSON.stringify({
uuid: dup,
type: 'user',
message: { role: 'user', content: 'hi' },
timestamp: '2026-05-19T10:00:00Z',
sessionId: 's1',
}),
].join('\n');
const ep = parseTranscript(lines, 'fallback');
expect(ep.environment.session_turn).toBe(1);
});
it('preserves distinct-uuid entries (no over-dedup)', () => {
const lines = [
JSON.stringify({
uuid: 'one',
type: 'user',
message: { role: 'user', content: 'first' },
timestamp: '2026-05-19T09:00:00Z',
sessionId: 's1',
}),
JSON.stringify({
uuid: 'two',
type: 'user',
message: { role: 'user', content: 'second' },
timestamp: '2026-05-19T10:00:00Z',
sessionId: 's1',
}),
].join('\n');
const ep = parseTranscript(lines, 'fallback');
expect(ep.environment.session_turn).toBe(2);
});
it('entries without uuid pass through unchanged (synthetic fixtures unaffected)', () => {
// Existing tests build entries without `uuid` — must still work.
const t = jsonl([
userPrompt('one', '2026-05-19T09:00:00Z'),
userPrompt('two', '2026-05-19T10:00:00Z'),
]);
expect(parseTranscript(t).environment.session_turn).toBe(2);
});
it('collapses duplicated-uuid assistant turns (task_size counted once)', () => {
const lines = [
JSON.stringify({
uuid: 'u-prompt',
type: 'user',
message: { role: 'user', content: 'go' },
timestamp: '2026-05-19T10:00:00Z',
sessionId: 's1',
}),
JSON.stringify({
uuid: 'u-asst',
type: 'assistant',
message: {
role: 'assistant',
content: [
{ type: 'tool_use', id: 't1', name: 'Read', input: { file_path: '/x.js' } },
{ type: 'tool_use', id: 't2', name: 'Edit', input: { file_path: '/x.js' } },
],
},
timestamp: '2026-05-19T10:01:00Z',
sessionId: 's1',
}),
// EXACT duplicate of the assistant entry — snapshot rebuild artifact
JSON.stringify({
uuid: 'u-asst',
type: 'assistant',
message: {
role: 'assistant',
content: [
{ type: 'tool_use', id: 't1', name: 'Read', input: { file_path: '/x.js' } },
{ type: 'tool_use', id: 't2', name: 'Edit', input: { file_path: '/x.js' } },
],
},
timestamp: '2026-05-19T10:01:00Z',
sessionId: 's1',
}),
].join('\n');
const ep = parseTranscript(lines, 'fallback');
expect(ep.task_size.tool_calls).toBe(2);
expect(ep.task_size.files_touched).toBe(1);
});
});
describe('extractEnvironment — parallel_session narrowed to tool_result evidence', () => {
// Collision detection scans ONLY tool_result content (real command output).
// Prose mentions in user prompts / assistant text — including analysis text
// that references collision phrases — must not trigger.
const userMsg = (text) => ({
message: { role: 'user', content: text },
timestamp: '2026-05-19T10:00:00.000Z',
});
const assistantText = (text) => ({
message: { role: 'assistant', content: [{ type: 'text', text }] },
timestamp: '2026-05-19T10:00:01.000Z',
});
const toolResultText = (text, isError = false) => ({
message: {
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 't1', content: text, is_error: isError }],
},
timestamp: '2026-05-19T10:00:02.000Z',
});
it('is false when a USER prompt only mentions a collision marker', () => {
const env = extractEnvironment([userMsg('в моём ходе появился чужой staged-файл — что делать?')], 0);
expect(env.parallel_session).toBe(false);
});
it('is false when ASSISTANT prose mentions a collision marker (e.g., analysis text)', () => {
const env = extractEnvironment(
[userMsg('go'), assistantText('я писал про чужой staged и foreign git index в разборе')],
0
);
expect(env.parallel_session).toBe(false);
});
it('is true when a tool_result (Bash output) contains "foreign git index"', () => {
const env = extractEnvironment(
[
userMsg('go'),
toolResultText('fatal: another git process is running\nforeign git index detected', true),
],
0
);
expect(env.parallel_session).toBe(true);
});
it('is true when a tool_result contains "чужой staged"', () => {
const env = extractEnvironment(
[userMsg('go'), toolResultText('error: чужой staged-файл присутствует в индексе')],
0
);
expect(env.parallel_session).toBe(true);
});
it('is false when there are no tool_result entries at all', () => {
const env = extractEnvironment([userMsg('говорим о параллельных сессиях вообще')], 0);
expect(env.parallel_session).toBe(false);
});
it('handles tool_result with content as a structured array of text blocks', () => {
const env = extractEnvironment(
[
userMsg('go'),
{
message: {
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 't1',
content: [{ type: 'text', text: 'index.lock exists, another process is holding it' }],
},
],
},
timestamp: '2026-05-19T10:00:02.000Z',
},
],
0
);
expect(env.parallel_session).toBe(true);
});
});
describe('classifyTask — extended dictionary (Task 1)', () => {
it('classifies analysis prompts', () => {
expect(classifyTask('проанализируй данные наблюдателя')).toBe('analysis');
expect(classifyTask('review the recent changes')).toBe('analysis');
expect(classifyTask('посмотри что в логах')).toBe('analysis');
});
it('classifies memory-sync prompts', () => {
expect(classifyTask('обнови эталон проекта')).toBe('memory-sync');
expect(classifyTask('sync memory с последним push')).toBe('memory-sync');
expect(classifyTask('обнови MEMORY.md')).toBe('memory-sync');
});
it('classifies regulatory-bump prompts', () => {
expect(classifyTask('обнови CLAUDE.md после правки')).toBe('regulatory-bump');
expect(classifyTask('правка Pravila §16')).toBe('regulatory-bump');
expect(classifyTask('обнови PSR_v1 v3.18')).toBe('regulatory-bump');
});
it('classifies release prompts', () => {
expect(classifyTask('push origin main')).toBe('release');
expect(classifyTask('merge feature-branch')).toBe('release');
expect(classifyTask('сделай release v2')).toBe('release');
});
it('does NOT classify a plain commit (no push/release) as release', () => {
expect(classifyTask('commit the auth changes')).not.toBe('release');
});
it('classifies cleanup prompts', () => {
expect(classifyTask('убери временные файлы')).toBe('cleanup');
expect(classifyTask('cleanup тулчейн')).toBe('cleanup');
});
it('classifies monitoring prompts', () => {
expect(classifyTask('проверь состояние портала')).toBe('monitoring');
expect(classifyTask('status of CI')).toBe('monitoring');
});
it('classifies planning prompts', () => {
expect(classifyTask('план рефакторинга биллинга')).toBe('planning');
expect(classifyTask('design новую фичу')).toBe('planning');
expect(classifyTask('обсудим архитектуру')).toBe('planning');
});
it('preserves existing classes — refactor stays refactor', () => {
expect(classifyTask('рефактор биллинг-сервиса')).toBe('refactor');
});
it('preserves existing classes — bugfix stays bugfix', () => {
expect(classifyTask('почини баг в logger')).toBe('bugfix');
});
});
describe('extractTokenUsage (Task 2)', () => {
it('sums input/output/cache fields across multiple assistant messages', () => {
const turn = [
{ message: { usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 100, cache_creation_input_tokens: 50 } } },
{ message: { usage: { input_tokens: 8, output_tokens: 3, cache_read_input_tokens: 80, cache_creation_input_tokens: 20 } } },
];
expect(extractTokenUsage(turn)).toEqual({
input_tokens: 18, output_tokens: 8, cache_read_input_tokens: 180,
cache_creation_input_tokens: 70, web_search_requests: 0, web_fetch_requests: 0, iterations: 0,
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,
});
});
it('captures server_tool_use bonus fields (web_search/web_fetch)', () => {
const turn = [
{ message: { usage: { input_tokens: 5, output_tokens: 2, server_tool_use: { web_search_requests: 3, web_fetch_requests: 1 } } } },
];
const result = extractTokenUsage(turn);
expect(result.web_search_requests).toBe(3);
expect(result.web_fetch_requests).toBe(1);
});
it('captures iterations (extended-thinking detector)', () => {
const turn = [
{ message: { usage: { input_tokens: 100, output_tokens: 50, iterations: 4 } } },
];
expect(extractTokenUsage(turn).iterations).toBe(4);
});
it('returns zero-filled object when no usage present', () => {
const turn = [
{ message: {} },
{ message: { usage: null } },
];
expect(extractTokenUsage(turn)).toEqual({
input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0,
cache_creation_input_tokens: 0, web_search_requests: 0, web_fetch_requests: 0, iterations: 0,
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,
});
});
it('handles empty/null turn safely', () => {
const zeroShape = {
input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0,
cache_creation_input_tokens: 0, web_search_requests: 0, web_fetch_requests: 0, iterations: 0,
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,
};
expect(extractTokenUsage([])).toEqual(zeroShape);
expect(extractTokenUsage(null)).toEqual(zeroShape);
});
it('safely skips entries where usage is a non-object primitive (defensive guard)', () => {
const turn = [
{ message: { usage: 42 } }, // malformed — usage as primitive
{ message: { usage: { input_tokens: 5, output_tokens: 3 } } },
];
const r = extractTokenUsage(turn);
expect(r.input_tokens).toBe(5);
expect(r.output_tokens).toBe(3);
});
});
describe('parseTranscript — task_cost integration (Task 2)', () => {
it('attaches task_cost to a v2 episode', () => {
const lines = [
JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'implement feature X' }] } }),
JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'done' }], usage: { input_tokens: 42, output_tokens: 7 } } }),
];
const result = parseTranscript(lines.join('\n'));
expect(result).not.toBeNull();
expect(result.task_cost).toBeDefined();
expect(result.task_cost.input_tokens).toBe(42);
expect(result.task_cost.output_tokens).toBe(7);
});
it('attaches zero-filled task_cost when no usage in transcript', () => {
const lines = [
JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'do something' }] } }),
JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'ok' }] } }),
];
const result = parseTranscript(lines.join('\n'));
expect(result).not.toBeNull();
expect(result.task_cost).toEqual({
input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0,
cache_creation_input_tokens: 0, web_search_requests: 0, web_fetch_requests: 0, iterations: 0,
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,
judge_spend_usd: 0,
});
});
});
import { extractAskUserQuestionEvents } from './observer-transcript-parser.mjs';
describe('extractAskUserQuestionEvents (Task 4)', () => {
it('records "option" when user picked an offered label exactly', () => {
const turn = [
{ message: { content: [{ type: 'tool_use', name: 'AskUserQuestion',
input: { questions: [{ question: 'q1', options: [{ label: 'A' }, { label: 'B' }] }] } }] } },
{ toolUseResult: { questions: [{ question: 'q1', options: [{ label: 'A' }, { label: 'B' }] }],
answers: { q1: 'A' } } },
];
const evs = extractAskUserQuestionEvents(turn);
expect(evs).toHaveLength(1);
expect(evs[0].kind).toBe('ask_user_question');
expect(evs[0].question_count).toBe(1);
expect(evs[0].answer_kind).toBe('option');
});
it('records "custom" when user wrote free-text Other', () => {
const turn = [
{ message: { content: [{ type: 'tool_use', name: 'AskUserQuestion',
input: { questions: [{ question: 'q1', options: [{ label: 'A' }, { label: 'B' }] }] } }] } },
{ toolUseResult: { questions: [{ question: 'q1', options: [{ label: 'A' }, { label: 'B' }] }],
answers: { q1: 'C — мой вариант' } } },
];
const evs = extractAskUserQuestionEvents(turn);
expect(evs[0].answer_kind).toBe('custom');
});
it('records "no_answer" when answer key missing', () => {
const turn = [
{ toolUseResult: { questions: [{ question: 'q1', options: [{ label: 'A' }, { label: 'B' }] }],
answers: {} } },
];
const evs = extractAskUserQuestionEvents(turn);
expect(evs[0].answer_kind).toBe('no_answer');
});
it('reports question_count from toolUseResult.questions length', () => {
const turn = [
{ toolUseResult: { questions: [
{ question: 'q1', options: [{ label: 'A' }] },
{ question: 'q2', options: [{ label: 'B' }] },
], answers: { q1: 'A', q2: 'B' } } },
];
const evs = extractAskUserQuestionEvents(turn);
expect(evs).toHaveLength(2); // one event per question
expect(evs[0].question_count).toBe(2);
expect(evs[1].question_count).toBe(2);
});
it('returns empty for a turn without AskUserQuestion', () => {
expect(extractAskUserQuestionEvents([{ message: { content: [{ type: 'tool_use', name: 'Read' }] } }])).toEqual([]);
});
it('handles null/undefined turn safely', () => {
expect(extractAskUserQuestionEvents(null)).toEqual([]);
expect(extractAskUserQuestionEvents(undefined)).toEqual([]);
expect(extractAskUserQuestionEvents([])).toEqual([]);
});
});
describe('parseTranscript — ask_user_question events (Task 4)', () => {
it('emits ask_user_question event from AskUserQuestion + toolUseResult', () => {
const transcript = [
JSON.stringify({ sessionId: 's1' }),
JSON.stringify({ type: 'user', message: { role: 'user', content: 'делай' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }),
JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [
{ type: 'tool_use', id: 't1', name: 'AskUserQuestion',
input: { questions: [{ question: 'q', options: [{ label: 'X' }, { label: 'Y' }] }] } },
] }, uuid: 'u2', timestamp: '2026-05-20T00:00:01Z' }),
JSON.stringify({ type: 'user', message: { role: 'user', content: [
{ type: 'tool_result', tool_use_id: 't1' },
] }, toolUseResult: { questions: [{ question: 'q', options: [{ label: 'X' }, { label: 'Y' }] }],
answers: { q: 'X' } }, uuid: 'u3', timestamp: '2026-05-20T00:00:02Z' }),
].join('\n');
const ep = parseTranscript(transcript);
const aq = ep.events.filter((e) => e.kind === 'ask_user_question');
expect(aq).toHaveLength(1);
expect(aq[0].answer_kind).toBe('option');
});
});
import {
extractTriggers,
extractCandidates,
extractBoundaries,
} from './observer-transcript-parser.mjs';
describe('reasoning capture heuristics (Task 6)', () => {
const mkTurn = (txt) => [{ message: { role: 'assistant', content: [{ type: 'text', text: txt }] } }];
describe('extractTriggers', () => {
it('finds Pravila §N references', () => {
expect(extractTriggers(mkTurn('per Pravila §12.2 hard-rule'))).toContain('Pravila §12.2');
});
it('finds ADR references', () => {
expect(extractTriggers(mkTurn('see ADR-011 anchor'))).toContain('ADR-011');
});
it('finds PSR_v1 R refs', () => {
expect(extractTriggers(mkTurn('PSR_v1 R10.1 requires it'))).toContain('PSR_v1 R10.1');
});
it('finds routing-off-phase L refs from canonical form', () => {
expect(extractTriggers(mkTurn('routing-off-phase L12 chain'))).toContain('routing-off-phase L12');
});
it('finds hard-rule / hard-floor (case-insensitive)', () => {
const res = extractTriggers(mkTurn('this is a hard-rule per §15'));
expect(res.some((t) => t.toLowerCase().includes('hard-rule'))).toBe(true);
});
it('deduplicates repeated triggers', () => {
const res = extractTriggers(mkTurn('Pravila §16 and Pravila §16 again'));
expect(res.filter((t) => t === 'Pravila §16')).toHaveLength(1);
});
it('returns empty for plain prose', () => {
expect(extractTriggers(mkTurn('just plain text'))).toEqual([]);
});
it('safe on null/empty', () => {
expect(extractTriggers(null)).toEqual([]);
expect(extractTriggers([])).toEqual([]);
});
});
describe('extractCandidates', () => {
// Only items that look like router-node identifiers are accepted:
// - a known node from tools/observer-known-nodes.txt
// - a key from tools/observer-chain-map.json (e.g. superpowers:brainstorming)
// - a tooling ID matching ^#\d+$ (Прил. Н)
// - the sentinel "direct"
// Free-form prose bullets / numbered procedure steps / code snippets are rejected.
it('extracts numbered options that are known node names', () => {
const c = extractCandidates(
mkTurn('1. brainstorming\n2. subagent-driven-development\n3. direct')
);
expect(c).toEqual(['brainstorming', 'subagent-driven-development', 'direct']);
});
it('accepts namespaced plugin:skill form from the chain map', () => {
const c = extractCandidates(
mkTurn('1. superpowers:brainstorming\n2. claude-md-management:claude-md-improver')
);
expect(c).toEqual(['superpowers:brainstorming', 'claude-md-management:claude-md-improver']);
});
it('accepts tooling IDs like #25 / #74', () => {
expect(extractCandidates(mkTurn('1. #25\n2. #74'))).toEqual(['#25', '#74']);
});
it('strips simple markdown wrappers before checking', () => {
const c = extractCandidates(
mkTurn('1. **brainstorming**\n2. `writing-plans`\n3. discovery-interview')
);
expect(c).toEqual(['brainstorming', 'writing-plans', 'discovery-interview']);
});
it('drops free-form prose bullets even when ≥2 are present', () => {
// Repro from docs/observer/episodes-2026-05.jsonl: analysis bullets with
// bold-prefix sentence text were going straight into candidates_considered.
const text =
'- **Hard-floor работает только для §12 Superpowers** (14 раз). §14/§15 в журнале не оставили следов.\n' +
'- **На feature/planning я не ищу триггеры** — 0% матча.\n' +
'- **Метка `regulated` врёт** в 79% случаев — нет настоящего применения границ.';
expect(extractCandidates(mkTurn(text))).toEqual([]);
});
it('drops numbered procedure-step text (real episode repro)', () => {
const text =
'1. **Hard-floor check** — Pravila §12 (Superpowers) / §14 (Queen) / §15.\n' +
'2. **Классификация** — определяю тип задачи (feature/bugfix/planning).\n' +
'3. **Trigger-based node selection** — по реестру Tooling Прил. Н §4.X.\n' +
'4. **Canonical chain check** — смотрю L1-L15.\n' +
'5. **Execution** — иду делать.';
expect(extractCandidates(mkTurn(text))).toEqual([]);
});
it('drops code-snippet bullets (regex patterns etc.)', () => {
const text =
'- regex `(?:^|[\\s\\"\\\'])(tools\\/[\\w-]+\\.(?:mjs|py|sh))` → имя файла.\n' +
'- fallback `inline:<sha256(command).slice(0,16)>` — стабильно.';
expect(extractCandidates(mkTurn(text))).toEqual([]);
});
it('filters a mixed list down to just the real nodes', () => {
const text =
'1. brainstorming\n' +
'2. resolver + tests\n' +
'3. discovery-interview\n' +
'4. parser extension + tests + smoke';
expect(extractCandidates(mkTurn(text))).toEqual(['brainstorming', 'discovery-interview']);
});
it('returns empty when only one real node survives the filter', () => {
// ≥2 raw items but only 1 known-node → not enough signal, drop.
expect(extractCandidates(mkTurn('1. brainstorming\n2. некий текст'))).toEqual([]);
});
it('prefers numbered over bullets when both lists contain known nodes', () => {
const c = extractCandidates(
mkTurn('1. brainstorming\n2. writing-plans\n- discovery-interview\n- regression')
);
expect(c).toEqual(['brainstorming', 'writing-plans']);
});
it('returns empty for prose', () => {
expect(extractCandidates(mkTurn('просто текст'))).toEqual([]);
});
it('safe on null/empty', () => {
expect(extractCandidates(null)).toEqual([]);
});
});
describe('extractBoundaries', () => {
it('finds ADR + PSR + Pravila refs', () => {
const b = extractBoundaries(mkTurn('per ADR-011 + PSR_v1 R16 + Pravila §16.2'));
expect(b).toContain('ADR-011');
expect(b.some((x) => x.includes('PSR_v1 R16'))).toBe(true);
expect(b).toContain('Pravila §16.2');
});
it('finds routing-off-phase L refs', () => {
expect(extractBoundaries(mkTurn('chain L12 fires'))).toEqual(expect.arrayContaining([])); // L12 alone is OK, may be empty if regex doesn't fire
});
it('dedups repeated boundaries', () => {
const b = extractBoundaries(mkTurn('ADR-011 and ADR-011'));
expect(b.filter((x) => x === 'ADR-011')).toHaveLength(1);
});
it('safe on null/empty', () => {
expect(extractBoundaries(null)).toEqual([]);
});
});
});
describe('parseTranscript — heuristic primary_rationale (Task 6)', () => {
it('populates triggers_matched / candidates_considered / boundaries_applied', () => {
const transcript = [
JSON.stringify({ sessionId: 's1' }),
JSON.stringify({ type: 'user', message: { role: 'user', content: 'делай' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }),
JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [
{ type: 'text', text: 'per Pravila §12.2 hard-rule\n1. brainstorming\n2. direct\nADR-011 applies.' }
] }, uuid: 'u2', timestamp: '2026-05-20T00:01:00Z' }),
].join('\n');
const ep = parseTranscript(transcript);
expect(ep.primary_rationale.triggers_matched).toContain('Pravila §12.2');
expect(ep.primary_rationale.candidates_considered).toContain('brainstorming');
expect(ep.primary_rationale.boundaries_applied).toContain('ADR-011');
});
});
describe('error event differentiation (Task 7)', () => {
it('attributes error to the failing tool and captures summary', () => {
const transcript = [
JSON.stringify({ sessionId: 's' }),
JSON.stringify({ type: 'user', message: { role: 'user', content: 'делай' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }),
JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [
{ type: 'tool_use', id: 'tu1', name: 'Bash', input: {} }
] }, uuid: 'u2', timestamp: '2026-05-20T00:00:01Z' }),
JSON.stringify({ type: 'user', message: { role: 'user', content: [
{ type: 'tool_result', tool_use_id: 'tu1', is_error: true, content: 'ENOENT: no such file or directory' }
] }, uuid: 'u3', timestamp: '2026-05-20T00:00:02Z' }),
].join('\n');
const ep = parseTranscript(transcript);
const errorEvents = ep.events.filter((e) => e.kind === 'error');
expect(errorEvents).toHaveLength(1);
expect(errorEvents[0].tool).toBe('Bash');
expect(errorEvents[0].summary).toContain('ENOENT');
});
it('attributes error to "unknown" when tool_use_id not seen', () => {
const transcript = [
JSON.stringify({ sessionId: 's' }),
JSON.stringify({ type: 'user', message: { role: 'user', content: 'делай' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }),
JSON.stringify({ type: 'user', message: { role: 'user', content: [
{ type: 'tool_result', tool_use_id: 'orphan', is_error: true, content: 'mystery error' }
] }, uuid: 'u2', timestamp: '2026-05-20T00:00:02Z' }),
].join('\n');
const ep = parseTranscript(transcript);
const errs = ep.events.filter((e) => e.kind === 'error');
expect(errs[0].tool).toBe('unknown');
expect(errs[0].summary).toBe('mystery error');
});
it('truncates summary at 80 chars', () => {
const longText = 'a'.repeat(200);
const transcript = [
JSON.stringify({ sessionId: 's' }),
JSON.stringify({ type: 'user', message: { role: 'user', content: 'делай' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }),
JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [
{ type: 'tool_use', id: 'tu1', name: 'Read', input: {} }
] }, uuid: 'u2', timestamp: '2026-05-20T00:00:01Z' }),
JSON.stringify({ type: 'user', message: { role: 'user', content: [
{ type: 'tool_result', tool_use_id: 'tu1', is_error: true, content: longText }
] }, uuid: 'u3', timestamp: '2026-05-20T00:00:02Z' }),
].join('\n');
const ep = parseTranscript(transcript);
const e = ep.events.find((x) => x.kind === 'error');
expect(e.summary.length).toBe(80);
});
it('handles structured content array (text blocks)', () => {
const transcript = [
JSON.stringify({ sessionId: 's' }),
JSON.stringify({ type: 'user', message: { role: 'user', content: 'делай' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }),
JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [
{ type: 'tool_use', id: 'tu1', name: 'Edit', input: {} }
] }, uuid: 'u2', timestamp: '2026-05-20T00:00:01Z' }),
JSON.stringify({ type: 'user', message: { role: 'user', content: [
{ type: 'tool_result', tool_use_id: 'tu1', is_error: true, content: [{ type: 'text', text: 'String to replace not found' }] }
] }, uuid: 'u3', timestamp: '2026-05-20T00:00:02Z' }),
].join('\n');
const ep = parseTranscript(transcript);
const e = ep.events.find((x) => x.kind === 'error');
expect(e.tool).toBe('Edit');
expect(e.summary).toContain('String to replace');
});
});
describe('promptText strips <system-reminder> blocks (Task 8)', () => {
it('classifyTask is not polluted by reminder content', () => {
const transcript = [
JSON.stringify({ sessionId: 's' }),
JSON.stringify({ type: 'user', message: { role: 'user',
content: '<system-reminder>почему как что зачем</system-reminder>\nрефактор биллинга'
}, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }),
].join('\n');
const ep = parseTranscript(transcript);
expect(ep.primary_rationale.task_classification).toBe('refactor');
});
it('multiline system-reminder is stripped', () => {
const transcript = [
JSON.stringify({ sessionId: 's' }),
JSON.stringify({ type: 'user', message: { role: 'user',
content: '<system-reminder>\nline 1\nline 2 with почему\n</system-reminder>\nfix баг'
}, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }),
].join('\n');
const ep = parseTranscript(transcript);
expect(ep.primary_rationale.task_classification).toBe('bugfix');
});
it('multiple system-reminders all stripped', () => {
const transcript = [
JSON.stringify({ sessionId: 's' }),
JSON.stringify({ type: 'user', message: { role: 'user',
content: '<system-reminder>почему</system-reminder>middle<system-reminder>как</system-reminder>создай фичу'
}, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }),
].join('\n');
const ep = parseTranscript(transcript);
expect(ep.primary_rationale.task_classification).toBe('feature');
});
it('content array form also stripped', () => {
const transcript = [
JSON.stringify({ sessionId: 's' }),
JSON.stringify({ type: 'user', message: { role: 'user',
content: [{ type: 'text', text: '<system-reminder>почему как</system-reminder> рефактор' }]
}, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }),
].join('\n');
const ep = parseTranscript(transcript);
expect(ep.primary_rationale.task_classification).toBe('refactor');
});
});
describe('classifyPromptSignal — extended dictionary (Task 9)', () => {
it('detects "не совсем" as correction', () => {
expect(classifyPromptSignal('не совсем то')).toBe('correction');
});
it('detects "wrong direction" as correction', () => {
expect(classifyPromptSignal('wrong direction here')).toBe('correction');
});
it('detects "другое" as correction', () => {
expect(classifyPromptSignal('другое решение нужно')).toBe('correction');
});
it('detects "класс" as approval', () => {
expect(classifyPromptSignal('класс')).toBe('approval');
});
it('detects "well done" as approval', () => {
expect(classifyPromptSignal('well done')).toBe('approval');
});
it('detects "nice" as approval', () => {
expect(classifyPromptSignal('nice')).toBe('approval');
});
it('detects "теперь" prefix as new_task', () => {
expect(classifyPromptSignal('теперь сделай биллинг')).toBe('new_task');
});
it('detects "далее" prefix as new_task', () => {
expect(classifyPromptSignal('далее переходим к настройкам')).toBe('new_task');
});
it('detects "next" prefix as new_task', () => {
expect(classifyPromptSignal('next step please')).toBe('new_task');
});
it('preserves existing — neutral for nondescript text', () => {
expect(classifyPromptSignal('некий случайный текст')).toBe('neutral');
});
it('preserves existing — correction "переделай"', () => {
expect(classifyPromptSignal('переделай это')).toBe('correction');
});
});
import { parseReasoningTag } from './observer-transcript-parser.mjs';
describe('parseReasoningTag (Task 11)', () => {
it('parses opt-in reasoning tag from assistant text', () => {
const turn = [{ message: { role: 'assistant', content: [
{ type: 'text', text: '<!-- reasoning: triggers="Pravila §12.2;ADR-011" candidates="brain-retro;brainstorming" boundaries="Pravila §16" -->\nAnswer.' }
] } }];
const tag = parseReasoningTag(turn);
expect(tag).toEqual({
triggers: ['Pravila §12.2', 'ADR-011'],
candidates: ['brain-retro', 'brainstorming'],
boundaries: ['Pravila §16'],
});
});
it('returns null when no tag present', () => {
expect(parseReasoningTag([{ message: { role: 'assistant', content: [{ type: 'text', text: 'plain' }] } }])).toBeNull();
});
it('safe on null/empty turn', () => {
expect(parseReasoningTag(null)).toBeNull();
expect(parseReasoningTag([])).toBeNull();
});
it('skips non-text blocks', () => {
const turn = [{ message: { role: 'assistant', content: [{ type: 'tool_use', name: 'Read' }] } }];
expect(parseReasoningTag(turn)).toBeNull();
});
});
describe('parseTranscript — reasoning-tag merges with heuristic (Task 11)', () => {
it('merges tag triggers into heuristic triggers (deduped)', () => {
const transcript = [
JSON.stringify({ sessionId: 's' }),
JSON.stringify({ type: 'user', message: { role: 'user', content: 'делай' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }),
JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [
{ type: 'text', text: 'Pravila §12.2 hard-rule\n<!-- reasoning: triggers="Pravila §12.2;ADR-011" candidates="brain-retro" boundaries="Pravila §16" -->' }
] }, uuid: 'u2', timestamp: '2026-05-20T00:01:00Z' }),
].join('\n');
const ep = parseTranscript(transcript);
// heuristic finds 'Pravila §12.2' from text + tag adds 'ADR-011'; dedup
expect(ep.primary_rationale.triggers_matched).toContain('Pravila §12.2');
expect(ep.primary_rationale.triggers_matched).toContain('ADR-011');
expect(ep.primary_rationale.triggers_matched.filter((t) => t === 'Pravila §12.2')).toHaveLength(1);
// candidates: tag contributes 'brain-retro' (no numbered list in text)
expect(ep.primary_rationale.candidates_considered).toContain('brain-retro');
// boundaries: heuristic empty (no marker in plain text), tag adds 'Pravila §16'
expect(ep.primary_rationale.boundaries_applied).toContain('Pravila §16');
});
});
import { extractAgentInvocations } from './observer-transcript-parser.mjs';
describe('extractAgentInvocations (Task 12)', () => {
it('emits subagent_invoked event from Agent tool_use', () => {
const turn = [{ message: { role: 'assistant', content: [
{ type: 'tool_use', name: 'Agent',
input: { subagent_type: 'general-purpose', model: 'sonnet', description: 'check files' } }
] } }];
const ev = extractAgentInvocations(turn);
expect(ev).toHaveLength(1);
expect(ev[0].kind).toBe('subagent_invoked');
expect(ev[0].subagent_type).toBe('general-purpose');
expect(ev[0].model).toBe('sonnet');
expect(ev[0].description).toBe('check files');
});
it('uses "unknown" when subagent_type missing', () => {
const turn = [{ message: { role: 'assistant', content: [
{ type: 'tool_use', name: 'Agent', input: {} }
] } }];
expect(extractAgentInvocations(turn)[0].subagent_type).toBe('unknown');
});
it('truncates description at 80 chars', () => {
const turn = [{ message: { role: 'assistant', content: [
{ type: 'tool_use', name: 'Agent',
input: { subagent_type: 'g', description: 'a'.repeat(200) } }
] } }];
expect(extractAgentInvocations(turn)[0].description.length).toBe(80);
});
it('returns empty for non-Agent tool_use', () => {
const turn = [{ message: { role: 'assistant', content: [
{ type: 'tool_use', name: 'Read', input: { file_path: '/a' } }
] } }];
expect(extractAgentInvocations(turn)).toEqual([]);
});
it('safe on null/empty', () => {
expect(extractAgentInvocations(null)).toEqual([]);
expect(extractAgentInvocations([])).toEqual([]);
});
});
describe('parseTranscript — subagent_invoked events (Task 12)', () => {
it('emits subagent_invoked from Agent tool_use', () => {
const transcript = [
JSON.stringify({ sessionId: 's' }),
JSON.stringify({ type: 'user', message: { role: 'user', content: 'делай' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }),
JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [
{ type: 'tool_use', id: 't1', name: 'Agent',
input: { subagent_type: 'tester', model: 'haiku', description: 'verify spec' } }
] }, uuid: 'u2', timestamp: '2026-05-20T00:00:01Z' }),
].join('\n');
const ep = parseTranscript(transcript);
const subs = ep.events.filter((e) => e.kind === 'subagent_invoked');
expect(subs).toHaveLength(1);
expect(subs[0].subagent_type).toBe('tester');
expect(subs[0].model).toBe('haiku');
});
});
describe('parallel_session — pre-flight OR clause (Task 13 PIVOT)', () => {
it('is true when Bash ran "git fetch && git log HEAD..origin/main"', () => {
const transcript = [
JSON.stringify({ sessionId: 's' }),
JSON.stringify({ type: 'user', message: { role: 'user', content: 'pre-flight' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }),
JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [
{ type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'git fetch && git log HEAD..origin/main --oneline' } }
] }, uuid: 'u2', timestamp: '2026-05-20T00:00:01Z' }),
].join('\n');
const ep = parseTranscript(transcript);
expect(ep.environment.parallel_session).toBe(true);
});
it('is true with origin/<other-branch> via HEAD..origin/feat', () => {
const transcript = [
JSON.stringify({ sessionId: 's' }),
JSON.stringify({ type: 'user', message: { role: 'user', content: 'sync' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }),
JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [
{ type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'git fetch origin && git log HEAD..origin/feat/x --oneline' } }
] }, uuid: 'u2', timestamp: '2026-05-20T00:00:01Z' }),
].join('\n');
const ep = parseTranscript(transcript);
expect(ep.environment.parallel_session).toBe(true);
});
it('F1 still works — collision text in tool_result triggers', () => {
const transcript = [
JSON.stringify({ sessionId: 's' }),
JSON.stringify({ type: 'user', message: { role: 'user', content: 'go' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }),
JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [
{ type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'git commit -am ok' } }
] }, uuid: 'u2', timestamp: '2026-05-20T00:00:01Z' }),
JSON.stringify({ type: 'user', message: { role: 'user', content: [
{ type: 'tool_result', tool_use_id: 't1', content: 'fatal: index.lock exists' }
] }, uuid: 'u3', timestamp: '2026-05-20T00:00:02Z' }),
].join('\n');
const ep = parseTranscript(transcript);
expect(ep.environment.parallel_session).toBe(true);
});
it('false on regular Bash without pre-flight or collision', () => {
const transcript = [
JSON.stringify({ sessionId: 's' }),
JSON.stringify({ type: 'user', message: { role: 'user', content: 'go' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }),
JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [
{ type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'npm run test:tools' } }
] }, uuid: 'u2', timestamp: '2026-05-20T00:00:01Z' }),
].join('\n');
const ep = parseTranscript(transcript);
expect(ep.environment.parallel_session).toBe(false);
});
});
describe('parseTranscript v3 fields', () => {
function transcriptWithSkill(skillName) {
return [
JSON.stringify({
type: 'user',
message: { role: 'user', content: 'добавь endpoint /api/foo' },
timestamp: '2026-05-23T10:00:00Z',
uuid: 'u-1',
sessionId: 'sess-1',
}),
JSON.stringify({
type: 'assistant',
message: {
role: 'assistant',
content: [
{ type: 'tool_use', id: 't-1', name: 'Skill', input: { skill: skillName } },
],
},
timestamp: '2026-05-23T10:00:01Z',
uuid: 'u-2',
sessionId: 'sess-1',
}),
].join('\n');
}
function transcriptDirectFeature() {
return [
JSON.stringify({
type: 'user',
message: { role: 'user', content: 'добавь новый endpoint /api/foo' },
timestamp: '2026-05-23T10:00:00Z',
uuid: 'u-1',
sessionId: 'sess-1',
}),
JSON.stringify({
type: 'assistant',
message: { role: 'assistant', content: [{ type: 'text', text: 'делаю' }] },
timestamp: '2026-05-23T10:00:01Z',
uuid: 'u-2',
sessionId: 'sess-1',
}),
].join('\n');
}
function transcriptWithHookAttachment() {
return [
JSON.stringify({
type: 'user',
message: { role: 'user', content: 'ls' },
timestamp: '2026-05-23T10:00:00Z',
uuid: 'u-1',
sessionId: 'sess-1',
}),
JSON.stringify({
type: 'assistant',
message: {
role: 'assistant',
content: [{ type: 'tool_use', id: 't-1', name: 'Bash', input: { command: 'ls' } }],
},
timestamp: '2026-05-23T10:00:01Z',
uuid: 'u-2',
sessionId: 'sess-1',
}),
JSON.stringify({
type: 'attachment',
attachment: { type: 'hook_success', hookName: 'PreToolUse:Bash', hookEvent: 'PreToolUse' },
timestamp: '2026-05-23T10:00:01Z',
uuid: 'u-3',
sessionId: 'sess-1',
}),
].join('\n');
}
it('emits schema_version: 4', () => {
const ep = parseTranscript(transcriptDirectFeature(), 'sess-1');
expect(ep.schema_version).toBe(4);
expect(ep.schema_minor).toBe(4);
});
it('recommended_node is null on direct episode without router-state (no classifier signal)', () => {
const ep = parseTranscript(transcriptDirectFeature(), 'sess-1');
// Brain-retro #6 follow-up (2026-05-26): removed silent classification-map
// fallback. recommended_node now reflects ONLY real classifier signal from
// router-state. Direct feature episode without router-state → null, not '#19'.
expect(ep.primary_rationale.recommended_node).toBeNull();
});
it('recommended_node is null when a skill was invoked', () => {
const ep = parseTranscript(transcriptWithSkill('superpowers:writing-plans'), 'sess-1');
expect(ep.primary_rationale.recommended_node).toBeNull();
});
it('hook_fired event includes both counts and scripts keys', () => {
const ep = parseTranscript(transcriptWithHookAttachment(), 'sess-1');
const hookEvent = ep.events.find((e) => e.kind === 'hook_fired');
expect(hookEvent).toBeDefined();
expect(hookEvent.counts).toBeDefined();
expect(hookEvent.scripts).toBeDefined();
expect(typeof hookEvent.scripts).toBe('object');
});
});
describe('parseTranscript — router-state enrichment (Task 3)', () => {
function makeTranscript(sessionId) {
return [
JSON.stringify({
type: 'user',
message: { role: 'user', content: 'добавь новый endpoint /api/bar' },
timestamp: '2026-05-24T10:00:00Z',
uuid: 'u-t3-1',
sessionId,
}),
JSON.stringify({
type: 'assistant',
message: { role: 'assistant', content: [{ type: 'text', text: 'делаю' }] },
timestamp: '2026-05-24T10:00:01Z',
uuid: 'u-t3-2',
sessionId,
}),
].join('\n');
}
it('enriches primary_rationale from router-state file when present', () => {
const dir = mkdtempSync(join(tmpdir(), 'router-state-test-'));
const sessionId = 'test-session-t3-enrich';
const state = {
classification: { recommendedNode: '#42', recommendedChain: 'L13' },
chainProgress: ['step-a', 'step-b'],
chainCompleted: false,
};
writeFileSync(join(dir, `router-state-${sessionId}.json`), JSON.stringify(state));
try {
const ep = parseTranscript(makeTranscript(sessionId), sessionId, { routerStateBaseDir: dir });
expect(ep.primary_rationale.recommended_node).toBe('#42');
expect(ep.primary_rationale.recommended_chain).toBe('L13');
expect(ep.primary_rationale.chain_progress).toEqual(['step-a', 'step-b']);
expect(ep.primary_rationale.chain_completed).toBe(false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('recommended_node is null when router-state file is absent (no silent regex fallback)', () => {
const dir = mkdtempSync(join(tmpdir(), 'router-state-test-'));
const sessionId = 'test-session-t3-missing';
try {
const ep = parseTranscript(makeTranscript(sessionId), sessionId, { routerStateBaseDir: dir });
// Brain-retro #6 follow-up (2026-05-26): no router-state → recommended_node = null.
// The earlier silent classification-map fallback baked false-positive recommendations
// into the JSONL — analysis showed 60-70% «recommended_node» values were just keyword
// regex from classifyTask(prompt), not real classifier output.
expect(ep.primary_rationale.recommended_node).toBeNull();
expect(ep.primary_rationale.recommended_chain).toBeNull();
expect(ep.primary_rationale.chain_progress).toEqual([]);
expect(ep.primary_rationale.chain_completed).toBe(false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('merges router-state.task_cost (classifier tokens) into episode task_cost — A1 cost tracking', () => {
const dir = mkdtempSync(join(tmpdir(), 'router-state-test-'));
const sessionId = 'test-session-cost-merge';
const state = {
classification: { recommendedNode: '#19' },
task_cost: {
classifier_input_tokens: 4500,
classifier_output_tokens: 120,
},
};
writeFileSync(join(dir, `router-state-${sessionId}.json`), JSON.stringify(state));
try {
const ep = parseTranscript(makeTranscript(sessionId), sessionId, { routerStateBaseDir: dir });
expect(ep.task_cost.classifier_input_tokens).toBe(4500);
expect(ep.task_cost.classifier_output_tokens).toBe(120);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
// ─── Phase 3 deferred #2: parser write-block v4.3 ────────────────────────────
describe('parseTranscript — schema v4.3 write-block fields (phase 3 deferred #2)', () => {
function simpleTranscript(prompt = 'add a feature', ts = '2026-05-25T10:00:00Z', sid = 's-v43') {
return [
JSON.stringify({ type: 'user', message: { role: 'user', content: prompt }, timestamp: ts, sessionId: sid }),
JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'done' }] }, timestamp: ts, sessionId: sid }),
].join('\n');
}
it('emits schema_minor 4', () => {
const ep = parseTranscript(simpleTranscript());
expect(ep.schema_minor).toBe(4);
});
it('emits outcome_reviewed: null', () => {
const ep = parseTranscript(simpleTranscript());
expect('outcome_reviewed' in ep).toBe(true);
expect(ep.outcome_reviewed).toBeNull();
});
it('emits outcome_reviewed_source: null', () => {
const ep = parseTranscript(simpleTranscript());
expect('outcome_reviewed_source' in ep).toBe(true);
expect(ep.outcome_reviewed_source).toBeNull();
});
it('emits prompt_embedding_base64 as null when embedding model unavailable', () => {
// parser is synchronous; embedding is null by design (filled async by stop-hook)
const ep = parseTranscript(simpleTranscript());
expect('prompt_embedding_base64' in ep).toBe(true);
expect(ep.prompt_embedding_base64).toBeNull();
});
it('does not throw when transcript has unusual content', () => {
// robustness guard: parser must never throw regardless of transcript shape
expect(() => parseTranscript(simpleTranscript('', '2026-05-25T10:00:00Z'))).not.toThrow();
expect(() => parseTranscript('')).not.toThrow();
expect(() => parseTranscript('{ broken json\nnot valid')).not.toThrow();
});
it('task_cost has 8 new zero-default LLM-cost fields', () => {
const ep = parseTranscript(simpleTranscript());
const cost = ep.task_cost;
expect(typeof cost.classifier_input_tokens).toBe('number');
expect(typeof cost.classifier_output_tokens).toBe('number');
expect(typeof cost.self_assessment_input_tokens).toBe('number');
expect(typeof cost.self_assessment_output_tokens).toBe('number');
expect(typeof cost.reviewer_input_tokens).toBe('number');
expect(typeof cost.reviewer_output_tokens).toBe('number');
expect(typeof cost.reviewer_subagent_usd).toBe('number');
expect(typeof cost.reviewer_direct_fallback_usd).toBe('number');
// all default to 0
expect(cost.classifier_input_tokens).toBe(0);
expect(cost.classifier_output_tokens).toBe(0);
expect(cost.self_assessment_input_tokens).toBe(0);
expect(cost.self_assessment_output_tokens).toBe(0);
expect(cost.reviewer_input_tokens).toBe(0);
expect(cost.reviewer_output_tokens).toBe(0);
expect(cost.reviewer_subagent_usd).toBe(0);
expect(cost.reviewer_direct_fallback_usd).toBe(0);
});
it('task_cost retains all existing fields alongside new ones', () => {
const lines = [
JSON.stringify({ type: 'user', message: { role: 'user', content: 'do it' } }),
JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'ok' }], usage: { input_tokens: 100, output_tokens: 20, cache_read_input_tokens: 500, cache_creation_input_tokens: 50 } } }),
].join('\n');
const cost = parseTranscript(lines).task_cost;
expect(cost.input_tokens).toBe(100);
expect(cost.output_tokens).toBe(20);
expect(cost.cache_read_input_tokens).toBe(500);
expect(cost.cache_creation_input_tokens).toBe(50);
// new fields still 0 (populated retroactively by controller scripts)
expect(cost.classifier_input_tokens).toBe(0);
expect(cost.reviewer_subagent_usd).toBe(0);
});
});
describe('classifyFilePath — Pass 3 path-pattern bucketing (project-brain-factor-analysis-4passes)', () => {
it('classifies test files', () => {
expect(classifyFilePath('tools/foo.test.mjs')).toBe('test');
expect(classifyFilePath('app/tests/Feature/X.php')).toBe('test');
expect(classifyFilePath('resources/js/foo.spec.ts')).toBe('test');
});
it('classifies config files', () => {
expect(classifyFilePath('package.json')).toBe('config');
expect(classifyFilePath('vite.config.ts')).toBe('config');
expect(classifyFilePath('lefthook.yml')).toBe('config');
expect(classifyFilePath('.env')).toBe('config');
expect(classifyFilePath('tsconfig.json')).toBe('config');
});
it('classifies spec/plan files under docs/superpowers/', () => {
expect(classifyFilePath('docs/superpowers/specs/x.md')).toBe('spec');
expect(classifyFilePath('docs/superpowers/plans/x.md')).toBe('spec');
});
it('classifies normative documents', () => {
expect(classifyFilePath('CLAUDE.md')).toBe('norm');
expect(classifyFilePath('c:\\моя\\проекты\\портал crm\\Документация\\CLAUDE.md')).toBe('norm');
expect(classifyFilePath('docs/Pravila_raboty_Claude_v1_1.md')).toBe('norm');
expect(classifyFilePath('docs/Plugin_stack_rules_v1.md')).toBe('norm');
expect(classifyFilePath('docs/Tooling_v8_3.md')).toBe('norm');
expect(classifyFilePath('C:\\Users\\x\\.claude\\projects\\proj\\memory\\foo.md')).toBe('norm');
});
it('classifies data files', () => {
expect(classifyFilePath('docs/observer/episodes-2026-05.jsonl')).toBe('data');
expect(classifyFilePath('db/seed.csv')).toBe('data');
expect(classifyFilePath('db/schema.sql')).toBe('data');
});
it('classifies app/tools source under src', () => {
expect(classifyFilePath('app/Http/Controllers/X.php')).toBe('src');
expect(classifyFilePath('tools/router-classifier.mjs')).toBe('src');
expect(classifyFilePath('resources/js/views/Dashboard.vue')).toBe('src');
});
it('returns other for paths that fit no category', () => {
expect(classifyFilePath('some-random-binary.png')).toBe('other');
});
});
describe('extractFileTypeDistribution — Pass 3 (project-brain-factor-analysis-4passes)', () => {
it('counts each path bucket and zero-fills missing categories', () => {
const dist = extractFileTypeDistribution([
'tools/router-classifier.mjs',
'tools/router-classifier.test.mjs',
'docs/superpowers/specs/x.md',
'CLAUDE.md',
]);
expect(dist.src).toBe(1);
expect(dist.test).toBe(1);
expect(dist.spec).toBe(1);
expect(dist.norm).toBe(1);
expect(dist.config).toBe(0);
expect(dist.data).toBe(0);
expect(dist.other).toBe(0);
});
it('returns all-zero distribution for empty input', () => {
const dist = extractFileTypeDistribution([]);
for (const v of Object.values(dist)) expect(v).toBe(0);
});
});
describe('extractMcpServers — Pass 3 (project-brain-factor-analysis-4passes)', () => {
it('extracts unique mcp__<server>__* prefixes from tool_use entries', () => {
const turn = [
assistantTurn([
{ type: 'tool_use', id: 't1', name: 'mcp__github__list_issues', input: {} },
{ type: 'tool_use', id: 't2', name: 'mcp__github__get_pr', input: {} },
{ type: 'tool_use', id: 't3', name: 'mcp__playwright__browser_click', input: {} },
{ type: 'tool_use', id: 't4', name: 'Read', input: { file_path: 'a.txt' } },
], '2026-05-25T10:00:00Z'),
];
expect(extractMcpServers(turn).sort()).toEqual(['github', 'playwright']);
});
it('returns empty array when no mcp tools used', () => {
const turn = [assistantTurn([{ type: 'tool_use', id: 't1', name: 'Read', input: { file_path: 'a' } }], '2026-05-25T10:00:00Z')];
expect(extractMcpServers(turn)).toEqual([]);
});
});
describe('parseTranscript — Pass 3 task_meta block (project-brain-factor-analysis-4passes)', () => {
it('includes prompt_length_chars / mcp_servers_used / file_type_distribution under task_meta', () => {
const t = jsonl([
userPrompt('добавь функцию X в файл tools/router-classifier.mjs', '2026-05-25T10:00:00Z'),
assistantTurn([
{ type: 'tool_use', id: 't1', name: 'mcp__github__get_pr', input: {} },
{ type: 'tool_use', id: 't2', name: 'Read', input: { file_path: 'tools/router-classifier.mjs' } },
{ type: 'tool_use', id: 't3', name: 'Edit', input: { file_path: 'tools/router-classifier.test.mjs', old_string: 'a', new_string: 'b' } },
], '2026-05-25T10:01:00Z'),
]);
const ep = parseTranscript(t);
expect(ep.task_meta).toBeDefined();
expect(ep.task_meta.prompt_length_chars).toBe('добавь функцию X в файл tools/router-classifier.mjs'.length);
expect(ep.task_meta.mcp_servers_used).toEqual(['github']);
expect(ep.task_meta.file_type_distribution.src).toBe(1);
expect(ep.task_meta.file_type_distribution.test).toBe(1);
});
it('task_meta is present even on empty transcript (null-safe defaults)', () => {
const ep = parseTranscript('');
expect(ep.task_meta).toBeDefined();
expect(ep.task_meta.prompt_length_chars).toBe(0);
expect(ep.task_meta.mcp_servers_used).toEqual([]);
expect(ep.task_meta.file_type_distribution.other).toBe(0);
});
});