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:
@@ -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 = {},
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user