From 7108406ccafdb407d838b92368bd0d60c85d31ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 19 May 2026 08:11:10 +0300 Subject: [PATCH] feat(brain): observer captures real session data via transcript parse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Stop-hook was writing empty-shell episodes (task_id "unknown-", 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) --- docs/observer/README.md | 4 +- tools/observer-stop-hook.mjs | 39 +++- tools/observer-stop-hook.test.mjs | 27 +++ tools/observer-transcript-parser.mjs | 148 +++++++++++++++ tools/observer-transcript-parser.test.mjs | 217 ++++++++++++++++++++++ 5 files changed, 424 insertions(+), 11 deletions(-) create mode 100644 tools/observer-transcript-parser.mjs create mode 100644 tools/observer-transcript-parser.test.mjs diff --git a/docs/observer/README.md b/docs/observer/README.md index 5932f059..4c5a5183 100644 --- a/docs/observer/README.md +++ b/docs/observer/README.md @@ -4,14 +4,14 @@ Passive evidence-loop for the Лидерра «brain» per ADR-011. ## Files -- `episodes-YYYY-MM.jsonl` — append-only JSONL, one line per Claude session. Written by `tools/observer-stop-hook.mjs` on Stop-event. +- `episodes-YYYY-MM.jsonl` — append-only JSONL, one line per Stop-event (one prompt→response turn). Written by `tools/observer-stop-hook.mjs`, which parses the session transcript (`transcript_path`) via `tools/observer-transcript-parser.mjs`. - `notes/YYYY-MM-DD-.md` — optional MD notes for sessions with qualitative history. - `STATUS.md` — auto-generated dashboard. Regenerated per-commit by `tools/status-md-generator.mjs`. - `.read-counter.json` — C3 observer-of-observer counter. Updated on Read of observer files. ## Lifecycle -1. **Write**: every Claude session ends with a JSONL append (Stop-hook). +1. **Write**: every Stop-event appends one JSONL line, parsed from the session transcript (Stop-hook). 2. **Aggregate**: `/brain-retro` skill reads JSONL each sprint, proposes regulatory candidates. 3. **Surface**: `STATUS.md` shows controllers + monthly stats. 4. **Self-prune**: C3 warns if 54 weeks pass without any read of observer files. diff --git a/tools/observer-stop-hook.mjs b/tools/observer-stop-hook.mjs index e4896874..496c76d9 100644 --- a/tools/observer-stop-hook.mjs +++ b/tools/observer-stop-hook.mjs @@ -1,10 +1,12 @@ #!/usr/bin/env node /** * Stop-event hook for brain governance observer (B3). - * Reads JSON context from stdin (Claude Code Stop-event hook contract), - * builds an episode with 5 mandatory fields including primary_rationale - * (7 sub-fields per spec v1.1 §5.2.1), sanitizes via PII filter, - * appends to docs/observer/episodes-YYYY-MM.jsonl. + * Reads JSON context from stdin (Claude Code Stop-event hook contract). + * When the context provides `transcript_path`, the episode is derived from + * the real session transcript via parseTranscript; otherwise it falls back + * to best-effort defaults. Builds an episode with 5 mandatory fields + * including primary_rationale (7 sub-fields per spec v1.1 §5.2.1), + * sanitizes via PII filter, appends to docs/observer/episodes-YYYY-MM.jsonl. * * Never blocks the Stop-event — exits 0 on any error. * @@ -12,9 +14,10 @@ * Per Pravila §16.2 + ADR-011 + spec v1.1 §5.2.1. */ -import { appendFileSync, existsSync, mkdirSync } from 'fs'; +import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'fs'; import { join } from 'path'; import { sanitize } from './observer-pii-filter.mjs'; +import { parseTranscript } from './observer-transcript-parser.mjs'; const REQUIRED_FIELDS = ['task_id', 'timestamps', 'path_type', 'outcome', 'primary_rationale']; @@ -66,13 +69,20 @@ export function appendEpisode(episode, baseDir = process.cwd(), month = currentM /** * Build a well-formed episode object from a Claude Code Stop-event context. - * If ctx already contains primary_rationale it is preserved verbatim. + * Preferred path: when `transcriptText` is supplied, the episode is derived + * from the real session transcript via parseTranscript. Fallback path: when + * no transcript is available, best-effort defaults are read from `ctx` + * (and an explicit ctx.primary_rationale is preserved verbatim). * @param {object} ctx - Raw context from stdin (may be partial). + * @param {string|null} transcriptText - Raw transcript JSONL, if readable. * @returns {object} Episode with 5 mandatory fields. */ -export function buildEpisodeFromContext(ctx = {}) { +export function buildEpisodeFromContext(ctx = {}, transcriptText = null) { + if (transcriptText) { + return parseTranscript(transcriptText, ctx.session_id || ctx.sessionId || ctx.task_id); + } return { - task_id: ctx.sessionId || ctx.task_id || `unknown-${Date.now()}`, + task_id: ctx.session_id || ctx.sessionId || ctx.task_id || `unknown-${Date.now()}`, timestamps: { started_at: ctx.started || ctx.started_at || new Date().toISOString(), ended_at: ctx.ended || ctx.ended_at || new Date().toISOString(), @@ -109,7 +119,18 @@ if (process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/observer-s } catch (_e) { // best-effort: write minimal episode even if stdin is malformed } - const ep = buildEpisodeFromContext(ctx); + // Claude Code's Stop-event supplies transcript_path — the real source of + // session data. Read it best-effort; fall back to ctx-only on any error. + let transcriptText = null; + const tp = ctx.transcript_path || ctx.transcriptPath; + if (tp) { + try { + if (existsSync(tp)) transcriptText = readFileSync(tp, 'utf-8'); + } catch (_e) { + transcriptText = null; + } + } + const ep = buildEpisodeFromContext(ctx, transcriptText); try { appendEpisode(ep); process.exit(0); diff --git a/tools/observer-stop-hook.test.mjs b/tools/observer-stop-hook.test.mjs index 1b7ae79b..a07335db 100644 --- a/tools/observer-stop-hook.test.mjs +++ b/tools/observer-stop-hook.test.mjs @@ -134,4 +134,31 @@ describe('buildEpisodeFromContext', () => { 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); + }); }); diff --git a/tools/observer-transcript-parser.mjs b/tools/observer-transcript-parser.mjs new file mode 100644 index 00000000..3f4369b5 --- /dev/null +++ b/tools/observer-transcript-parser.mjs @@ -0,0 +1,148 @@ +#!/usr/bin/env node +/** + * Transcript parser for the brain governance observer. + * Deterministically extracts episode fields from a Claude Code session + * transcript (JSONL). No LLM — pure parsing. + * + * Scope: the last turn (from the last real user prompt to end of file) — + * one episode == one prompt→response cycle. + * + * Reasoning fields (triggers_matched / candidates_considered / + * boundaries_applied) are NOT recoverable from a transcript and stay []; + * their capture is a separate design question (ADR-011 follow-up). + * + * Security Guidance #40: pure parsing — no exec/execSync. + * Per ADR-011 §6 + spec v1.1 §5.2.1. + */ + +const SUPERPOWERS_PREFIX = 'superpowers:'; + +function parseLines(text) { + const entries = []; + for (const line of String(text || '').split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + entries.push(JSON.parse(trimmed)); + } catch { + // broken line — skip, never throw + } + } + return entries; +} + +// A genuine user prompt (turn boundary) — not a tool_result carrier message. +function isRealUserPrompt(entry) { + const msg = entry && entry.message; + if (!msg || msg.role !== 'user') return false; + const c = msg.content; + if (typeof c === 'string') return c.trim().length > 0; + if (Array.isArray(c)) { + const hasToolResult = c.some((b) => b && b.type === 'tool_result'); + const hasText = c.some((b) => b && b.type === 'text'); + return hasText && !hasToolResult; + } + return false; +} + +function findTurnStart(entries) { + for (let i = entries.length - 1; i >= 0; i--) { + if (isRealUserPrompt(entries[i])) return i; + } + return 0; +} + +function promptText(entry) { + const c = entry && entry.message && entry.message.content; + if (typeof c === 'string') return c; + if (Array.isArray(c)) { + return c + .filter((b) => b && b.type === 'text') + .map((b) => b.text || '') + .join(' '); + } + return ''; +} + +export function classifyTask(text) { + const t = String(text || '').toLowerCase(); + if (/рефактор|refactor/.test(t)) return 'refactor'; + if (/баг|bug|почини|исправ|fix\b|сломан|broken/.test(t)) return 'bugfix'; + if (/фич|feature|добав|implement|реализ|создай|create|новый|new /.test(t)) return 'feature'; + if (/докум|readme|\bdocs?\b/.test(t)) return 'docs'; + if (/\?|как |что |почему|зачем|why|how |what /.test(t)) return 'question'; + return 'other'; +} + +function collectToolUse(entries) { + const skills = []; + const counts = {}; + let errorCount = 0; + for (const e of entries) { + const content = e && e.message && Array.isArray(e.message.content) ? e.message.content : []; + for (const block of content) { + if (!block || typeof block !== 'object') continue; + if (block.type === 'tool_use') { + const name = block.name || 'unknown'; + counts[name] = (counts[name] || 0) + 1; + if (name === 'Skill') { + skills.push((block.input && block.input.skill) || 'unknown'); + } + } else if (block.type === 'tool_result' && block.is_error === true) { + errorCount += 1; + } + } + } + return { skills, counts, errorCount }; +} + +/** + * Parse a transcript JSONL string into observer episode fields. + * @param {string} transcriptText - Raw JSONL transcript contents. + * @param {string|null} fallbackSessionId - Used when the transcript has no sessionId. + * @returns {object} Episode with 5 mandatory fields + events. + */ +export function parseTranscript(transcriptText, fallbackSessionId = null) { + const entries = parseLines(transcriptText); + + const withSession = entries.find((e) => e && e.sessionId); + const sessionId = + (withSession && withSession.sessionId) || fallbackSessionId || `unknown-${Date.now()}`; + + const start = findTurnStart(entries); + const turn = entries.slice(start); + + const stamps = turn.map((e) => e && e.timestamp).filter(Boolean); + const started_at = stamps[0] || new Date().toISOString(); + const ended_at = stamps[stamps.length - 1] || started_at; + + const { skills, counts, errorCount } = collectToolUse(turn); + + const events = []; + for (const skill of skills) events.push({ kind: 'skill_invoked', skill }); + if (Object.keys(counts).length > 0) events.push({ kind: 'tool_summary', counts }); + for (let i = 0; i < errorCount; i++) { + events.push({ kind: 'error', message: 'tool_result reported is_error' }); + } + + const usedSuperpowers = skills.some((s) => String(s).startsWith(SUPERPOWERS_PREFIX)); + + return { + task_id: sessionId, + timestamps: { started_at, ended_at }, + path_type: usedSuperpowers ? 'regulated' : 'improvised', + outcome: 'success', + primary_rationale: { + step: 1, + node_chosen: skills.length > 0 ? skills[0] : 'direct', + triggers_matched: [], + candidates_considered: [], + boundaries_applied: [], + hard_floor: usedSuperpowers + ? { invoked: true, rules: ['Pravila §12'] } + : { invoked: false, rules: [] }, + task_classification: classifyTask(promptText(entries[start])), + }, + events, + }; +} diff --git a/tools/observer-transcript-parser.test.mjs b/tools/observer-transcript-parser.test.mjs new file mode 100644 index 00000000..5442a965 --- /dev/null +++ b/tools/observer-transcript-parser.test.mjs @@ -0,0 +1,217 @@ +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(); + } + }); +});