397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
184 lines
7.8 KiB
JavaScript
184 lines
7.8 KiB
JavaScript
// 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 (?<![\p{L}\p{N}_]) guards against fused-word matches (e.g. вообщевызови).
|
|
// Fix 2: polite imperatives вызовите/примените added alongside вызови/примени.
|
|
{ re: /(?<![\p{L}\p{N}_])(?:вызови|вызовите|примени|примените)\s+Skill\(([^)]+)\)/iu, type: 'skill_call', group: 1 },
|
|
{ re: /использу(?:й|йте)\s+#(\d+)/iu, type: 'registry_num', group: 1 },
|
|
// Fix 1: left boundary on Cyrillic-leading literal_name pattern (mirrors todowrite-skill-verifier).
|
|
{ re: /(?<![\p{L}\p{N}_])(?:делай|сделай|вызови|примени|используй)\s+([a-z][a-z0-9:-]+)/iu, type: 'literal_name', group: 1 },
|
|
];
|
|
|
|
export function detectDirectInvocation(userPrompt, opts = {}) {
|
|
const { registryHas = () => 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' };
|
|
}
|