Files
portal/tools/observer-transcript-parser.test.mjs
T
Дмитрий 99c7bac99b feat(brain): observer captures real session data via transcript parse
The Stop-hook was writing empty-shell episodes (task_id "unknown-<ts>",
node_chosen "unknown", events []). Root cause: buildEpisodeFromContext
read fields from the Stop-event stdin that Claude Code never sends
(primary_rationale, node_chosen, ...) and the session field name was
wrong (ctx.sessionId camelCase vs Claude Code's session_id). The hook
never read transcript_path — the only real source of session data.

New tools/observer-transcript-parser.mjs — pure parseTranscript(text,
fallbackSessionId):
- Scopes to the last turn (from the last real user prompt to EOF) —
  one episode == one prompt→response cycle. A tool_result-carrier user
  message is not treated as a turn boundary.
- Extracts task_id (real sessionId), timestamps (real duration),
  skill_invoked events, a tool_summary event with per-tool counts,
  error events (tool_result is_error), node_chosen (first skill, else
  "direct"), hard_floor (invoked when a superpowers:* skill is used),
  path_type (regulated/improvised), task_classification (keyword
  heuristic on the prompt).
- Reasoning fields triggers_matched/candidates_considered/
  boundaries_applied stay [] — not recoverable from a transcript;
  their capture is a separate ADR-011 follow-up.

observer-stop-hook.mjs: reads ctx.transcript_path + ctx.session_id
(camelCase fallback kept), readFileSync best-effort, delegates to
parseTranscript. No transcript → graceful fallback to ctx defaults.
Episode schema (5 mandatory + 7-field primary_rationale) unchanged —
no normative change. Stop-event is never blocked (exit 0 on any error).

TDD: 17 parseTranscript tests + 1 buildEpisodeFromContext transcript
test. Full tools Vitest 70/70 GREEN. CLI smoke against a real 575-entry
transcript: episode populated — real task_id, ~6.5 min duration,
tool_summary {Bash:5,Read:5,Grep:1,Edit:9,Write:1}, error event.

Refs: ADR-011 brain governance §6.2 (observer evidence loop).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:11:10 +03:00

218 lines
8.4 KiB
JavaScript

import { describe, it, expect } from 'vitest';
import { parseTranscript } from './observer-transcript-parser.mjs';
// 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('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'),
]);
expect(parseTranscript(t).events.filter((e) => e.kind === 'error')).toHaveLength(1);
});
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('success');
});
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();
}
});
});