Files
portal/tools/router-prehook.mjs
T
Дмитрий 57bd85edc6 fix(router): prehook reads 'prompt' field + remove matcher from UserPromptSubmit (stage 3 hotfix)
Two real bugs found via verification (hook didn't fire in live session):
1. UserPromptSubmit block had matcher:"*" — event doesn't support matcher,
   non-standard block dropped (claude-code-guide authoritative). Removed →
   block now {hooks:[...]} like working observer-stop-hook.
2. stdin field was event.user_prompt; Claude Code sends event.prompt.
   Now reads (event.prompt || event.user_prompt) for compat.

Field-fix verified manually with real stdin shape {prompt:...} → #71 pdn-152fz.
Firing fix (matcher) NOT verifiable in-session (hooks load at session start) —
needs restart + next-turn state-file check.

NB stop-gate turn_events field also wrong (Stop sends transcript_path) — separate
follow-up, not on observation critical path (affects chain tracking only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 11:32:28 +03:00

107 lines
3.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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';
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.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();
}