From 3900e3369e16887dd17e7ea0cf65eb7ea9ea792c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Mon, 25 May 2026 18:23:05 +0300 Subject: [PATCH] =?UTF-8?q?feat(enforce):=20T8=20=E2=80=94=20Rule=20#8=20c?= =?UTF-8?q?lassifier-mismatch=20enforce=20(Stop)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/enforce-classifier-match.mjs | 105 ++++++++++++++++++++++++ tools/enforce-classifier-match.test.mjs | 94 +++++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 tools/enforce-classifier-match.mjs create mode 100644 tools/enforce-classifier-match.test.mjs diff --git a/tools/enforce-classifier-match.mjs b/tools/enforce-classifier-match.mjs new file mode 100644 index 00000000..4b9551a6 --- /dev/null +++ b/tools/enforce-classifier-match.mjs @@ -0,0 +1,105 @@ +#!/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']); + +/** 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; + if (toolUse.name === 'Skill') { + const s = normalizeNode(String(toolUse.input && toolUse.input.skill || '')); + if (s && (s === rec || s.includes(rec) || rec.includes(s))) return true; + } + if (toolUse.name === 'Task') { + const sub = String(toolUse.input && toolUse.input.subagent_type || '').toLowerCase(); + if (sub && rec.includes(sub)) return true; + } + 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 }; + + // Allow explicit override: lines like "override: " in assistant text. + if (assistantText && /\boverride:\s+\S/i.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 "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; + const recommendation = cls && (cls.recommended_node || cls.recommendedNode); + const confidence = cls && typeof cls.confidence === 'number' ? cls.confidence : null; + 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(); diff --git a/tools/enforce-classifier-match.test.mjs b/tools/enforce-classifier-match.test.mjs new file mode 100644 index 00000000..ccbe5724 --- /dev/null +++ b/tools/enforce-classifier-match.test.mjs @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest'; +import { decide } from './enforce-classifier-match.mjs'; + +describe('enforce-classifier-match / decide', () => { + it('allows pure conversation (no mutating tools)', () => { + expect(decide({ + toolUses: [{ name: 'Read' }], + recommendation: 'superpowers:writing-plans', + confidence: 0.9, + }).block).toBe(false); + }); + + it('allows when no recommendation', () => { + expect(decide({ + toolUses: [{ name: 'Edit', input: {} }], + recommendation: null, + confidence: null, + }).block).toBe(false); + }); + + it('allows when confidence below threshold', () => { + expect(decide({ + toolUses: [{ name: 'Edit', input: {} }], + recommendation: 'superpowers:writing-plans', + confidence: 0.5, + }).block).toBe(false); + }); + + it('blocks when recommendation high-confidence + no matching tool', () => { + const r = decide({ + toolUses: [{ name: 'Edit', input: { file_path: 'x.mjs' } }], + recommendation: 'superpowers:writing-plans', + confidence: 0.9, + }); + expect(r.block).toBe(true); + expect(r.message).toMatch(/writing-plans/); + }); + + it('allows when Skill tool invoked with matching name', () => { + const r = decide({ + toolUses: [ + { name: 'Skill', input: { skill: 'superpowers:writing-plans' } }, + { name: 'Edit', input: { file_path: 'x.mjs' } }, + ], + recommendation: 'superpowers:writing-plans', + confidence: 0.9, + }); + expect(r.block).toBe(false); + }); + + it('matches normalized name without superpowers: prefix', () => { + const r = decide({ + toolUses: [ + { name: 'Skill', input: { skill: 'writing-plans' } }, + { name: 'Edit', input: {} }, + ], + recommendation: 'superpowers:writing-plans', + confidence: 0.9, + }); + expect(r.block).toBe(false); + }); + + it('matches Task subagent', () => { + const r = decide({ + toolUses: [ + { name: 'Task', input: { subagent_type: 'rls-reviewer' } }, + { name: 'Edit', input: {} }, + ], + recommendation: 'rls-reviewer', + confidence: 0.85, + }); + expect(r.block).toBe(false); + }); + + it('allows when explicit "override:" in assistant text', () => { + const r = decide({ + toolUses: [{ name: 'Edit', input: {} }], + recommendation: 'foo:bar', + confidence: 0.9, + assistantText: 'override: simpler direct edit, foo:bar overkill here\n', + }); + expect(r.block).toBe(false); + }); + + it('allows when override phrase present', () => { + const r = decide({ + toolUses: [{ name: 'Edit', input: {} }], + recommendation: 'foo:bar', + confidence: 0.9, + override: { phrase: 'direct ok', suppresses: ['classifier-mismatch'] }, + }); + expect(r.block).toBe(false); + }); +});