From c2caac2bec045d0302ef215d3db9a849495c42fd 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: Sun, 24 May 2026 10:28:31 +0300 Subject: [PATCH] =?UTF-8?q?feat(router):=20UserPromptSubmit=20hook=20?= =?UTF-8?q?=E2=80=94=20classifier=20wiring=20(stage=203=20task=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit При каждом prompt'е: classifier → state-файл ~/.claude/runtime/router-state-.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) --- tools/router-prehook.mjs | 105 ++++++++++++++++++++++++++++++++++ tools/router-prehook.test.mjs | 50 ++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 tools/router-prehook.mjs create mode 100644 tools/router-prehook.test.mjs diff --git a/tools/router-prehook.mjs b/tools/router-prehook.mjs new file mode 100644 index 00000000..3715a648 --- /dev/null +++ b/tools/router-prehook.mjs @@ -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-.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(); +} diff --git a/tools/router-prehook.test.mjs b/tools/router-prehook.test.mjs new file mode 100644 index 00000000..3dc1ae49 --- /dev/null +++ b/tools/router-prehook.test.mjs @@ -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); + }); +});