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>
149 lines
5.0 KiB
JavaScript
149 lines
5.0 KiB
JavaScript
#!/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,
|
|
};
|
|
}
|