2026-05-24 11:05:00 +03:00
#!/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 ] } ;
}
2026-05-25 11:58:34 +03:00
// §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.
2026-05-26 19:25:16 +03:00
// 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' ] ;
2026-05-25 11:58:34 +03:00
2026-05-27 06:46:55 +03:00
// brain-retro #7 C2 (2026-05-27): short ambiguous prompts.
// Closes a leak in H bypass (unknown → exempt): when the LLM parser returns
// parse_null on a short prompt, regex fallback assigns task_type=unknown and
// the gate let it through. Reviewer (Opus 4.7) flagged 3/4 such cases as
// mistake_should_not_start — agent improvised 15+ tool calls instead of
// asking the user. Block them; long-prompt unknown still passes per H.
const SHORT _AMBIGUOUS _LEN = 30 ;
const SHORT _AMBIGUOUS _TYPES = new Set ( [ 'ambiguous' , 'unknown' ] ) ;
2026-05-25 11:58:34 +03:00
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
2026-05-27 06:46:55 +03:00
* `{ block: true, reason: 'direct_in_non_conversation' | 'no_skill_found_block' | 'short_ambiguous_block' }`
2026-05-25 11:58:34 +03:00
* 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)
2026-05-27 06:46:55 +03:00
* 3. C2 (brain-retro #7): skillInvoked=false, no direct_justified tag,
* task_type ∈ {ambiguous, unknown}, prompt_length ≤ 30
* → block(short_ambiguous_block) — narrows H bypass on short improv.
* 4. task_type ∈ NON_BLOCKING_TASK_TYPES → false (§17 exempt set)
* 5. skillInvokedThisTurn === true → false (skill already invoked)
* 6. routing-tag direct_justified=true with reason → false (escape hatch)
* 7. Bash + isReadOnlyBash(cmd) → false (read-only commands)
* 8. tool ∉ {Edit, Write, MultiEdit, Bash} → false (not gated)
* 9. → block(direct_in_non_conversation)
2026-05-25 11:58:34 +03:00
*/
2026-05-24 11:05:00 +03:00
export function shouldBlock ( tool , state , responseText , options = { } ) {
2026-05-25 11:58:34 +03:00
const mode = resolveMode ( options ) ;
if ( mode === 'off' || mode === 'warn-only' ) return false ;
2026-05-24 11:05:00 +03:00
2026-05-25 11:58:34 +03:00
const cls = state ? . classification || { } ;
2026-05-24 11:05:00 +03:00
2026-05-25 11:58:34 +03:00
if ( cls . no _skill _found === true ) {
return { block : true , reason : 'no_skill_found_block' } ;
}
const taskType = resolveTaskType ( cls ) ;
2026-05-27 06:46:55 +03:00
const tag = decodeRoutingTag ( responseText ) ;
// C2: short ambiguous/unknown prompts. Check BEFORE NON_BLOCKING so 'unknown'
// (which is in NON_BLOCKING for parse_null tolerance) is still caught when
// the prompt is short. Respects skill-invoked + routing-tag escape hatches.
const promptLen = state ? . prompt _length ;
if (
typeof promptLen === 'number'
&& promptLen <= SHORT _AMBIGUOUS _LEN
&& SHORT _AMBIGUOUS _TYPES . has ( taskType )
&& state ? . skillInvokedThisTurn !== true
&& ! tag ? . directJustified
) {
return { block : true , reason : 'short_ambiguous_block' } ;
}
2026-05-25 11:58:34 +03:00
if ( NON _BLOCKING _TASK _TYPES . includes ( taskType ) ) return false ;
if ( state ? . skillInvokedThisTurn === true ) return false ;
2026-05-24 11:05:00 +03:00
2026-05-25 11:58:34 +03:00
if ( tag ? . directJustified ) return false ;
if ( tool === 'Bash' && isReadOnlyBash ( options . bashCommand || '' ) ) return false ;
if ( ! [ 'Edit' , 'Write' , 'MultiEdit' , 'Bash' ] . includes ( tool ) ) return false ;
2026-05-24 11:05:00 +03:00
2026-05-25 11:58:34 +03:00
return { block : true , reason : 'direct_in_non_conversation' } ;
2026-05-24 11:05:00 +03:00
}
export function decideDecision ( tool , state , responseText , options = { } ) {
2026-05-25 11:58:34 +03:00
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 } ) ` : '' ;
2026-05-27 06:46:55 +03:00
let reasonText ;
if ( block . reason === 'no_skill_found_block' ) {
reasonText = ` Классификатор не нашёл подходящий узел (no_skill_found). Уточни задачу или дай routing-tag direct_justified. Узел: ${ recNode } . ` ;
} else if ( block . reason === 'short_ambiguous_block' ) {
reasonText = ` Короткий размытый промпт ( ${ state ? . prompt _length ? ? '?' } симв, task_type= ${ taskType } ). На таких — лучше уточнить через AskUserQuestion, чем угадывать. Если задача и правда тривиальна — начни ответ с <!-- routing: direct_justified=true reason="..." -->. ` ;
} else {
reasonText = ` Эта задача классифицирована как ${ taskType } . Реестр рекомендует узел ${ recNode } ${ chainSuf } . Вызови соответствующий навык ПЕРВЫМ, либо начни ответ с <!-- routing: direct_justified=true reason="..." --> с явным обоснованием. ` ;
}
2026-05-25 11:58:34 +03:00
return { decision : 'block' , reason : reasonText , reason _code : block . reason } ;
2026-05-24 11:05:00 +03:00
}
2026-05-25 11:58:34 +03:00
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)' ;
2026-05-24 11:05:00 +03:00
return {
2026-05-25 11:58:34 +03:00
warning : ` [router-gate WARN-ONLY] ${ tool } would be blocked — recommended ${ recNode } . ` ,
2026-05-24 11:05:00 +03:00
} ;
}
2026-05-25 11:58:34 +03:00
2026-05-24 11:05:00 +03:00
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' ) ) ;
2026-05-25 11:58:34 +03:00
if ( data . mode === 'enforce' ) return 'enforce' ;
if ( data . mode === 'off' ) return 'off' ;
return 'warn-only' ;
2026-05-24 11:05:00 +03:00
} 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 ( ) {
2026-05-24 15:36:14 +03:00
const input = await readStdinAsUtf8 ( process . stdin ) ;
2026-05-24 11:05:00 +03:00
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 || '' ;
2026-05-25 11:58:34 +03:00
const decision = decideDecision ( tool , state , responseText , { mode , bashCommand } ) ;
2026-05-24 11:05:00 +03:00
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' ;
2026-05-24 15:36:14 +03:00
import { readStdinAsUtf8 } from './router-stdin-helper.mjs' ;
2026-05-24 11:05:00 +03:00
if ( process . argv [ 1 ] && fileURLToPath ( import . meta . url ) === process . argv [ 1 ] ) { main ( ) ; }