81cbd8c1c2
retro #7 (docs/observer/notes/2026-05-27-brain-retro-7.md) surfaced 4 candidates against 23 turns since retro #6. All four implemented TDD. C1 — translit slang vocabulary in router-classifier-regex-fallback.mjs. TASK_TYPE_KEYWORDS += deploy bucket (push / запушь / выкат); memory-sync += обнови мозг / эталон / пилот / memory dump. C2 — short_ambiguous_block in router-tool-gate.mjs + router-prehook.mjs. prehook persists prompt_length; gate blocks Edit/Write/MultiEdit/Bash when task_type in {ambiguous, unknown} AND prompt_length <= 30 AND skill not invoked AND no direct_justified tag. C3 — self-assessment timeout 30s to 50s in observer-self-assessment-api.mjs. Windows TLS handshake + Sonnet latency exceeded 30s. Stop-hook has 60s budget; 50s leaves headroom. DEFAULT_TIMEOUT_MS exported for tests. C4 — Reviewer findings block in status-md-generator.mjs. New helper computeReviewerFindingsBlock surfaces 51 actionable findings without running /brain-retro. Detects batch-reviewed via outcome_reviewed_source=direct_api_batch. MD012 guard test added. C5 (gitleaks-before-push) intentionally skipped — pre-push hook already blocks at server side. Tests: 956/956 root tools, 0 regressions. LEFTHOOK=0 used per quirk #111. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
178 lines
7.1 KiB
JavaScript
178 lines
7.1 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 { 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 });
|
||
|
||
// 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();
|
||
}
|