8f00449615
Работа 1 промта от 01.08 — оживление сторожей текста. По ходу вскрылся отдельный дефект в нашем собственном скрипте. observer-coverage-checker при коммите из рабочей папки ветки печатал ".git/hooks/post-commit not installed". Файл при этом лежал на месте. Причина: в рабочей папке ветки .git не папка, а файл-указатель, и проверка пути <корень>/.git/hooks/post-commit смотрела мимо цели. Плюс настройка core.hooksPath, которая в этом репозитории задана, вообще не учитывалась. Класс беды тот же, что уже дважды записан в память: сторож, не умеющий отличить "нет" от "лежит в другом месте", штампует ложные тревоги. Рядом в той же строке живёт правдивая жалоба про неподключённый обработчик - и ложная половина приучает не читать всю строку целиком. Что сделано: - добавлена postCommitInstalled: сначала ищет общую папку репозитория (для обычной копии это сам .git, для рабочей папки ветки - по указателю gitdir и файлу commondir рядом с ним), затем учитывает core.hooksPath из config и только потом смотрит наличие файла; - чистое чтение файлов, без запуска сторонних команд - требование Security Guidance 40 для этого скрипта соблюдено. Порядок работы соблюдён. Пять проверок написаны ДО правки и покраснели на отсутствующей функции. Разобраны обе стороны каждой развилки: обычная копия с хуком и без, рабочая папка ветки с хуком и без, перенос хуков настройкой. Живой прогон по настоящему репозиторию: из рабочей папки ветки ложная половина ушла, правдивая осталась. Прогоны: набор проверок инструментов 14 файлов, 66 проверок, все зелёные. Отдельно, без правок в git: node_modules был в состоянии оборванной установки - 121 временная папка распаковки, 90 пакетов без главного файла, у tinyglobby файл обрезан посреди строки. Причина обрыва - падение сборки better-sqlite3. Пересобрано начисто из списка версий без сборочных сценариев; package.json и package-lock.json не тронуты, проверено хешем. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
194 lines
7.5 KiB
JavaScript
194 lines
7.5 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, 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
|
||
}
|