feat(router): UserPromptSubmit hook — classifier wiring (stage 3 task 4)

При каждом prompt'е: classifier → state-файл ~/.claude/runtime/router-state-<session>.json.
isEnforcementRequired — guard: micro/question/memory-sync пропускают.
Cache per-prompt-hash в runtime/router-classification-cache.json.
Любая ошибка прехука — silent fallback, пользовательский поток не ломается.

Smoke-test verified: regex-only path работает без ANTHROPIC_API_KEY.
Fix: CLI guard использует fileURLToPath для корректного сравнения путей с кириллицей (Windows quirk).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-24 10:28:31 +03:00
parent 9a659fa98f
commit c2caac2bec
2 changed files with 155 additions and 0 deletions
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env node
/**
* UserPromptSubmit hook — router prehook.
* Stage 3 of router discipline overhaul.
*
* При каждом prompt'е:
* 1. Читает реестр.
* 2. Вызывает classifier.
* 3. Пишет state в ~/.claude/runtime/router-state-<session>.json.
*
* Не блокирует prompt — только готовит state для PreToolUse gate (router-tool-gate).
*
* Контракт UserPromptSubmit hook (Claude Code): читает JSON из stdin
* { session_id, transcript_path, hook_event_name, user_prompt }
* на stdout — { } (пустой объект = ничего не меняем в prompt'е).
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { homedir } from 'os';
import { fileURLToPath } from 'url';
const ENFORCEMENT_TYPES = new Set(['feature', 'planning', 'bugfix', 'refactor', 'cleanup', 'marketing', 'security', 'analysis', 'monitoring']);
export function isEnforcementRequired(classification) {
if (!classification) return false;
if (classification.micro) return false;
if (!classification.recommendedNode) return false;
if (!ENFORCEMENT_TYPES.has(classification.taskType)) return false;
return true;
}
function hashPrompt(s) {
let h = 0;
for (let i = 0; i < s.length; i++) { h = ((h << 5) - h) + s.charCodeAt(i); h |= 0; }
return String(h);
}
export function buildStateFromClassification(classification, { sessionId, promptHash }) {
return {
sessionId,
promptHash,
classification,
skillInvokedThisTurn: false,
chainProgress: [],
enforcementRequired: isEnforcementRequired(classification),
timestamp: new Date().toISOString(),
};
}
function stateFilePath(sessionId) {
return join(homedir(), '.claude', 'runtime', `router-state-${sessionId}.json`);
}
async function main() {
let input = '';
for await (const chunk of process.stdin) input += chunk;
const event = JSON.parse(input || '{}');
const sessionId = event.session_id || 'unknown';
const userPrompt = event.user_prompt || '';
try {
const { loadRegistry } = await import('./registry-load.mjs');
const { classify } = await import('./router-classifier.mjs');
const registry = loadRegistry({ useCache: false });
const cachePath = join(homedir(), '.claude', 'runtime', 'router-classification-cache.json');
const cache = new Map();
if (existsSync(cachePath)) {
try {
const data = JSON.parse(readFileSync(cachePath, 'utf-8'));
for (const [k, v] of Object.entries(data)) cache.set(k, v);
} catch { /* ignore */ }
}
const classification = await classify(userPrompt, registry, { cache });
const state = buildStateFromClassification(classification, {
sessionId,
promptHash: hashPrompt(userPrompt),
});
const statePath = stateFilePath(sessionId);
mkdirSync(dirname(statePath), { recursive: true });
writeFileSync(statePath, JSON.stringify(state, null, 2));
// Persist cache
const cacheObj = {};
for (const [k, v] of cache) cacheObj[k] = v;
writeFileSync(cachePath, JSON.stringify(cacheObj, null, 2));
process.stdout.write(JSON.stringify({}));
process.exit(0);
} catch (err) {
// Любая ошибка прехука НЕ должна сломать prompt пользователя — fallback: пустой state, прохожу.
process.stderr.write(`[router-prehook] ${err.message}\n`);
process.stdout.write(JSON.stringify({}));
process.exit(0);
}
}
// CLI entry point guard — use fileURLToPath for correct non-ASCII path comparison on Windows
const __filename = fileURLToPath(import.meta.url);
if (process.argv[1] && process.argv[1] === __filename) {
main();
}
+50
View File
@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest';
import { buildStateFromClassification, isEnforcementRequired } from './router-prehook.mjs';
describe('buildStateFromClassification', () => {
it('builds full state object', () => {
const cls = { taskType: 'feature', micro: false, recommendedNode: '#19', confidence: 0.9, source: 'regex', recommendedChain: 'L1' };
const s = buildStateFromClassification(cls, { sessionId: 'abc', promptHash: '12345' });
expect(s.sessionId).toBe('abc');
expect(s.promptHash).toBe('12345');
expect(s.classification).toEqual(cls);
expect(s.skillInvokedThisTurn).toBe(false);
expect(s.chainProgress).toEqual([]);
expect(s.enforcementRequired).toBe(true);
expect(s.timestamp).toBeDefined();
});
it('enforcementRequired false on micro', () => {
const s = buildStateFromClassification({ taskType: 'bugfix', micro: true, recommendedNode: null }, { sessionId: 'a', promptHash: 'b' });
expect(s.enforcementRequired).toBe(false);
});
it('enforcementRequired false when no recommendedNode', () => {
const s = buildStateFromClassification({ taskType: 'question', micro: false, recommendedNode: null }, { sessionId: 'a', promptHash: 'b' });
expect(s.enforcementRequired).toBe(false);
});
it('enforcementRequired false on excluded taskType', () => {
const s = buildStateFromClassification({ taskType: 'question', micro: false, recommendedNode: '#60' }, { sessionId: 'a', promptHash: 'b' });
expect(s.enforcementRequired).toBe(false);
});
});
describe('isEnforcementRequired', () => {
it('true on feature with node', () => {
expect(isEnforcementRequired({ taskType: 'feature', micro: false, recommendedNode: '#19' })).toBe(true);
});
it('false on micro', () => {
expect(isEnforcementRequired({ taskType: 'feature', micro: true, recommendedNode: '#19' })).toBe(false);
});
it('false when no node', () => {
expect(isEnforcementRequired({ taskType: 'feature', micro: false, recommendedNode: null })).toBe(false);
});
it('false on question/memory-sync (excluded)', () => {
expect(isEnforcementRequired({ taskType: 'question', micro: false, recommendedNode: '#60' })).toBe(false);
expect(isEnforcementRequired({ taskType: 'memory-sync', micro: false, recommendedNode: '#33' })).toBe(false);
});
});