feat(enforce): T8 — Rule #8 classifier-mismatch enforce (Stop)
This commit is contained in:
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user