Files
portal/tools/router-prehook.mjs
T
Дмитрий 7b4da1477e fix(classifier,gate): G parser-quirks + H unknown-not-blocking + A1/A2/B3/C1
Brain-retro #6 follow-up #2 (consolidated). Eight independent fixes:

A1 — task_cost wiring (cost tracking)
  - router-prehook.mjs: capture classifier LLM usage via onUsage callback,
    persist to state.task_cost.classifier_input_tokens / output_tokens.
  - observer-transcript-parser.mjs: merge router-state.task_cost on top of
    extractTokenUsage(turn). State-file values win for classifier/
    self_assessment/reviewer fields.
  - New buildCostFromClassifierUsage() exported from router-prehook.
  - Verified live: state file now shows real input_tokens=190 /
    output_tokens=598 / cache_read=10075 (was 0 before).

A2 — self-assessment coverage
  - observer-self-assessment-api.mjs: DEFAULT_TIMEOUT_MS 10s -> 30s.
  - .claude/settings.json: Stop-hook timeout 15s -> 60s.
  - Same Windows TLS handshake issue. Was 85% no_self_assessment in retro #6.

B3 — brain-retro SKILL.md reconciliation
  - Step 5b: batch=default for N>=20, subagent for N<20.

C1 — dead-code cleanup
  - Removed recommendNode import + getClassificationMap + getDormancy from
    observer-transcript-parser.mjs.

G — parseClassifierResponse Pass 3 (fixLLMJsonQuirks)
  - Root cause: real Sonnet output sometimes contains raw newlines inside
    string values (multi-line reason_for_choice) and trailing commas, which
    strict JSON.parse rejects. Result was llm_error_type=parse_null on
    every other call, falling back to regex with task_type=unknown.
  - Fix: after Pass 1 (clean) and Pass 2 (brace-extract) fail, try Pass 3
    that escapes raw newline/tab inside string values and strips trailing
    commas before final JSON.parse attempt. Pure char-walk, no JSON5 dep.

H — 'unknown' added to NON_BLOCKING_TASK_TYPES in router-tool-gate.mjs
  - Until G fully proves itself, blocking Bash/Edit on unknown is too strict.
    With G in place, parse_null should be rare; H gives a safety net.

Tests added: +9 across 5 test files. Regression: 913 vitest tests in tools/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:25:16 +03:00

172 lines
6.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';
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;
}
/**
* 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 });
// 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,
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();
}