c7e02eeac9
router-prehook, router-stop-gate, router-tool-gate теперь читают stdin через readStdinAsUtf8 (StringDecoder). Русский в промпте корректно доходит до Anthropic API и в state-файл — никаких mojibake типа 'посмотри'. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
107 lines
3.9 KiB
JavaScript
107 lines
3.9 KiB
JavaScript
#!/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, prompt }
|
||
* на stdout — { } (пустой объект = ничего не меняем в prompt'е).
|
||
* NB: Claude Code шлёт поле `prompt` (не `user_prompt`) — читаем оба для совместимости.
|
||
*/
|
||
|
||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
||
import { join, dirname } from 'path';
|
||
import { homedir } from 'os';
|
||
import { fileURLToPath } from 'url';
|
||
import { readStdinAsUtf8 } from './router-stdin-helper.mjs';
|
||
|
||
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() {
|
||
const input = await readStdinAsUtf8(process.stdin);
|
||
const event = JSON.parse(input || '{}');
|
||
const sessionId = event.session_id || 'unknown';
|
||
const userPrompt = event.prompt || 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();
|
||
}
|