397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
98 lines
3.6 KiB
JavaScript
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;
|
|
}
|