#!/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 → inline:. */ 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: * - ":" when entry has `matcher` (e.g. "PreToolUse:Bash") * - "" 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; }