#!/usr/bin/env node /** * Rule #8 — Classifier-mismatch enforce. * * Stop hook. Reads classifier output from router-state. If classifier recommended * a node with confidence >= threshold AND the turn DIDN'T invoke a matching * skill/task — block. * * Override: "без скилов" / "direct ok" / explicit "override: " line in * assistant text. * * Spec: docs/superpowers/specs/2026-05-25-enforce-hard-rules-design.md */ import { readStdin, parseEventJson, readTranscript, lastUserPromptText, lastAssistantText, turnToolUses, findOverride, logOverride, exitDecision, readRouterState, } from './enforce-hook-helpers.mjs'; const RULE_KEY = 'classifier-mismatch'; const CONFIDENCE_THRESHOLD = 0.7; const MUTATING_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash', 'Task', 'Agent']); /** Normalize a node id: strip "superpowers:" / "skill:" prefix; allow #ID. */ function normalizeNode(s) { if (typeof s !== 'string') return ''; return s.toLowerCase().replace(/^skill:/, '').replace(/^superpowers:/, ''); } function nodeMatches(recommendation, toolUse) { if (!recommendation || !toolUse) return false; const rec = normalizeNode(recommendation); if (!rec) return false; // Hole 5 fix: exact match OR matching last segment after ':' / '#'. // No generic substring (would match meta-planning to planning). const matches = (candidate) => { if (!candidate) return false; if (candidate === rec) return true; const recSegs = rec.split(/[:#]/); const canSegs = candidate.split(/[:#]/); const recLast = recSegs[recSegs.length - 1]; const canLast = canSegs[canSegs.length - 1]; return recLast === canLast; }; if (toolUse.name === 'Skill') { return matches(normalizeNode(String(toolUse.input && toolUse.input.skill || ''))); } if (toolUse.name === 'Task' || toolUse.name === 'Agent') { return matches(String(toolUse.input && toolUse.input.subagent_type || '').toLowerCase()); } return false; } export function decide({ toolUses, recommendation, confidence, assistantText, override }) { // Pure conversation: skip. const hasMutating = toolUses.some((u) => MUTATING_TOOLS.has(u.name)); if (!hasMutating) return { block: false }; if (override) return { block: false }; if (!recommendation) return { block: false }; if (typeof confidence === 'number' && confidence < CONFIDENCE_THRESHOLD) return { block: false }; const matched = toolUses.some((u) => nodeMatches(recommendation, u)); if (matched) return { block: false }; // NOTE: prior \ self-bypass removed (retro #5 hole 1) - assistant // cannot grant itself an override. User must use a vocabulary phrase. return { block: true, message: [ `[enforce-classifier-match] Classifier recommended "${recommendation}" (confidence=${confidence ?? 'n/a'}) but turn did not invoke that skill/node.`, `Either:`, ` - Invoke ${recommendation} via Skill / Task tool, OR`, ` - Add an explicit "override: " line in your response, OR`, ` - Include "без скилов" / "direct ok" in the next user prompt.`, ].join('\n'), }; } async function main() { try { const raw = await readStdin(); const event = parseEventJson(raw); const transcript = readTranscript(event.transcript_path); const userPrompt = lastUserPromptText(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; let recommendation = cls && (cls.recommended_node || cls.recommendedNode); const confidence = cls && typeof cls.confidence === 'number' ? cls.confidence : null; // Hole 4 fix: fall back to triggers_matched[0] when classifier silent. // Confidence stays null in fallback path — decide() accepts null (only // numeric confidence < 0.7 blocks the rule). if (!recommendation) { const triggers = (cls && cls.triggers_matched) || []; if (Array.isArray(triggers) && triggers.length > 0 && typeof triggers[0] === 'string' && triggers[0].length > 0) { recommendation = triggers[0]; } } const toolUses = turnToolUses(transcript); const assistantText = lastAssistantText(transcript); const result = decide({ toolUses, recommendation, confidence, assistantText, override }); exitDecision(result); } catch { exitDecision({ block: false }); } } const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/enforce-classifier-match.mjs'); if (isCli) main();