#!/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, statSync } from 'fs'; import { join, resolve } 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', }; } /** * Общая папка репозитория. В обычной рабочей копии это сам `.git`; в рабочей * папке ветки (git worktree) `.git` — файл-указатель `gitdir: <путь>`, а рядом * с указанной папкой лежит `commondir` с дорогой до общей папки репозитория. * @param {string} root * @returns {string|null} */ function gitCommonDir(root) { const dot = join(root, '.git'); if (!existsSync(dot)) return null; let st; try { st = statSync(dot); } catch { return null; } if (st.isDirectory()) return dot; let gitdir; try { const m = /^gitdir:\s*(.+)$/m.exec(readFileSync(dot, 'utf-8')); if (!m) return null; gitdir = resolve(root, m[1].trim()); } catch { return null; } const common = join(gitdir, 'commondir'); if (!existsSync(common)) return gitdir; try { return resolve(gitdir, readFileSync(common, 'utf-8').trim()); } catch { return gitdir; } } /** * Настройка core.hooksPath переносит папку хуков в другое место. * @returns {string|null} */ function hooksPathFromConfig(commonDir, root) { const cfg = join(commonDir, 'config'); if (!existsSync(cfg)) return null; try { const m = /^\s*hooksPath\s*=\s*(.+)$/m.exec(readFileSync(cfg, 'utf-8')); return m ? resolve(root, m[1].trim()) : null; } catch { return null; } } /** * Установлен ли хук post-commit. Учитывает рабочие папки веток и core.hooksPath — * иначе из worktree сторож штампует ложную тревогу «post-commit not installed». * @param {string} root * @returns {boolean} */ export function postCommitInstalled(root) { const commonDir = gitCommonDir(root); if (!commonDir) return false; const hooksDir = hooksPathFromConfig(commonDir, root) || join(commonDir, 'hooks'); return existsSync(join(hooksDir, 'post-commit')); } 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, postCommitInstalled(root)); 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 }