#!/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, 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 { randomUUID } from 'crypto'; import { readStdinAsUtf8 } from './router-stdin-helper.mjs'; // NB: ENFORCEMENT_TYPES + isEnforcementRequired removed in Phase 2 Task 14. // router-tool-gate now decides exempt via NON_BLOCKING_TASK_TYPES on // state.classification.task_type (spec §4.4, D1 — continuation NOT exempt). 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); } /** * Build the router state object written to ~/.claude/runtime/router-state-*.json. * Schema (Phase 2 Task 14, spec §4.1 / §4.2): * - task_id: stable per turn (taskId option overrides randomUUID for tests). * - classification: raw output from classify() (any of prefilter / llm / regex shapes). * - skillInvokedThisTurn: gate watches this on PostToolUse Skill. * - chainProgress: reserved for chain enforcement. * - task_cost: classifier input/output token counts (caller fills it when LLM was called). * - prompt_length: raw character length of the user prompt (added 2026-05-27, * brain-retro #7 C2 fix). Lets router-tool-gate detect short ambiguous prompts * where AskUserQuestion beats improvising. * - inheritance: { inherited_from_task_id, inheritance_age_minutes } — present only * on continuation; written by main() when classify() returns source: 'prefilter_inherited'. * - timestamp: ISO — used by prefilter (next turn) to compute inheritance age. */ export function buildStateFromClassification(classification, options = {}) { const { sessionId, promptHash, promptLength = null, inheritedFrom = null, ageMin = null, cost = {}, taskId, } = options; const state = { task_id: taskId ?? randomUUID(), sessionId, promptHash, prompt_length: promptLength, classification, skillInvokedThisTurn: false, chainProgress: [], task_cost: { ...cost }, timestamp: new Date().toISOString(), }; if (inheritedFrom) { state.inheritance = { inherited_from_task_id: inheritedFrom, inheritance_age_minutes: ageMin, }; } return state; } /** * Convert Anthropic API usage shape into a classifier task_cost block. Pure. * Used by main() onUsage callback to persist classifier cost into router-state, * which observer-transcript-parser then merges into the episode's task_cost. * * Cost-tracking added 2026-05-26 (brain-retro #6 A1 follow-up). Previously * task_cost.classifier_* fields were hardcoded to 0 — no cost visibility. */ export function buildCostFromClassifierUsage(usage) { if (!usage || typeof usage !== 'object') return {}; const out = {}; if (typeof usage.input_tokens === 'number') out.classifier_input_tokens = usage.input_tokens; if (typeof usage.output_tokens === 'number') out.classifier_output_tokens = usage.output_tokens; if (typeof usage.cache_read_input_tokens === 'number') out.classifier_cache_read_input_tokens = usage.cache_read_input_tokens; if (typeof usage.cache_creation_input_tokens === 'number') out.classifier_cache_creation_input_tokens = usage.cache_creation_input_tokens; return out; } 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 */ } } // Read previous turn's state BEFORE overwriting — feeds prefilter's // continuation/cancellation check (spec §4.1 проверки 2 + 4). const statePath = stateFilePath(sessionId); let prevState = null; if (existsSync(statePath)) { try { prevState = JSON.parse(readFileSync(statePath, 'utf-8')); } catch { /* ignore */ } } // A1 (2026-05-26): capture classifier LLM usage to persist into state.task_cost // so brain-retro and STATUS.md can report real $ spend. let classifierCost = {}; const onUsage = (usage) => { classifierCost = buildCostFromClassifierUsage(usage); }; const classification = await classify(userPrompt, registry, { cache, prevState, onUsage, llmCall: async () => null }); // If prefilter inherited from the previous turn, lift the inheritance // block into the new state — observer-stop-hook copies it to the episode (B5). const inh = (classification?.source === 'prefilter_inherited' && classification.inheritance) ? classification.inheritance : null; const state = buildStateFromClassification(classification, { sessionId, promptHash: hashPrompt(userPrompt), promptLength: userPrompt.length, inheritedFrom: inh?.inherited_from_task_id ?? null, ageMin: inh?.inheritance_age_minutes ?? null, cost: classifierCost, }); 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(); }