726c2121b5
Two changes: 1. CONFIDENCE_THRESHOLD 0.8 → 0.6 — catches borderline recommendations that previously slipped through. Driver: brain-retro #10 shows 0% single-node-skill follow-through, suggesting hook needs to fire more. 2. Inline escape hatch — 'router-skip: <reason 50+ chars>' in assistant text. Per-tool scope (does not affect other tools in same turn). Replaces the documented 'override: <reason>' hint which was a self-bypass loophole — high-friction 50+ char justification discourages reflexive use. Per Level 2 of plan docs/superpowers/plans/2026-05-28-router-discipline-level-1-2.md. Legacy tests flipped (2 tests): - 'allows when confidence exactly 0.7 (raised threshold)' → 'BLOCKS when confidence exactly 0.7 (above new threshold 0.6)' - 'allows when confidence 0.75 (still under raised threshold)' → 'BLOCKS when confidence 0.75 (above new threshold 0.6)' These tests previously asserted block:false at 0.7/0.75 under the old 0.8 threshold; with 0.6 threshold they now correctly assert block:true. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
133 lines
5.1 KiB
JavaScript
133 lines
5.1 KiB
JavaScript
#!/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: <reason 50+ chars>" 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: <reason 50+ chars>" 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();
|