// tools/router-gate-decide.mjs /** * Core decision module — router-gate v4 spec §4 (4 поведения) + §10.1. * Pure: вся behavior-логика allow/block/unlock. Safe-baseline metering, skill-scope, * Bash-content — отдельные слои в хуке (Stream B/G), поверх decide(). * * NOTE on knownInRegistry: directInvocation.knownInRegistry===false is NOT handled * here. Caller (Stream G hook) MUST check knownInRegistry and emit AskUser (with * fuzzy-match suggestions) before calling decide(). decide() unlocks direct * invocations regardless of registry membership. */ export const SAFE_BASELINE_TOOLS = ['Read', 'Grep', 'Glob', 'LS', 'TodoWrite', 'AskUserQuestion']; export const MUTATING_TOOLS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash', 'Skill', 'Task']; const SAFE_SET = new Set(SAFE_BASELINE_TOOLS); const MUT_SET = new Set(MUTATING_TOOLS); export function isSafeBaselineTool(name) { return SAFE_SET.has(name); } export function isMutatingTool(name) { return MUT_SET.has(name); } function normName(n) { return String(n || '').trim().toLowerCase(); } function suffixMatch(a, b) { const x = normName(a); const y = normName(b); return x === y || x.endsWith(':' + y) || y.endsWith(':' + x); } export function nodeMatches(recommendation, toolUse, resolveAlias = (x) => x) { if (!recommendation || !toolUse) return false; const resolved = resolveAlias(recommendation); if (toolUse.name === 'Skill') { return suffixMatch(resolved, toolUse.input?.skill); } if (toolUse.name === 'Task') { return suffixMatch(resolved, toolUse.input?.subagent_type); } return false; } export const DIRECT_INVOCATION_PATTERNS = [ { re: /^\/([a-z0-9_-]+)(\s|$)/iu, type: 'slash', group: 1 }, // Fix 1: left boundary (? true } = opts; const text = String(userPrompt || ''); for (const { re, type, group } of DIRECT_INVOCATION_PATTERNS) { const m = text.match(re); if (m) { const name = m[group]; const known = type === 'registry_num' ? registryHas('#' + name) : registryHas(name); return { matched: true, type, name, knownInRegistry: known }; } } return { matched: false }; } // Used by Stream G hook layer pre-decide(); decide() does not call this. export function crossCheckSelfSuggested(directName, recentControllerResponses) { const target = normName(directName); const patterns = [ /Skill\(([^)]+)\)/giu, /(?:делай|используй|вызови)\s+([a-z][a-z0-9:-]+)/giu, /\/([a-z0-9_-]+)/giu, ]; for (const resp of recentControllerResponses || []) { for (const re of patterns) { for (const m of String(resp).matchAll(re)) { if (suffixMatch(target, m[1])) return { selfSuggested: true }; } } } return { selfSuggested: false }; } const DAY_MS = 86_400_000; export function newChainState(chain, nowMs) { const iso = new Date(nowMs).toISOString(); return { schema_version: 1, chain_active: chain, chain_step: 0, initialized_at: iso, last_step_at: iso, expires_at: new Date(nowMs + DAY_MS).toISOString(), }; } export function isChainExpired(chainState, nowMs) { if (!chainState?.initialized_at) return false; return nowMs - new Date(chainState.initialized_at).getTime() > DAY_MS; } export function chainStateUpdate(chainState, matchedNode, nowMs) { const expected = chainState.chain_active?.[chainState.chain_step]; if (matchedNode && expected && suffixMatch(matchedNode, expected)) { return { ...chainState, chain_step: chainState.chain_step + 1, last_step_at: new Date(nowMs).toISOString(), // initialized_at UNCHANGED — anti-tickle (v4.0 C19) }; } return chainState; } /** * Core 4-behaviour decision function (§4.1-§4.4 spec). * NOTE: directInvocation.knownInRegistry===false is NOT handled here. Caller * (Stream G hook) MUST check knownInRegistry and emit AskUser (with fuzzy-match * suggestions) before calling decide(). decide() unlocks direct invocations * regardless of registry membership. */ export function decide(ctx) { const { classification = {}, turnState = {}, toolUse = {}, directInvocation = null, chainState = null, nowMs = Date.now(), resolveAlias = (x) => x, } = ctx; const recNode = classification.recommended_node ?? null; const recChain = classification.recommended_chain ?? []; const { askuser_called = false, skill_invoked_matching = false } = turnState; const toolName = toolUse.name; // Поведение 1 — direct invocation precedence if (directInvocation?.matched) { if (toolName === 'Skill' || toolName === 'Task') { return { decision: 'unlock', reason: 'direct invocation', behavior_branch: '1_direct_invocation' }; } return { decision: 'allow', reason: 'direct invocation precedence', behavior_branch: '1_direct_invocation' }; } // Поведение 3 — chain active if (recChain.length >= 1 || chainState) { if (chainState && isChainExpired(chainState, nowMs)) { return { decision: 'block', reason: 'chain expired 24h from initialization (anti-tickle)', behavior_branch: '3_chain' }; } const expectedNode = chainState?.chain_active?.[chainState?.chain_step] ?? recChain[0]; if ((toolName === 'Skill' || toolName === 'Task') && nodeMatches(expectedNode, toolUse, resolveAlias)) { return { decision: 'unlock', reason: 'chain step match', behavior_branch: '3_chain' }; } if (isSafeBaselineTool(toolName)) { return { decision: 'allow', reason: 'safe-baseline in chain', behavior_branch: '3_chain' }; } if (askuser_called) { return { decision: 'allow', reason: 'askuser answered', behavior_branch: '3_chain' }; } return { decision: 'block', reason: 'chain active — вызови AskUserQuestion для следующего шага', behavior_branch: '3_chain' }; } // Поведение 2 — single recommendation if (recNode !== null && recChain.length === 0) { if (skill_invoked_matching) { return { decision: 'allow', reason: 'skill invoked matching', behavior_branch: '2_single_rec' }; } if (askuser_called) { return { decision: 'allow', reason: 'askuser answered', behavior_branch: '2_single_rec' }; } if ((toolName === 'Skill' || toolName === 'Task') && nodeMatches(recNode, toolUse, resolveAlias)) { return { decision: 'unlock', reason: 'skill matches rec_node', behavior_branch: '2_single_rec' }; } if (isSafeBaselineTool(toolName)) { return { decision: 'allow', reason: 'safe-baseline', behavior_branch: '2_single_rec' }; } return { decision: 'block', reason: `Router рекомендовал ${recNode}, вызови AskUserQuestion с предложениями`, behavior_branch: '2_single_rec' }; } // Поведение 4 — silence if (isSafeBaselineTool(toolName)) { return { decision: 'allow', reason: 'safe-baseline (silence)', behavior_branch: '4_silence' }; } if (askuser_called) { return { decision: 'allow', reason: 'askuser answered (silence)', behavior_branch: '4_silence' }; } return { decision: 'block', reason: 'AskUserQuestion required для mutating tool (silence)', behavior_branch: '4_silence' }; }