diff --git a/tools/observer-stop-hook.mjs b/tools/observer-stop-hook.mjs new file mode 100644 index 00000000..ebe6f2c6 --- /dev/null +++ b/tools/observer-stop-hook.mjs @@ -0,0 +1,121 @@ +#!/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. + * + * 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 } from 'fs'; +import { join } from 'path'; +import { sanitize } from './observer-pii-filter.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. + * If ctx already contains primary_rationale it is preserved verbatim. + * @param {object} ctx - Raw context from stdin (may be partial). + * @returns {object} Episode with 5 mandatory fields. + */ +export function buildEpisodeFromContext(ctx = {}) { + return { + task_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 (import.meta.url === `file://${process.argv[1].replace(/\\/g, '/')}`) { + 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 + } + const ep = buildEpisodeFromContext(ctx); + try { + appendEpisode(ep); + process.exit(0); + } catch (err) { + console.error(`[observer-stop-hook] error: ${err.message}`); + process.exit(0); // never block Stop-event + } + }); +} diff --git a/tools/observer-stop-hook.test.mjs b/tools/observer-stop-hook.test.mjs new file mode 100644 index 00000000..1b7ae79b --- /dev/null +++ b/tools/observer-stop-hook.test.mjs @@ -0,0 +1,137 @@ +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); + }); +});