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>
143 lines
5.2 KiB
JavaScript
143 lines
5.2 KiB
JavaScript
#!/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
|
|
}
|
|
});
|
|
}
|