dbaf0e0e76
Three brain-governance hardening changes from retro #8 follow-up:
1. enforce-classifier-match: confidence threshold raised 0.7→0.8 (was producing false-positives on borderline LLM recommendations like #3 GitHub MCP for local debug, #36 adr-kit for status readouts). 2 new vitest tests cover boundary values 0.7 and 0.75 (now allowed).
2. enforce-chain-recommendation (NEW): PreToolUse hook blocking mutating tool calls when router gave recommended_chain length >= 2 and controller is not expanding it. Allows pass when: any chain node already invoked, inline 'chain-override: <reason>' present, or global override-phrase in user prompt. 20 vitest tests cover empty chain, single-node bypass, override variants, alias resolution, mixed numeric/string ids.
3. registry-load.test.mjs: bump expected counts 85→86 nodes / 77→78 active (collateral fix after parallel session added #86 graphifyy in 06ee5ad3).
Full vitest tools-sweep: 1022/1022 GREEN.
Reviewer APPROVE on spec compliance + code quality (non-blocking observations: test count mis-report in implementer's claim 33→20 actual, hardcoded 'superpowers:' alias prefix, no direct test for extractCalledSkillIds — deferred).
Hook activation in .claude/settings.json deferred — controller will register separately based on owner's choice (block / warn-only / defer).
124 lines
5.4 KiB
JavaScript
124 lines
5.4 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* Rule — Chain-recommendation enforce.
|
||
*
|
||
* PreToolUse hook. When the router classifier recommends a multi-step chain
|
||
* (>= 2 nodes) and the controller is about to run a mutating tool without
|
||
* having invoked ANY node in the chain, block with instructions.
|
||
*
|
||
* Three escape hatches:
|
||
* 1. Call any skill/task matching at least one node in the chain.
|
||
* 2. Write chain-override at the start of a line in assistant text.
|
||
* 3. User prompt contains a global override phrase (vocab-driven).
|
||
*
|
||
* Single-node recommendations are handled by enforce-classifier-match.mjs.
|
||
*/
|
||
|
||
import {
|
||
readStdin,
|
||
parseEventJson,
|
||
readTranscript,
|
||
lastUserPromptText,
|
||
lastAssistantText,
|
||
turnToolUses,
|
||
findOverride,
|
||
logOverride,
|
||
exitDecision,
|
||
readRouterState,
|
||
} from './enforce-hook-helpers.mjs';
|
||
|
||
import { loadRegistry } from './registry-load.mjs';
|
||
|
||
const RULE_KEY = 'chain-recommendation';
|
||
const CHAIN_MIN_LENGTH = 2;
|
||
const MUTATING_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash', 'Task', 'Agent']);
|
||
const CHAIN_OVERRIDE_RE = /^chain-override:\s*\S+/m;
|
||
|
||
export function decide({ toolUses, recommendedChain, calledSkillIds, assistantText, override }) {
|
||
if (!Array.isArray(recommendedChain) || recommendedChain.length < CHAIN_MIN_LENGTH) return { block: false };
|
||
const hasMutating = Array.isArray(toolUses) && toolUses.some((u) => MUTATING_TOOLS.has(u && u.name));
|
||
if (!hasMutating) return { block: false };
|
||
if (override) return { block: false };
|
||
if (calledSkillIds instanceof Set) {
|
||
for (const id of recommendedChain) { if (calledSkillIds.has(id)) return { block: false }; }
|
||
}
|
||
if (typeof assistantText === 'string' && CHAIN_OVERRIDE_RE.test(assistantText)) return { block: false };
|
||
const chainStr = recommendedChain.join(' → ');
|
||
const message = [
|
||
`[enforce-chain-recommendation] Router рекомендовал цепочку ${chainStr}, но ни один узел не вызван и нет инлайн-обоснования отказа.`,
|
||
`Сделай ОДНО из трёх:`,
|
||
` 1. Вызови первый узел цепочки через Skill / Task tool.`,
|
||
` 2. Добавь в свой ответ строку «chain-override: <одна строка причины>» (не путать с глобальным override от пользователя — это инлайн-объяснение controller-а).`,
|
||
` 3. Попроси у пользователя глобальный override (без скилов / direct ok / срочно / быстрый коммит / recovery / memory dump / ремонт инфраструктуры).`,
|
||
].join('\n');
|
||
return { block: true, message };
|
||
}
|
||
|
||
function normalizeChainId(raw) {
|
||
if (raw === null || raw === undefined) return '';
|
||
const s = String(raw).trim().toLowerCase();
|
||
if (!s) return '';
|
||
return s.startsWith('#') ? s : `#${s}`;
|
||
}
|
||
|
||
function chainIdAliases(id, registry) {
|
||
const aliases = new Set([id]);
|
||
if (!registry) return aliases;
|
||
try {
|
||
const node = registry.indexById && registry.indexById.get(id);
|
||
if (!node) return aliases;
|
||
if (node.slug) aliases.add(node.slug.toLowerCase());
|
||
if (node.name) aliases.add(node.name.toLowerCase());
|
||
if (node.slug) aliases.add(`superpowers:${node.slug.toLowerCase()}`);
|
||
} catch { /* non-fatal */ }
|
||
return aliases;
|
||
}
|
||
|
||
function extractCalledSkillIds(toolUses, normalizedChain, registry) {
|
||
const aliasMap = new Map();
|
||
for (const id of normalizedChain) aliasMap.set(id, chainIdAliases(id, registry));
|
||
const called = new Set();
|
||
for (const u of toolUses) {
|
||
if (!u || !u.name) continue;
|
||
let rawName = null;
|
||
if (u.name === 'Skill') rawName = (u.input && u.input.skill) ? String(u.input.skill) : null;
|
||
else if (u.name === 'Task' || u.name === 'Agent') rawName = (u.input && u.input.subagent_type) ? String(u.input.subagent_type) : null;
|
||
if (!rawName) continue;
|
||
const norm = rawName.toLowerCase().trim();
|
||
called.add(norm);
|
||
const stripped = norm.replace(/^superpowers:/, '').replace(/^skill:/, '');
|
||
called.add(stripped);
|
||
for (const [chainId, aliases] of aliasMap) {
|
||
if (aliases.has(norm) || aliases.has(stripped)) called.add(chainId);
|
||
}
|
||
}
|
||
return called;
|
||
}
|
||
|
||
async function main() {
|
||
try {
|
||
const raw = await readStdin();
|
||
const event = parseEventJson(raw);
|
||
if (!MUTATING_TOOLS.has(event.tool_name)) { exitDecision({ block: false }); return; }
|
||
const transcript = readTranscript(event.transcript_path);
|
||
const userPrompt = lastUserPromptText(transcript);
|
||
const assistantText = lastAssistantText(transcript);
|
||
const toolUses = turnToolUses(transcript);
|
||
const override = findOverride(userPrompt, RULE_KEY);
|
||
if (override) logOverride(RULE_KEY, override, event.session_id);
|
||
const state = readRouterState(event.session_id);
|
||
const cls = state && state.classification;
|
||
const rawChain = (cls && cls.recommended_chain) || [];
|
||
const normalizedChain = Array.isArray(rawChain)
|
||
? rawChain.map(normalizeChainId).filter(Boolean)
|
||
: [];
|
||
let registry = null;
|
||
try { registry = loadRegistry(); } catch { /* fail-quiet */ }
|
||
const calledSkillIds = extractCalledSkillIds(toolUses, normalizedChain, registry);
|
||
exitDecision(decide({ toolUses, recommendedChain: normalizedChain, calledSkillIds, assistantText, override }));
|
||
} catch { exitDecision({ block: false }); }
|
||
}
|
||
|
||
const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/enforce-chain-recommendation.mjs');
|
||
if (isCli) main();
|