149 lines
6.5 KiB
JavaScript
149 lines
6.5 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,
|
||
logHookOutcome,
|
||
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 classifyOutcome({ chainLength, hasMutating, hasOverride, hasChainSkill, hasInlineOverride } = {}) {
|
||
if ((chainLength || 0) < CHAIN_MIN_LENGTH) return 'passed-short-chain';
|
||
if (!hasMutating) return 'passed-no-mutating';
|
||
if (hasOverride) return 'passed-global-override';
|
||
if (hasChainSkill) return 'passed-with-skill';
|
||
if (hasInlineOverride) return 'passed-inline-override';
|
||
return 'blocked';
|
||
}
|
||
|
||
export function decide({ toolUses, recommendedChain, calledSkillIds, assistantText, override }) {
|
||
// Compute all state flags once — returned in every branch so main() can
|
||
// pass them to classifyOutcome() without recomputing.
|
||
const hasMutating = Array.isArray(toolUses) && toolUses.some((u) => MUTATING_TOOLS.has(u && u.name));
|
||
const chain = Array.isArray(recommendedChain) ? recommendedChain : [];
|
||
const hasChainSkill = (calledSkillIds instanceof Set) && chain.some((id) => calledSkillIds.has(id));
|
||
const hasInlineOverride = typeof assistantText === 'string' && CHAIN_OVERRIDE_RE.test(assistantText);
|
||
const flags = { hasMutating, hasChainSkill, hasInlineOverride };
|
||
|
||
if (chain.length < CHAIN_MIN_LENGTH) return { block: false, ...flags };
|
||
if (!hasMutating) return { block: false, ...flags };
|
||
if (override) return { block: false, ...flags };
|
||
if (hasChainSkill) return { block: false, ...flags };
|
||
if (hasInlineOverride) return { block: false, ...flags };
|
||
|
||
const chainStr = chain.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, ...flags };
|
||
}
|
||
|
||
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);
|
||
const result = decide({ toolUses, recommendedChain: normalizedChain, calledSkillIds, assistantText, override });
|
||
const outcome = classifyOutcome({
|
||
chainLength: normalizedChain.length,
|
||
hasMutating: result.hasMutating,
|
||
hasOverride: !!override,
|
||
hasChainSkill: result.hasChainSkill,
|
||
hasInlineOverride: result.hasInlineOverride,
|
||
});
|
||
logHookOutcome(RULE_KEY, outcome, event.session_id);
|
||
exitDecision(result);
|
||
} catch { exitDecision({ block: false }); }
|
||
}
|
||
|
||
const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/enforce-chain-recommendation.mjs');
|
||
if (isCli) main();
|