99c7bac99b
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>
165 lines
6.5 KiB
JavaScript
165 lines
6.5 KiB
JavaScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { writeFileSync, readFileSync, existsSync, mkdtempSync, rmSync, mkdirSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import { appendEpisode, buildEpisodeFromContext } from './observer-stop-hook.mjs';
|
|
|
|
let workdir;
|
|
|
|
beforeEach(() => {
|
|
workdir = mkdtempSync(join(tmpdir(), 'observer-test-'));
|
|
mkdirSync(join(workdir, 'docs', 'observer'), { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
});
|
|
|
|
// Helper for v1.1 — primary_rationale fixture (factor analysis amendment)
|
|
const defaultRat = () => ({
|
|
step: 1,
|
|
node_chosen: '#1',
|
|
triggers_matched: [],
|
|
candidates_considered: [],
|
|
boundaries_applied: [],
|
|
hard_floor: { invoked: false, rules: [] },
|
|
task_classification: 'other',
|
|
});
|
|
|
|
describe('appendEpisode', () => {
|
|
it('appends one JSONL line to monthly file', () => {
|
|
const ep = {
|
|
task_id: 'abc-123',
|
|
timestamps: { started_at: '2026-05-19T10:00:00+03:00', ended_at: '2026-05-19T10:05:00+03:00' },
|
|
path_type: 'regulated',
|
|
outcome: 'success',
|
|
primary_rationale: defaultRat(),
|
|
};
|
|
appendEpisode(ep, workdir, '2026-05');
|
|
const file = join(workdir, 'docs', 'observer', 'episodes-2026-05.jsonl');
|
|
const content = readFileSync(file, 'utf-8');
|
|
expect(content).toContain('"task_id":"abc-123"');
|
|
expect(content).toContain('"primary_rationale"');
|
|
expect(content.endsWith('\n')).toBe(true);
|
|
});
|
|
|
|
it('appends to existing file without overwrite', () => {
|
|
appendEpisode({ task_id: 'a', timestamps: {}, path_type: 'regulated', outcome: 'success', primary_rationale: defaultRat() }, workdir, '2026-05');
|
|
appendEpisode({ task_id: 'b', timestamps: {}, path_type: 'improvised', outcome: 'partial', primary_rationale: defaultRat() }, workdir, '2026-05');
|
|
const lines = readFileSync(join(workdir, 'docs', 'observer', 'episodes-2026-05.jsonl'), 'utf-8').trim().split('\n');
|
|
expect(lines).toHaveLength(2);
|
|
expect(JSON.parse(lines[0]).task_id).toBe('a');
|
|
expect(JSON.parse(lines[1]).task_id).toBe('b');
|
|
});
|
|
|
|
it('applies PII filter before write (including events[])', () => {
|
|
appendEpisode({
|
|
task_id: 'c',
|
|
timestamps: {},
|
|
path_type: 'regulated',
|
|
outcome: 'success',
|
|
primary_rationale: defaultRat(),
|
|
events: [{ kind: 'error', message: 'call +79991234567 / mail x@y.com' }],
|
|
}, workdir, '2026-05');
|
|
const content = readFileSync(join(workdir, 'docs', 'observer', 'episodes-2026-05.jsonl'), 'utf-8');
|
|
expect(content).toContain('+7XXXXXXXXXX');
|
|
expect(content).toContain('***@***');
|
|
expect(content).not.toContain('79991234567');
|
|
});
|
|
|
|
it('throws on missing required top-level fields', () => {
|
|
expect(() => appendEpisode({}, workdir, '2026-05')).toThrow(/required/i);
|
|
expect(() => appendEpisode({ task_id: 'x' }, workdir, '2026-05')).toThrow(/required/i);
|
|
expect(() => appendEpisode({ task_id: 'x', timestamps: {}, path_type: 'regulated', outcome: 'success' }, workdir, '2026-05')).toThrow(/primary_rationale/i);
|
|
});
|
|
|
|
it('throws when primary_rationale field is missing', () => {
|
|
const ep = {
|
|
task_id: 'd',
|
|
timestamps: {},
|
|
path_type: 'regulated',
|
|
outcome: 'success',
|
|
primary_rationale: { step: 1, node_chosen: '#1' }, // missing other 5 fields
|
|
};
|
|
expect(() => appendEpisode(ep, workdir, '2026-05')).toThrow(/primary_rationale field missing/i);
|
|
});
|
|
|
|
it('persists routing_decision events with structured fields', () => {
|
|
appendEpisode({
|
|
task_id: 'e',
|
|
timestamps: {},
|
|
path_type: 'regulated',
|
|
outcome: 'success',
|
|
primary_rationale: defaultRat(),
|
|
events: [
|
|
{ kind: 'routing_decision', step: 1, node_chosen: '#55', triggers_matched: ['discovery'],
|
|
candidates_considered: [{ node_id: '#53', dropped_because: 'ADR-009' }],
|
|
boundaries_applied: ['ADR-009'], hard_floor: { invoked: false, rules: [] },
|
|
task_classification: 'discovery' },
|
|
],
|
|
}, workdir, '2026-05');
|
|
const line = JSON.parse(readFileSync(join(workdir, 'docs', 'observer', 'episodes-2026-05.jsonl'), 'utf-8').trim());
|
|
expect(line.events[0].kind).toBe('routing_decision');
|
|
expect(line.events[0].triggers_matched).toEqual(['discovery']);
|
|
expect(line.events[0].candidates_considered[0].dropped_because).toBe('ADR-009');
|
|
expect(line.events[0].boundaries_applied).toEqual(['ADR-009']);
|
|
});
|
|
});
|
|
|
|
describe('buildEpisodeFromContext', () => {
|
|
it('extracts 5 mandatory fields from context object', () => {
|
|
const ctx = {
|
|
sessionId: 'sess-1',
|
|
started: '2026-05-19T09:00:00+03:00',
|
|
ended: '2026-05-19T09:30:00+03:00',
|
|
result: 'success',
|
|
};
|
|
const ep = buildEpisodeFromContext(ctx);
|
|
expect(ep.task_id).toBe('sess-1');
|
|
expect(ep.timestamps.started_at).toBe(ctx.started);
|
|
expect(ep.outcome).toBe('success');
|
|
expect(['regulated', 'improvised', 'alternative', 'mixed']).toContain(ep.path_type);
|
|
expect(ep.primary_rationale).toBeDefined();
|
|
expect(ep.primary_rationale.step).toBe(1);
|
|
expect(ep.primary_rationale.hard_floor).toEqual({ invoked: false, rules: [] });
|
|
});
|
|
|
|
it('preserves user-provided primary_rationale unchanged', () => {
|
|
const rat = {
|
|
step: 1, node_chosen: '#55', triggers_matched: ['discovery'],
|
|
candidates_considered: [], boundaries_applied: ['ADR-009'],
|
|
hard_floor: { invoked: true, rules: ['Pravila §12'] },
|
|
task_classification: 'discovery',
|
|
};
|
|
const ep = buildEpisodeFromContext({ sessionId: 'x', primary_rationale: rat });
|
|
expect(ep.primary_rationale).toEqual(rat);
|
|
});
|
|
|
|
it('derives the episode from transcriptText when provided', () => {
|
|
const transcript = [
|
|
JSON.stringify({
|
|
type: 'user',
|
|
message: { role: 'user', content: 'fix the bug' },
|
|
timestamp: '2026-05-19T10:00:00Z',
|
|
sessionId: 'sess-t',
|
|
}),
|
|
JSON.stringify({
|
|
type: 'assistant',
|
|
message: {
|
|
role: 'assistant',
|
|
content: [
|
|
{ type: 'tool_use', id: 't1', name: 'Skill', input: { skill: 'superpowers:systematic-debugging' } },
|
|
],
|
|
},
|
|
timestamp: '2026-05-19T10:01:00Z',
|
|
sessionId: 'sess-t',
|
|
}),
|
|
].join('\n');
|
|
const ep = buildEpisodeFromContext({ session_id: 'sess-t' }, transcript);
|
|
expect(ep.task_id).toBe('sess-t');
|
|
expect(ep.primary_rationale.node_chosen).toBe('superpowers:systematic-debugging');
|
|
expect(ep.primary_rationale.hard_floor.invoked).toBe(true);
|
|
expect(ep.events.some((e) => e.kind === 'skill_invoked')).toBe(true);
|
|
});
|
|
});
|