feat(enforce): T8 — Rule #8 classifier-mismatch enforce (Stop)

This commit is contained in:
Дмитрий
2026-05-25 18:23:05 +03:00
parent 536d20cbcf
commit 3900e3369e
2 changed files with 199 additions and 0 deletions
+105
View File
@@ -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: <reason>" 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: <reason>" 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: <reason>" 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();
+94
View File
@@ -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);
});
});