feat(observer): Stop-event hook — JSONL append with PII filter + primary_rationale validation
Hook contract: reads JSON ctx from stdin (Claude Code Stop-event),
builds episode with 5 mandatory fields including primary_rationale
(7 sub-fields per spec v1.1 §5.2.1), sanitizes via observer-pii-filter,
appends to docs/observer/episodes-YYYY-MM.jsonl. Never blocks
Stop-event (exit 0 on error).
8 Vitest tests verified GREEN (6 in appendEpisode + 2 in
buildEpisodeFromContext): append/append-existing/PII-filter/
missing-required/missing-rationale-field/routing_decision-preserved
+ buildEpisode 5-field extraction + user-rationale-preserved.
Vitest config for tools/ already covers via glob ../tools/observer-*.test.mjs
(extended in B2 commit 4616308).
Per Pravila §16.2 + ADR-011 + spec v1.1 §5.2.1 (factor analysis).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user