Files
portal/tools/router-tool-gate.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

174 lines
7.3 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
/**
* 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(); }