6192d395e4
schema_version 2 → 3. hook_fired event now carries `scripts` map (reverse-lookup .claude/settings.json + user). primary_rationale gets `recommended_node` (Tooling node ID) for direct episodes via classification-map + dormancy. Existing `counts`/skill paths unchanged — backward-compat preserved. stop-hook validator updated to accept schema_version 2 or 3; fallback builder and observer_error marker bumped to v3. 4 tests updated for schema bump; 4 new v3 tests added. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
263 lines
10 KiB
JavaScript
263 lines
10 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, writeFileSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { sanitize, sanitizeWithCount } from './observer-pii-filter.mjs';
|
|
import { parseTranscript, extractLastUserPromptText } from './observer-transcript-parser.mjs';
|
|
import { detectMethodDirected, loadKnownNodes } from './observer-routing-detector.mjs';
|
|
|
|
const REQUIRED_FIELDS = ['task_id', 'timestamps', 'path_type', 'outcome', 'primary_rationale'];
|
|
const V2_FIELDS = [
|
|
'schema_version',
|
|
'decision_provenance',
|
|
'environment',
|
|
'task_size',
|
|
'task_ref',
|
|
// C-7: prompt_signal + events are always produced by parser and buildEpisodeFromContext,
|
|
// but were previously unvalidated → a ctx-fallback path that dropped them would silently
|
|
// write a malformed episode. Strict validation closes that gap.
|
|
'prompt_signal',
|
|
'events',
|
|
];
|
|
const OBSERVER_ERROR_FIELDS = ['schema_version', 'error_message', 'timestamps', 'task_id'];
|
|
|
|
const RATIONALE_FIELDS = [
|
|
'step',
|
|
'node_chosen',
|
|
'triggers_matched',
|
|
'candidates_considered',
|
|
'boundaries_applied',
|
|
'hard_floor',
|
|
'task_classification',
|
|
];
|
|
|
|
/** Update the monthly PII counter JSON with counts from a single episode write. */
|
|
function bumpPiiCounter(counts, baseDir, month) {
|
|
const counterPath = join(baseDir, 'docs', 'observer', '.pii-counters.json');
|
|
let store = {};
|
|
if (existsSync(counterPath)) {
|
|
try { store = JSON.parse(readFileSync(counterPath, 'utf-8')); } catch { store = {}; }
|
|
}
|
|
store[month] = store[month] || {};
|
|
for (const [k, n] of Object.entries(counts)) {
|
|
if (n > 0) store[month][k] = (store[month][k] || 0) + n;
|
|
}
|
|
try { writeFileSync(counterPath, JSON.stringify(store, null, 2) + '\n', 'utf-8'); }
|
|
catch { /* counter is informational — never fail the Stop-event */ }
|
|
}
|
|
|
|
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.
|
|
* Validates either a full schema-v2 episode or a minimal observer_error marker.
|
|
* @param {object} episode - The episode object.
|
|
* @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()) {
|
|
const dir = join(baseDir, 'docs', 'observer');
|
|
if (!existsSync(dir)) {
|
|
mkdirSync(dir, { recursive: true });
|
|
}
|
|
const file = join(dir, `episodes-${month}.jsonl`);
|
|
|
|
if (episode && episode.observer_error === true) {
|
|
for (const f of OBSERVER_ERROR_FIELDS) {
|
|
if (episode[f] === undefined) {
|
|
throw new Error(`observer_error marker field missing: ${f}`);
|
|
}
|
|
}
|
|
const { sanitized: sanitizedErr, counts: countsErr } = sanitizeWithCount(episode);
|
|
appendFileSync(file, JSON.stringify(sanitizedErr) + '\n', 'utf-8');
|
|
bumpPiiCounter(countsErr, baseDir, month);
|
|
return;
|
|
}
|
|
|
|
for (const f of REQUIRED_FIELDS) {
|
|
if (episode[f] === undefined) {
|
|
throw new Error(`required field missing: ${f}`);
|
|
}
|
|
}
|
|
for (const f of V2_FIELDS) {
|
|
if (episode[f] === undefined) {
|
|
throw new Error(`schema v2 field missing: ${f}`);
|
|
}
|
|
}
|
|
if (episode.schema_version !== 2 && episode.schema_version !== 3) {
|
|
throw new Error(`schema_version must be 2 or 3 (got ${episode.schema_version})`);
|
|
}
|
|
validateRationale(episode.primary_rationale);
|
|
|
|
const { sanitized, counts } = sanitizeWithCount(episode);
|
|
appendFileSync(file, JSON.stringify(sanitized) + '\n', 'utf-8');
|
|
bumpPiiCounter(counts, baseDir, month);
|
|
}
|
|
|
|
/**
|
|
* Build a well-formed schema-v2 episode 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: v2
|
|
* defaults from `ctx` (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} v2 episode.
|
|
*/
|
|
export function buildEpisodeFromContext(ctx = {}, transcriptText = null) {
|
|
if (transcriptText) {
|
|
return parseTranscript(transcriptText, ctx.session_id || ctx.sessionId || ctx.task_id);
|
|
}
|
|
const sid = ctx.session_id || ctx.sessionId || ctx.task_id || `unknown-${Date.now()}`;
|
|
const now = new Date().toISOString();
|
|
return {
|
|
schema_version: 3,
|
|
task_id: sid,
|
|
task_ref: sid,
|
|
timestamps: {
|
|
started_at: ctx.started || ctx.started_at || now,
|
|
ended_at: ctx.ended || ctx.ended_at || now,
|
|
},
|
|
path_type: ctx.path_type || 'regulated',
|
|
outcome: ctx.result || ctx.outcome || 'unknown',
|
|
prompt_signal: ctx.prompt_signal || 'neutral',
|
|
decision_provenance: ctx.decision_provenance || { kind: 'autonomous', claude_would_have_chosen: null },
|
|
environment: ctx.environment || {
|
|
economy_level: null,
|
|
model: null,
|
|
post_compaction: false,
|
|
session_turn: 0,
|
|
parallel_session: false,
|
|
},
|
|
task_size: ctx.task_size || { tool_calls: 0, files_touched: 0, files: [] },
|
|
primary_rationale: ctx.primary_rationale || {
|
|
step: 1,
|
|
node_chosen: ctx.node_chosen || ctx.skill_id || 'unknown',
|
|
triggers_matched: [],
|
|
candidates_considered: [],
|
|
boundaries_applied: [],
|
|
hard_floor: ctx.hard_floor || { invoked: false, rules: [] },
|
|
task_classification: ctx.task_classification || 'other',
|
|
},
|
|
events: ctx.events || [],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build a minimal observer_error marker — written instead of a silent skip
|
|
* when the Stop-hook fails internally (spec §3 / §5.2).
|
|
*/
|
|
export function buildObserverError(ctx = {}, err) {
|
|
const now = new Date().toISOString();
|
|
return {
|
|
schema_version: 3,
|
|
observer_error: true,
|
|
error_message: String((err && err.message) || err),
|
|
timestamps: { started_at: now, ended_at: now },
|
|
task_id: ctx.session_id || ctx.sessionId || ctx.task_id || `unknown-${Date.now()}`,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Routing-gate decision (spec §5.1, 3a). Pure — the CLI calls this.
|
|
* Blocks the Stop-event (decision: block) when the user dictated a method
|
|
* but the turn carries no routing tag. Skipped when stop_hook_active is true
|
|
* (the gate fires at most once per turn — no infinite loop).
|
|
* @returns {{block: boolean, reason: string|null}}
|
|
*/
|
|
export function routingGateDecision(episode, promptText, knownNodes, stopHookActive) {
|
|
if (stopHookActive) return { block: false, reason: null };
|
|
// user_chose_from_options is collaborative-choice from Claude-offered options —
|
|
// not an externally directed method; no routing tag required (spec §11.4).
|
|
if (episode && episode.decision_provenance && episode.decision_provenance.kind === 'user_chose_from_options') {
|
|
return { block: false, reason: null };
|
|
}
|
|
const det = detectMethodDirected(promptText, knownNodes);
|
|
if (!det.directed) return { block: false, reason: null };
|
|
if (episode && episode.decision_provenance && episode.decision_provenance.kind === 'user_directed_method') {
|
|
return { block: false, reason: null };
|
|
}
|
|
return {
|
|
block: true,
|
|
reason:
|
|
`[observer routing-gate] Похоже, метод навязан пользователем (узел "${det.node}"), ` +
|
|
`но routing-тег в этом ходе отсутствует. Добавь в свой ответ ровно одну строку:\n` +
|
|
`<!-- routing: provenance=user_directed_method node=${det.node} ` +
|
|
`counterfactual=<узел, который ты выбрал бы автономно> -->`,
|
|
};
|
|
}
|
|
|
|
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: build a 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;
|
|
}
|
|
}
|
|
try {
|
|
const ep = buildEpisodeFromContext(ctx, transcriptText);
|
|
// Always write the episode first — exit-0-safe (spec §5.1 step 1).
|
|
appendEpisode(ep);
|
|
// Then the routing-gate (spec §5.1 steps 2-4).
|
|
if (transcriptText) {
|
|
const promptText = extractLastUserPromptText(transcriptText);
|
|
const gate = routingGateDecision(ep, promptText, loadKnownNodes(), ctx.stop_hook_active === true);
|
|
if (gate.block) {
|
|
process.stdout.write(JSON.stringify({ decision: 'block', reason: gate.reason }));
|
|
process.exit(0);
|
|
}
|
|
}
|
|
process.exit(0);
|
|
} catch (err) {
|
|
// Visible failure (spec §5.2): write an observer_error marker, never a silent skip.
|
|
try {
|
|
appendEpisode(buildObserverError(ctx, err));
|
|
} catch (_e2) {
|
|
// last-resort: even the marker failed — do not crash the Stop-event
|
|
}
|
|
console.error(`[observer-stop-hook] error: ${err.message}`);
|
|
process.exit(0); // never block the Stop-event on an internal error
|
|
}
|
|
});
|
|
}
|