Files
portal/tools/enforce-chain-recommendation.mjs
T
Дмитрий dbaf0e0e76 feat(brain-governance): classifier threshold 0.7→0.8 + chain-recommendation enforcer + registry test bump
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).
2026-05-28 05:33:22 +03:00

124 lines
5.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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();