Files
portal/tools/observer-coverage-checker.mjs
T
Дмитрий 3ec638cbd2 fix(observer): C5 coverage driven by hook registration, drop commit ratio (COV-1)
Bug: checkCoverage flagged anomaly when "recent commits > 0 AND episodes == 0".
Two design flaws, proven in this project:
- Wrong unit: commits = work-unit (one turn → many commits via subagent
  workflow); episodes = turn-unit. A 1023-vs-19 ratio is not anomalous, it's
  expected.
- Wrong window: the 14-day commit window predated the Stop-hook's existence
  (registered 2026-05-19). For 13 of 14 days the hook didn't exist — 889
  commits were structurally impossible to mirror as episodes.

Result: the C5 indicator was either always-red (flagging the hook's birth
as anomaly) or always-green (any episode count vs huge commit count = ok).
Either way uninformative.

Fix:
- checkCoverage(episodeCount, hookRegistered) — drops the commit param.
  Warn iff hook is registered AND 0 episodes this month → the hook is
  silently failing. If the hook isn't registered, 0 episodes is correct.
- runCoverageChecker derives hookRegistered from settings.json
  (isObserverStopRegistered helper) and passes it to checkCoverage.
  No more git execFileSync — pure fs.

Tests rewritten under the new contract: 7/7 (was 6, +1 drift-hazard guard
ensuring detail strings never mention "commit"). 15/15 coverage tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:07:58 +03:00

96 lines
3.8 KiB
JavaScript

#!/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';
/**
* @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 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')));
return { coverage, registration };
}
if (process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/observer-coverage-checker.mjs')) {
const { coverage, registration } = 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 (coverage.ok && registration.ok) {
console.log(`[observer-coverage-checker] OK — ${coverage.detail}; ${registration.detail}`);
}
process.exit(0); // warn-only — never blocks a commit
}