Files
brain/tools/observer-hook-resolver.mjs

98 lines
3.6 KiB
JavaScript

#!/usr/bin/env node
/**
* Hook resolver for the brain governance observer.
* Reverse-lookup .claude/settings.json (+ ~/.claude/settings.json):
* matcher (event:tool) → list of hook-script names.
*
* Pure — no exec, no fs side-effects (Security Guidance #40).
* Caller is responsible for reading the JSON; this module operates on
* already-parsed settings objects.
*
* Per spec: docs/superpowers/specs/2026-05-23-observer-parser-skill-hook-expand-design.md
*/
import { createHash } from 'node:crypto';
const TOOL_SCRIPT_RE = /(?:^|[\s"'/\\])(tools[\/\\][\w-]+\.(?:mjs|py|sh))/;
const NPX_RE = /(?:^|[\s"'])npx\s+(?:-y\s+)?([\w@/.-]+)/;
/**
* Normalize a command string for stable hashing:
* - strip surrounding whitespace
* - collapse internal whitespace runs to single space
* No lowercase (script names are case-sensitive in Windows-aware contexts).
*/
function normalizeCommand(s) {
return String(s || '').trim().replace(/\s+/g, ' ');
}
/**
* Extract a stable, human-readable identifier from a hook command string.
* Priority: tools/X.{mjs,py,sh} → npx <pkg> → inline:<sha-16>.
*/
export function extractScriptName(command) {
const cmd = String(command || '');
const toolMatch = cmd.match(TOOL_SCRIPT_RE);
if (toolMatch) return toolMatch[1].replace(/\\/g, '/');
const npxMatch = cmd.match(NPX_RE);
if (npxMatch) return npxMatch[1];
const sha = createHash('sha256').update(normalizeCommand(cmd)).digest('hex').slice(0, 16);
return `inline:${sha}`;
}
/**
* Build matcher → [scriptName, ...] from one or two settings objects.
* Matcher key format:
* - "<event>:<tool>" when entry has `matcher` (e.g. "PreToolUse:Bash")
* - "<event>" when entry has no `matcher` (UserPromptSubmit, SessionStart)
*
* Project settings listed before user settings on shared matchers.
*/
export function buildHookMap(projectSettings = {}, userSettings = {}) {
const map = new Map();
for (const settings of [projectSettings, userSettings]) {
const hooks = settings && settings.hooks;
if (!hooks || typeof hooks !== 'object') continue;
for (const [event, entries] of Object.entries(hooks)) {
if (!Array.isArray(entries)) continue;
for (const entry of entries) {
if (!entry || typeof entry !== 'object') continue;
const scripts = Array.isArray(entry.hooks) ? entry.hooks : [];
const scriptNames = [];
for (const h of scripts) {
if (!h || h.type !== 'command') continue;
scriptNames.push(extractScriptName(h.command));
}
if (scriptNames.length === 0) continue;
const matcherKeys = entry.matcher
? String(entry.matcher).split('|').map((t) => `${event}:${t.trim()}`).filter(Boolean)
: [event];
for (const matcher of matcherKeys) {
const existing = map.get(matcher) || [];
existing.push(...scriptNames);
map.set(matcher, existing);
}
}
}
}
return map;
}
/**
* Given matcher counts (from parser hook_fired.counts) and a hook map,
* return per-script counts. Each script's count = sum over matchers that
* include it of matcherCounts[matcher]. Matchers not in map are skipped
* silently (their counts remain reflected in the original `counts` field).
*/
export function resolveScriptCounts(matcherCounts, hookMap) {
const result = {};
for (const [matcher, count] of Object.entries(matcherCounts || {})) {
const scripts = hookMap.get(matcher);
if (!scripts || scripts.length === 0) continue;
for (const script of scripts) {
result[script] = (result[script] || 0) + count;
}
}
return result;
}