#!/usr/bin/env node /** * Stop-event hook for brain governance observer (B3). * 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. * * Security Guidance #40: NO exec/execSync — pure fs + sanitize. * Per Pravila §16.2 + ADR-011 + spec v1.1 §5.2.1. */ 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']; const RATIONALE_FIELDS = [ 'step', 'node_chosen', 'triggers_matched', 'candidates_considered', 'boundaries_applied', 'hard_floor', 'task_classification', ]; function validateRationale(rationale) { for (const f of RATIONALE_FIELDS) { if (rationale[f] === undefined) { throw new Error(`primary_rationale field missing: ${f}`); } } } /** * Append a single episode to the monthly JSONL file. * @param {object} episode - The episode object (5 mandatory top-level fields required). * @param {string} baseDir - Repository root (default: process.cwd()). * @param {string} month - YYYY-MM string for the file name (default: current UTC month). */ export function appendEpisode(episode, baseDir = process.cwd(), month = currentMonth()) { // Validate required top-level fields for (const f of REQUIRED_FIELDS) { if (episode[f] === undefined) { throw new Error(`required field missing: ${f}`); } } // Validate primary_rationale sub-fields validateRationale(episode.primary_rationale); // Sanitize before write const sanitized = sanitize(episode); const dir = join(baseDir, 'docs', 'observer'); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } const file = join(dir, `episodes-${month}.jsonl`); appendFileSync(file, JSON.stringify(sanitized) + '\n', 'utf-8'); } /** * Build a well-formed episode object from a Claude Code Stop-event context. * 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 = {}, transcriptText = null) { if (transcriptText) { return parseTranscript(transcriptText, ctx.session_id || ctx.sessionId || ctx.task_id); } return { 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(), }, path_type: ctx.path_type || 'regulated', outcome: ctx.result || ctx.outcome || 'success', primary_rationale: ctx.primary_rationale || { step: 1, node_chosen: ctx.node_chosen || ctx.skill_id || 'unknown', triggers_matched: ctx.triggers_matched || [], candidates_considered: ctx.candidates_considered || [], boundaries_applied: ctx.boundaries_applied || [], hard_floor: ctx.hard_floor || { invoked: false, rules: [] }, task_classification: ctx.task_classification || 'other', }, events: ctx.events || [], }; } function currentMonth() { const d = new Date(); return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`; } // CLI entry point: read JSON context from stdin (Claude Code Stop-event hook contract) if (process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/observer-stop-hook.mjs')) { const chunks = []; process.stdin.on('data', (c) => chunks.push(c)); process.stdin.on('end', () => { let ctx = {}; try { const raw = Buffer.concat(chunks).toString('utf-8'); if (raw.trim()) ctx = JSON.parse(raw); } catch (_e) { // best-effort: write minimal episode even if stdin is malformed } // 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); } catch (err) { console.error(`[observer-stop-hook] error: ${err.message}`); process.exit(0); // never block Stop-event } }); }