7b4da1477e
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>
174 lines
7.3 KiB
JavaScript
174 lines
7.3 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* PreToolUse hook — router tool gate.
|
||
* Stage 3 of router discipline overhaul.
|
||
*
|
||
* Читает state из ~/.claude/runtime/router-state-<session>.json (написан router-prehook).
|
||
* Решает: block / proceed для tools Edit, Write, Bash (non-read-only).
|
||
*
|
||
* Escape hatch: <!-- routing: direct_justified=true reason="..." --> в начале response пропускает.
|
||
*
|
||
* Mode: warn-only (только stderr) или enforce (decision: block).
|
||
* Mode читается из ~/.claude/runtime/router-gate-mode.json {"mode": "warn-only"|"enforce"}.
|
||
* По умолчанию warn-only (первая неделя), потом ручной переключатель.
|
||
*/
|
||
|
||
import { readFileSync, existsSync } from 'fs';
|
||
import { join } from 'path';
|
||
import { homedir } from 'os';
|
||
|
||
const READ_ONLY_BASH_PATTERNS = [
|
||
/^\s*ls(\s|$)/, /^\s*cat\s/, /^\s*head\s/, /^\s*tail\s/, /^\s*wc\s/,
|
||
/^\s*grep\s/, /^\s*find\s.*-print/, /^\s*pwd\s*$/,
|
||
/^\s*git\s+(status|log|show|diff|rev-parse|branch|ls-tree|ls-remote|remote\s+show|tag|fetch)/,
|
||
/^\s*node\s.*--check/, /^\s*npx\s+vitest\s+run/, /^\s*node\s+tools\/[\w-]+\.mjs\s+/,
|
||
];
|
||
|
||
export function isReadOnlyBash(command) {
|
||
if (!command) return false;
|
||
return READ_ONLY_BASH_PATTERNS.some((re) => re.test(command));
|
||
}
|
||
|
||
export function decodeRoutingTag(responseText) {
|
||
if (!responseText) return null;
|
||
const m = String(responseText).match(/<!--\s*routing:\s*direct_justified=true\s+reason=["']([^"']+)["']\s*-->/);
|
||
if (!m) return null;
|
||
return { directJustified: true, reason: m[1] };
|
||
}
|
||
|
||
// §17 exempt set — task types that never trigger the gate (spec §4.4).
|
||
// Continuation deliberately NOT in this list (D1): a continuation that
|
||
// inherits a `feature`/`bugfix` classification gets the same enforcement as
|
||
// the original prompt.
|
||
// H (2026-05-26): 'unknown' added to NON_BLOCKING_TASK_TYPES. Brain-retro #6
|
||
// surfaced that the LLM classifier hits parse_null occasionally (Sonnet returns
|
||
// JSON that parseClassifierResponse can't extract — prose wrapper or unexpected
|
||
// shape), falling back to regex which assigns task_type=unknown. Blocking on
|
||
// unknown is too strict — Bash/Edit gets stuck on routine work. G is the proper
|
||
// fix (better parser); H is the workaround until G ships.
|
||
const NON_BLOCKING_TASK_TYPES = ['conversation', 'micro', 'manual_override', 'unknown'];
|
||
|
||
function resolveTaskType(cls) {
|
||
return cls?.task_type ?? cls?.taskType;
|
||
}
|
||
|
||
function resolveMode(options) {
|
||
if (typeof options.mode === 'string') return options.mode;
|
||
// Legacy fallback: warnOnly=false maps to enforce, otherwise warn-only.
|
||
return options.warnOnly === false ? 'enforce' : 'warn-only';
|
||
}
|
||
|
||
/**
|
||
* §17 gate decision (spec §4.4, Phase 2 Task 13).
|
||
*
|
||
* @returns `false` when the tool call is allowed to proceed, or
|
||
* `{ block: true, reason: 'direct_in_non_conversation' | 'no_skill_found_block' }`
|
||
* when the gate decides to block.
|
||
*
|
||
* Order of checks:
|
||
* 1. mode off / warn-only → false (no enforcement)
|
||
* 2. classification.no_skill_found === true → block(no_skill_found_block)
|
||
* 3. task_type ∈ NON_BLOCKING_TASK_TYPES → false (§17 exempt set)
|
||
* 4. skillInvokedThisTurn === true → false (skill already invoked)
|
||
* 5. routing-tag direct_justified=true with reason → false (escape hatch)
|
||
* 6. Bash + isReadOnlyBash(cmd) → false (read-only commands)
|
||
* 7. tool ∉ {Edit, Write, MultiEdit, Bash} → false (not gated)
|
||
* 8. → block(direct_in_non_conversation)
|
||
*/
|
||
export function shouldBlock(tool, state, responseText, options = {}) {
|
||
const mode = resolveMode(options);
|
||
if (mode === 'off' || mode === 'warn-only') return false;
|
||
|
||
const cls = state?.classification || {};
|
||
|
||
if (cls.no_skill_found === true) {
|
||
return { block: true, reason: 'no_skill_found_block' };
|
||
}
|
||
|
||
const taskType = resolveTaskType(cls);
|
||
if (NON_BLOCKING_TASK_TYPES.includes(taskType)) return false;
|
||
if (state?.skillInvokedThisTurn === true) return false;
|
||
|
||
const tag = decodeRoutingTag(responseText);
|
||
if (tag?.directJustified) return false;
|
||
|
||
if (tool === 'Bash' && isReadOnlyBash(options.bashCommand || '')) return false;
|
||
if (!['Edit', 'Write', 'MultiEdit', 'Bash'].includes(tool)) return false;
|
||
|
||
return { block: true, reason: 'direct_in_non_conversation' };
|
||
}
|
||
|
||
export function decideDecision(tool, state, responseText, options = {}) {
|
||
const cls = state?.classification || {};
|
||
const taskType = resolveTaskType(cls);
|
||
const block = shouldBlock(tool, state, responseText, options);
|
||
|
||
if (block && block.block) {
|
||
const recNode = cls.recommendedNode ?? cls.recommended_node ?? '(unknown)';
|
||
const recChain = cls.recommendedChain ?? cls.recommended_chain_id;
|
||
const chainSuf = recChain ? ` (chain ${recChain})` : '';
|
||
const reasonText = block.reason === 'no_skill_found_block'
|
||
? `Классификатор не нашёл подходящий узел (no_skill_found). Уточни задачу или дай routing-tag direct_justified. Узел: ${recNode}.`
|
||
: `Эта задача классифицирована как ${taskType}. Реестр рекомендует узел ${recNode}${chainSuf}. Вызови соответствующий навык ПЕРВЫМ, либо начни ответ с <!-- routing: direct_justified=true reason="..." --> с явным обоснованием.`;
|
||
return { decision: 'block', reason: reasonText, reason_code: block.reason };
|
||
}
|
||
|
||
const mode = resolveMode(options);
|
||
if (
|
||
mode === 'warn-only'
|
||
&& taskType
|
||
&& !NON_BLOCKING_TASK_TYPES.includes(taskType)
|
||
&& cls.no_skill_found !== true
|
||
&& !state?.skillInvokedThisTurn
|
||
) {
|
||
const recNode = cls.recommendedNode ?? cls.recommended_node ?? '(unknown)';
|
||
return {
|
||
warning: `[router-gate WARN-ONLY] ${tool} would be blocked — recommended ${recNode}.`,
|
||
};
|
||
}
|
||
|
||
return {};
|
||
}
|
||
|
||
function gateMode() {
|
||
const path = join(homedir(), '.claude', 'runtime', 'router-gate-mode.json');
|
||
if (!existsSync(path)) return 'warn-only';
|
||
try {
|
||
const data = JSON.parse(readFileSync(path, 'utf-8'));
|
||
if (data.mode === 'enforce') return 'enforce';
|
||
if (data.mode === 'off') return 'off';
|
||
return 'warn-only';
|
||
} catch { return 'warn-only'; }
|
||
}
|
||
|
||
function readState(sessionId) {
|
||
const path = join(homedir(), '.claude', 'runtime', `router-state-${sessionId}.json`);
|
||
if (!existsSync(path)) return null;
|
||
try { return JSON.parse(readFileSync(path, 'utf-8')); } catch { return null; }
|
||
}
|
||
|
||
async function main() {
|
||
const input = await readStdinAsUtf8(process.stdin);
|
||
const event = JSON.parse(input || '{}');
|
||
const sessionId = event.session_id || 'unknown';
|
||
const tool = event.tool_name;
|
||
const state = readState(sessionId);
|
||
|
||
if (!state) { process.stdout.write(JSON.stringify({})); process.exit(0); return; }
|
||
|
||
const mode = gateMode();
|
||
const responseText = ''; // PreToolUse event doesn't include response
|
||
const bashCommand = (event.tool_input || {}).command || '';
|
||
|
||
const decision = decideDecision(tool, state, responseText, { mode, bashCommand });
|
||
|
||
if (decision.warning) process.stderr.write(decision.warning + '\n');
|
||
process.stdout.write(JSON.stringify(decision.decision ? decision : {}));
|
||
process.exit(0);
|
||
}
|
||
|
||
// CLI guard — Windows-cyrillic quirk: use fileURLToPath(import.meta.url)
|
||
import { fileURLToPath } from 'url';
|
||
import { readStdinAsUtf8 } from './router-stdin-helper.mjs';
|
||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { main(); }
|