Files
portal/tools/observer-stop-hook.mjs
T
Дмитрий 4382de3a79 feat(controller): C1 l1-watcher — settings.json ↔ Tooling drift detector
Pure regex/JSON, 0 LLM calls. 4 Vitest tests GREEN. Per ADR-011 + spec §6.1.

Smoke run surfaces REAL drift (DONE_WITH_CONCERNS — plan B5 said «that's
a real signal, document, don't fix here»): 9 plugins in
~/.claude/settings.json enabledPlugins NOT formalized by exact
«name@source» string in Tooling Прил. Н:
- frontend-design@claude-plugins-official (informally as #30
  «Frontend Design plugin»)
- 8× ToB plugins @trailofbits (differential-review, audit-context-
  building, supply-chain-risk-auditor, insecure-defaults, sharp-
  edges, static-analysis, variant-analysis, agentic-actions-auditor)
  informally as #39 «Trail of Bits Skills»

This is naming-vocabulary mismatch (Tooling uses human-readable
names; settings.json uses machine names). Not architectural drift.
Resolution options for follow-up:
- Add machine names as «external_id» attribute to Tooling Прил. Н rows.
- Add tools/.l1-watcher-aliases.txt with accepted machine→human map.

Until resolved: C1 will FAIL on lefthook (C5 wiring) — addressed in
C5 by adding alias mechanism OR temporarily downgrade to WARN.

Also fixed CLI guard bug in observer-stop-hook.mjs (B3) and l1-watcher
— old guard `import.meta.url === \`file://\${argv[1]}\`` did not match
on Windows (file:/// triple-slash vs file:// double-slash + relative
argv[1]). New guard: argv[1].endsWith('/<filename>.mjs').

Weekly GH Actions cron (Mon 09:00 MSK) opens issue on drift.

Vitest config extended to ../tools/*.test.mjs with exclude for ruflo-*
and subagent-prompt-prefix tests (pre-existing, not part of brain
governance).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 06:31:18 +03:00

122 lines
4.1 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),
* 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 (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
}
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
}
});
}