Files
portal/tools/router-prehook.mjs
T
Дмитрий fb0309d357 feat(router): prehook inheritance + task_id + cost, drop ENFORCEMENT_TYPES (phase 2 task 14)
Spec §4.1 + §4.2 — Phase 2 Task 14:

- tools/router-prehook.mjs:
  - removed: ENFORCEMENT_TYPES + isEnforcementRequired (gate now uses
    NON_BLOCKING_TASK_TYPES on state.classification.task_type — Task 13).
  - buildStateFromClassification:
    + task_id: randomUUID() per turn (or caller-supplied taskId).
    + task_cost: {} placeholder (caller fills classifier_input/output_tokens
      when available; LLM helper does not yet thread tokens through — task
      17/20 will add).
    + inheritance: { inherited_from_task_id, inheritance_age_minutes } —
      written only on continuation (source: 'prefilter_inherited'); copied
      into the episode by observer-stop-hook in Task 16 (closes B5).
    - dropped enforcementRequired field — Tool gate decides solely on
      task_type + no_skill_found + skillInvokedThisTurn.
  - main(): read prevState (~/.claude/runtime/router-state-<session>.json)
    BEFORE overwrite; pass to classify({ prevState }); lift inheritance
    from classification result into the new state when prefilter inherited.
- tools/router-prehook.test.mjs: rewritten — 9 tests covering v4 shape,
  task_id randomness + override, inheritance present/absent, cost passthrough,
  ENFORCEMENT_TYPES + isEnforcementRequired no longer exported, UTF-8 smoke.

Tests: 9/9 prehook PASS. Consumer regressions: router-tool-gate (25) +
router-classifier (44) = 69 PASS — no regressions.
2026-05-25 14:28:25 +03:00

149 lines
5.5 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';
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).
* - 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,
inheritedFrom = null,
ageMin = null,
cost = {},
taskId,
} = options;
const state = {
task_id: taskId ?? randomUUID(),
sessionId,
promptHash,
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;
}
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 */ }
}
const classification = await classify(userPrompt, registry, { cache, prevState });
// 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),
inheritedFrom: inh?.inherited_from_task_id ?? null,
ageMin: inh?.inheritance_age_minutes ?? null,
});
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();
}