#!/usr/bin/env node /** * C5 observer-coverage-checker (brain governance, observer factor-analysis * spec §5.2). Warn-only — always exits 0. Two checks: * 1. Coverage — Stop-hook is registered but 0 episodes this month. * Comparing episodes against commit volume is wrong-unit (commits = * work-unit, episodes = turn-unit) and wrong-window (the C5 window * can predate the hook's registration); a freshly-registered hook * vs. 1000 historical commits would flap forever. Driven by hook * registration instead — the only honest expectation source. * 2. Registration integrity — observer Stop-hook present in * .claude/settings.json and .git/hooks/post-commit installed. * Findings are surfaced in docs/observer/STATUS.md (C4 generator); this * controller never blocks a commit. * * Security Guidance #40: pure fs — no exec/execSync. */ import { readFileSync, existsSync } from 'fs'; import { join } from 'path'; import { detectMissedActivations } from './missed-activations.mjs'; import { dedupeEpisodes } from './brain-retro-analyzer.mjs'; import { loadRegistry } from './registry-load.mjs'; import { buildClassificationMap, buildDormancyMap } from './registry-to-classification-map.mjs'; /** * @param {number} episodeCount - episodes in the current month JSONL * @param {boolean} hookRegistered - whether observer-stop-hook is wired in settings * @returns {{ok: boolean, detail: string}} */ export function checkCoverage(episodeCount, hookRegistered) { if (hookRegistered && episodeCount === 0) { return { ok: false, detail: `Stop-hook registered but 0 episode(s) recorded this month — hook may be silently failing`, }; } return { ok: true, detail: `${episodeCount} episode(s) this month` }; } /** @returns {{ok: boolean, detail: string}} */ export function checkRegistration(settingsJson, postCommitExists) { const problems = []; const stopHooks = (((settingsJson || {}).hooks || {}).Stop) || []; const hasObserverStop = stopHooks.some((entry) => ((entry && entry.hooks) || []).some((h) => String((h && h.command) || '').includes('observer-stop-hook')) ); if (!hasObserverStop) { problems.push('observer-stop-hook NOT registered in .claude/settings.json Stop hook'); } if (!postCommitExists) { problems.push('.git/hooks/post-commit not installed (run: npx lefthook install --force)'); } return { ok: problems.length === 0, detail: problems.length ? problems.join('; ') : 'Stop-hook + post-commit OK', }; } function countEpisodes(root) { const month = new Date().toISOString().slice(0, 7); const file = join(root, 'docs', 'observer', `episodes-${month}.jsonl`); if (!existsSync(file)) return 0; return readFileSync(file, 'utf-8').trim().split('\n').filter(Boolean).length; } function loadEpisodes(root) { const month = new Date().toISOString().slice(0, 7); const file = join(root, 'docs', 'observer', `episodes-${month}.jsonl`); if (!existsSync(file)) return []; const out = []; for (const line of readFileSync(file, 'utf-8').split('\n')) { const t = line.trim(); if (!t) continue; try { out.push(JSON.parse(t)); } catch { /* skip */ } } return out; } function loadClassificationMap(root) { try { const registry = loadRegistry({ registryPath: join(root, 'docs', 'registry', 'nodes.yaml'), schemaPath: join(root, 'docs', 'registry', 'schema.json'), useCache: false, }); return buildClassificationMap(registry); } catch { return {}; } } function loadDormancy(root) { try { const registry = loadRegistry({ registryPath: join(root, 'docs', 'registry', 'nodes.yaml'), schemaPath: join(root, 'docs', 'registry', 'schema.json'), useCache: false, }); return buildDormancyMap(registry); } catch { return {}; } } function readSettings(root) { try { return JSON.parse(readFileSync(join(root, '.claude', 'settings.json'), 'utf-8')); } catch { return {}; } } function isObserverStopRegistered(settings) { const stopHooks = (((settings || {}).hooks || {}).Stop) || []; return stopHooks.some((entry) => ((entry && entry.hooks) || []).some((h) => String((h && h.command) || '').includes('observer-stop-hook') ) ); } export function runCoverageChecker(root = process.cwd()) { const settings = readSettings(root); const hookRegistered = isObserverStopRegistered(settings); const coverage = checkCoverage(countEpisodes(root), hookRegistered); const registration = checkRegistration(settings, existsSync(join(root, '.git', 'hooks', 'post-commit'))); const episodes = loadEpisodes(root).filter((e) => e && e.schema_version === 2 && !e.observer_error); const missed = detectMissedActivations( dedupeEpisodes(episodes), loadClassificationMap(root), loadDormancy(root) ); return { coverage, registration, missed }; } if (process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/observer-coverage-checker.mjs')) { const { coverage, registration, missed } = runCoverageChecker(); if (!coverage.ok) console.warn(`[observer-coverage-checker] WARN — coverage: ${coverage.detail}`); if (!registration.ok) console.warn(`[observer-coverage-checker] WARN — registration: ${registration.detail}`); if (missed.totalMissed > 0) { console.warn(`[observer-coverage-checker] WARN — missed activations: ${missed.totalMissed} (see /brain-retro)`); } if (coverage.ok && registration.ok && missed.totalMissed === 0) { console.log(`[observer-coverage-checker] OK — ${coverage.detail}; ${registration.detail}`); } process.exit(0); // warn-only — never blocks a commit }