fix(router-gate): stream A decide — unicode boundary on cyrillic direct-invocation, polite skill_call forms, +tests, knownInRegistry contract docs

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-29 21:26:10 +03:00
parent 7f21cd929a
commit bdd573a129
2 changed files with 38 additions and 2 deletions
+18 -2
View File
@@ -3,6 +3,11 @@
* 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'];
@@ -34,9 +39,12 @@ export function nodeMatches(recommendation, toolUse, resolveAlias = (x) => x) {
export const DIRECT_INVOCATION_PATTERNS = [
{ re: /^\/([a-z0-9_-]+)(\s|$)/iu, type: 'slash', group: 1 },
{ re: /(?:вызови|примени)\s+Skill\(([^)]+)\)/iu, type: 'skill_call', 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 },
{ re: /(?:делай|сделай|вызови|примени|используй)\s+([a-z][a-z0-9:-]+)/iu, type: 'literal_name', 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 = {}) {
@@ -53,6 +61,7 @@ export function detectDirectInvocation(userPrompt, opts = {}) {
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 = [
@@ -102,6 +111,13 @@ export function chainStateUpdate(chainState, matchedNode, nowMs) {
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 = {},
+20
View File
@@ -109,6 +109,24 @@ describe('detectDirectInvocation', () => {
const r = detectDirectInvocation('');
expect(r.matched).toBe(false);
});
// Fix 3: boundary tests for Fix 1+2
it('делай subagent-driven-development → literal_name (boundary at string start)', () => {
const r = detectDirectInvocation('делай subagent-driven-development');
expect(r.matched).toBe(true);
expect(r.type).toBe('literal_name');
expect(r.name).toBe('subagent-driven-development');
});
it('вообщеиспользуй brain-retro → does NOT match literal_name (fused word rejected)', () => {
const r = detectDirectInvocation('вообщеиспользуй brain-retro');
// The literal_name pattern must NOT fire because "используй" is fused with "вообще"
expect(r.matched).toBe(false);
});
it('примените Skill(brain-retro) → skill_call (polite form Fix 2)', () => {
const r = detectDirectInvocation('примените Skill(brain-retro)');
expect(r.matched).toBe(true);
expect(r.type).toBe('skill_call');
expect(r.name).toBe('brain-retro');
});
});
// ─── crossCheckSelfSuggested ─────────────────────────────────────────────
@@ -335,6 +353,8 @@ describe('decide — Поведение 3 chain', () => {
});
expect(r.decision).toBe('block');
expect(r.behavior_branch).toBe('3_chain');
// Fix 4: chain-block reason must mention AskUserQuestion
expect(r.reason).toContain('AskUserQuestion');
});
it('Read with active chain → allow', () => {
const r = decide({