From accc1692e15b87f46db96e61496eb4ffc29c484d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Mon, 25 May 2026 11:58:34 +0300 Subject: [PATCH] =?UTF-8?q?feat(router):=20=C2=A717=20mode-based=20gate,?= =?UTF-8?q?=20continuation=20NOT=20exempt=20(phase=202=20task=2013)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec §4.4 — shouldBlock rewritten on mode='off'|'warn-only'|'enforce'. Old boolean warnOnly API kept as legacy fallback. Continuation deliberately NOT in the §17 exempt set (D1) — an inherited 'feature' classification still triggers the gate. - tools/router-tool-gate.mjs: + NON_BLOCKING_TASK_TYPES = ['conversation','micro','manual_override'] + shouldBlock returns false OR { block: true, reason } with reason ∈ {'no_skill_found_block','direct_in_non_conversation'}. + Reads state.classification.task_type (v4 snake_case) with fallback to legacy taskType — backward-compatible until Task 14 updates prehook. + resolveMode(): options.mode wins; legacy warnOnly=false maps to enforce. + decideDecision returns decision/reason/reason_code on block, warning on warn-only with non-exempt classification, empty on proceed/exempt. + gateMode() now recognises 'off' alongside warn-only/enforce. - tools/router-tool-gate.test.mjs: rewritten 25 tests (mode-based) — covers §17 exempt set, no_skill_found path, skill invoked, routing-tag escape, read-only Bash, tool whitelist, legacy back-compat (warnOnly + taskType), decideDecision reason_code + warn-only warning suppression on exempt tasks. Tests: 25/25 PASS. --- tools/router-tool-gate.mjs | 101 +++++++++++++++++++++++------- tools/router-tool-gate.test.mjs | 107 ++++++++++++++++++++++++-------- 2 files changed, 160 insertions(+), 48 deletions(-) diff --git a/tools/router-tool-gate.mjs b/tools/router-tool-gate.mjs index 0de58591..889ce84a 100644 --- a/tools/router-tool-gate.mjs +++ b/tools/router-tool-gate.mjs @@ -36,37 +36,91 @@ export function decodeRoutingTag(responseText) { return { directJustified: true, reason: m[1] }; } -export function shouldBlock(tool, state, responseText, options = {}) { - const warnOnly = options.warnOnly !== false; // default true - if (warnOnly) return false; +// §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. +const NON_BLOCKING_TASK_TYPES = ['conversation', 'micro', 'manual_override']; - if (!state.enforcementRequired) return false; - if (state.skillInvokedThisTurn) return false; +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 + * `{ block: true, reason: 'direct_in_non_conversation' | 'no_skill_found_block' }` + * 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) + * 3. task_type ∈ NON_BLOCKING_TASK_TYPES → false (§17 exempt set) + * 4. skillInvokedThisTurn === true → false (skill already invoked) + * 5. routing-tag direct_justified=true with reason → false (escape hatch) + * 6. Bash + isReadOnlyBash(cmd) → false (read-only commands) + * 7. tool ∉ {Edit, Write, MultiEdit, Bash} → false (not gated) + * 8. → block(direct_in_non_conversation) + */ +export function shouldBlock(tool, state, responseText, options = {}) { + const mode = resolveMode(options); + if (mode === 'off' || mode === 'warn-only') return false; + + const cls = state?.classification || {}; + + if (cls.no_skill_found === true) { + return { block: true, reason: 'no_skill_found_block' }; + } + + const taskType = resolveTaskType(cls); + if (NON_BLOCKING_TASK_TYPES.includes(taskType)) return false; + if (state?.skillInvokedThisTurn === true) return false; + + const tag = decodeRoutingTag(responseText); + if (tag?.directJustified) return false; if (tool === 'Bash' && isReadOnlyBash(options.bashCommand || '')) return false; if (!['Edit', 'Write', 'MultiEdit', 'Bash'].includes(tool)) return false; - const tag = decodeRoutingTag(responseText); - if (tag && tag.directJustified) return false; - - return true; + return { block: true, reason: 'direct_in_non_conversation' }; } export function decideDecision(tool, state, responseText, options = {}) { - const cls = state.classification || {}; - if (shouldBlock(tool, state, responseText, options)) { - const recommendedNode = cls.recommendedNode || '(unknown)'; - const recommendedChain = cls.recommendedChain ? ` (chain ${cls.recommendedChain})` : ''; + 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})` : ''; + const reasonText = block.reason === 'no_skill_found_block' + ? `Классификатор не нашёл подходящий узел (no_skill_found). Уточни задачу или дай routing-tag direct_justified. Узел: ${recNode}.` + : `Эта задача классифицирована как ${taskType}. Реестр рекомендует узел ${recNode}${chainSuf}. Вызови соответствующий навык ПЕРВЫМ, либо начни ответ с с явным обоснованием.`; + return { decision: 'block', reason: reasonText, reason_code: block.reason }; + } + + 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)'; return { - decision: 'block', - reason: `Эта задача классифицирована как ${cls.taskType}. Реестр рекомендует узел ${recommendedNode}${recommendedChain}. Вызови соответствующий навык ПЕРВЫМ, либо начни ответ с с явным обоснованием.`, - }; - } - if (options.warnOnly && state.enforcementRequired && !state.skillInvokedThisTurn) { - return { - warning: `[router-gate WARN-ONLY] ${tool} would be blocked — recommended ${cls.recommendedNode}.`, + warning: `[router-gate WARN-ONLY] ${tool} would be blocked — recommended ${recNode}.`, }; } + return {}; } @@ -75,7 +129,9 @@ function gateMode() { if (!existsSync(path)) return 'warn-only'; try { const data = JSON.parse(readFileSync(path, 'utf-8')); - return data.mode === 'enforce' ? 'enforce' : 'warn-only'; + if (data.mode === 'enforce') return 'enforce'; + if (data.mode === 'off') return 'off'; + return 'warn-only'; } catch { return 'warn-only'; } } @@ -95,11 +151,10 @@ async function main() { if (!state) { process.stdout.write(JSON.stringify({})); process.exit(0); return; } const mode = gateMode(); - const warnOnly = mode === 'warn-only'; const responseText = ''; // PreToolUse event doesn't include response const bashCommand = (event.tool_input || {}).command || ''; - const decision = decideDecision(tool, state, responseText, { warnOnly, bashCommand }); + const decision = decideDecision(tool, state, responseText, { mode, bashCommand }); if (decision.warning) process.stderr.write(decision.warning + '\n'); process.stdout.write(JSON.stringify(decision.decision ? decision : {})); diff --git a/tools/router-tool-gate.test.mjs b/tools/router-tool-gate.test.mjs index 476c691b..5db40a5a 100644 --- a/tools/router-tool-gate.test.mjs +++ b/tools/router-tool-gate.test.mjs @@ -6,10 +6,14 @@ import { decideDecision, } from './router-tool-gate.mjs'; -const enforcementState = { - enforcementRequired: true, +const baseState = { skillInvokedThisTurn: false, - classification: { taskType: 'feature', recommendedNode: '#19', recommendedChain: 'L1' }, + classification: { + task_type: 'feature', + no_skill_found: false, + recommendedNode: '#19', + recommendedChain: 'L1', + }, chainProgress: [], }; @@ -51,51 +55,104 @@ describe('decodeRoutingTag', () => { }); }); -describe('shouldBlock', () => { - it('blocks Edit on enforcement state without skill invoked', () => { - expect(shouldBlock('Edit', enforcementState, '', { warnOnly: false })).toBe(true); +describe('shouldBlock — §17 mode-based (Phase 2 Task 13)', () => { + it('mode=off never blocks', () => { + expect(shouldBlock('Edit', baseState, '', { mode: 'off' })).toBe(false); }); - it('does NOT block when skill invoked this turn', () => { - const state = { ...enforcementState, skillInvokedThisTurn: true }; - expect(shouldBlock('Edit', state, '', { warnOnly: false })).toBe(false); + it('warn-only never blocks (always returns false)', () => { + expect(shouldBlock('Edit', baseState, '', { mode: 'warn-only' })).toBe(false); }); - it('does NOT block when enforcement not required', () => { - const state = { ...enforcementState, enforcementRequired: false }; - expect(shouldBlock('Edit', state, '', { warnOnly: false })).toBe(false); + it('enforce blocks Edit on feature without skill invoked', () => { + expect(shouldBlock('Edit', baseState, '', { mode: 'enforce' })).toMatchObject({ + block: true, + reason: 'direct_in_non_conversation', + }); }); - it('does NOT block when routing-tag has direct_justified=true with reason', () => { - expect(shouldBlock('Edit', enforcementState, '', { warnOnly: false })).toBe(false); + it('enforce passes conversation task_type (§17 exempt)', () => { + const s = { ...baseState, classification: { task_type: 'conversation', no_skill_found: false } }; + expect(shouldBlock('Edit', s, '', { mode: 'enforce' })).toBe(false); }); - it('does NOT block read-only Bash', () => { - expect(shouldBlock('Bash', enforcementState, '', { warnOnly: false, bashCommand: 'ls' })).toBe(false); + it('enforce passes micro / manual_override (§17 exempt)', () => { + for (const t of ['micro', 'manual_override']) { + const s = { ...baseState, classification: { task_type: t, no_skill_found: false } }; + expect(shouldBlock('Edit', s, '', { mode: 'enforce' })).toBe(false); + } }); - it('warn-only mode never blocks (always returns false)', () => { - expect(shouldBlock('Edit', enforcementState, '', { warnOnly: true })).toBe(false); + it('enforce does NOT block when skill invoked this turn', () => { + const s = { ...baseState, skillInvokedThisTurn: true }; + expect(shouldBlock('Edit', s, '', { mode: 'enforce' })).toBe(false); + }); + + it('enforce blocks no_skill_found=true with specific reason', () => { + const s = { ...baseState, classification: { task_type: 'feature', no_skill_found: true } }; + expect(shouldBlock('Edit', s, '', { mode: 'enforce' })).toMatchObject({ + block: true, + reason: 'no_skill_found_block', + }); + }); + + it('continuation-inherited feature is NOT exempt (D1 — same shape as base)', () => { + expect(shouldBlock('Edit', baseState, '', { mode: 'enforce' })).toMatchObject({ block: true }); + }); + + it('enforce does NOT block when routing-tag has direct_justified=true with reason', () => { + expect(shouldBlock('Edit', baseState, '', { mode: 'enforce' })).toBe(false); + }); + + it('enforce does NOT block read-only Bash', () => { + expect(shouldBlock('Bash', baseState, '', { mode: 'enforce', bashCommand: 'ls' })).toBe(false); + }); + + it('enforce does NOT block tools outside whitelist (e.g. Read)', () => { + expect(shouldBlock('Read', baseState, '', { mode: 'enforce' })).toBe(false); + }); + + it('legacy back-compat: warnOnly=false maps to enforce', () => { + expect(shouldBlock('Edit', baseState, '', { warnOnly: false })).toMatchObject({ block: true }); + }); + + it('legacy back-compat: taskType (camelCase) still recognised', () => { + const s = { ...baseState, classification: { taskType: 'conversation', no_skill_found: false } }; + expect(shouldBlock('Edit', s, '', { mode: 'enforce' })).toBe(false); }); }); -describe('decideDecision', () => { - it('returns decision: block with message when shouldBlock=true', () => { - const r = decideDecision('Edit', enforcementState, '', { warnOnly: false }); +describe('decideDecision — §17 mode-based', () => { + it('returns decision: block with reason text and reason_code when shouldBlock blocks', () => { + const r = decideDecision('Edit', baseState, '', { mode: 'enforce' }); expect(r.decision).toBe('block'); expect(r.reason).toMatch(/#19/); + expect(r.reason_code).toBe('direct_in_non_conversation'); }); - it('returns empty (proceed) when shouldBlock=false', () => { - const r = decideDecision('Edit', { ...enforcementState, skillInvokedThisTurn: true }, '', { warnOnly: false }); + it('returns no_skill_found_block reason_code when classifier signalled no match', () => { + const s = { ...baseState, classification: { task_type: 'feature', no_skill_found: true, recommendedNode: null } }; + const r = decideDecision('Edit', s, '', { mode: 'enforce' }); + expect(r.decision).toBe('block'); + expect(r.reason_code).toBe('no_skill_found_block'); + }); + + it('returns empty (proceed) when skill invoked', () => { + const r = decideDecision('Edit', { ...baseState, skillInvokedThisTurn: true }, '', { mode: 'enforce' }); expect(r.decision).toBeUndefined(); }); - it('warn-only mode logs to stderr but does not block', () => { - const r = decideDecision('Edit', enforcementState, '', { warnOnly: true }); + it('warn-only mode emits warning string but does not block', () => { + const r = decideDecision('Edit', baseState, '', { mode: 'warn-only' }); expect(r.decision).toBeUndefined(); expect(r.warning).toMatch(/#19/); }); + + it('warn-only mode does NOT emit warning when task is exempt (conversation)', () => { + const s = { ...baseState, classification: { task_type: 'conversation', no_skill_found: false } }; + const r = decideDecision('Edit', s, '', { mode: 'warn-only' }); + expect(r.warning).toBeUndefined(); + }); }); describe('UTF-8 cyrillic stdin (regression — Stage 3 fix 1)', () => {