#!/usr/bin/env node /** * Rule #8 — Classifier-mismatch enforce. * * Stop hook. Reads classifier output from router-state. If classifier recommended * a node with confidence >= 0.6 AND the turn DIDN'T invoke a matching * skill/task — block. * * Escape hatches: * - Invoke recommended skill via Skill / Task tool, OR * - "router-skip: " line in assistant text (inline, per-tool), OR * - Global vocab override ("без скилов" / "direct ok") in user prompt. * * Spec: docs/superpowers/specs/2026-05-25-enforce-hard-rules-design.md * docs/superpowers/plans/2026-05-28-router-discipline-level-1-2.md */ import { readStdin, parseEventJson, readTranscript, lastUserPromptText, lastAssistantText, turnToolUses, findOverride, logOverride, exitDecision, readRouterState, } from './enforce-hook-helpers.mjs'; const RULE_KEY = 'classifier-mismatch'; // Lowered 2026-05-28 (Task 4, brain-retro #10): 0.8 was too high — 0% // single-node-skill follow-through. 0.6 catches more borderline cases. // Inline router-skip escape hatch (50+ chars) mitigates friction. const CONFIDENCE_THRESHOLD = 0.6; const ROUTER_SKIP_RE = /^router-skip:\s*(.{50,})$/m; 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 }; // Inline override: "router-skip: <50+ chars justification>" in assistant text. if (typeof assistantText === 'string' && ROUTER_SKIP_RE.test(assistantText)) { return { block: false }; } 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 "router-skip: " 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 ≥ CONFIDENCE_THRESHOLD (0.6) 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();