Skip to content

Commit 24685c1

Browse files
committed
feat(mcp-server): gate expensive specialist execution until clarification confirmed
Closes #1378 Delay specialist dispatch and tool-heavy execution until the request is clarified enough to justify the cost. Builds on Clarification Gate (#1371) and Staged Planning (#1372). Gating rules (evaluated in order): 1. planReady=true → ungated (full dispatch) 2. clarificationNeeded=true → gated (ambiguous) 3. currentStage in {discover,design} → gated (still exploring) 4. Otherwise → ungated New response field in PLAN/AUTO parse_mode response: executionGate: { gated: boolean reason: string unblockCondition?: string deferredSpecialists?: string[] } When gated, specialists that would normally be dispatched are preserved in deferredSpecialists for later dispatch after the gate opens. Backward-compatible: clear prompts (planReady=true) are never gated. ACT/EVAL modes omit executionGate entirely. Tests: 14 unit + 6 integration (gated/ungated/budget-exhausted/ ACT-EVAL skip/AUTO mode).
1 parent 1812c1d commit 24685c1

4 files changed

Lines changed: 555 additions & 0 deletions

File tree

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { evaluateExecutionGate, type ExecutionGateInput } from './execution-gate';
3+
import type { ClarificationMetadata } from './clarification-gate';
4+
import type { PlanningStageMetadata } from './planning-stage';
5+
6+
// ---------------------------------------------------------------------------
7+
// Helper factories
8+
// ---------------------------------------------------------------------------
9+
10+
function ambiguousClarification(): ClarificationMetadata {
11+
return {
12+
clarificationNeeded: true,
13+
planReady: false,
14+
questionBudget: 2,
15+
nextQuestion: 'What should change?',
16+
clarificationTopics: ['vague-intent'],
17+
};
18+
}
19+
20+
function clearClarification(): ClarificationMetadata {
21+
return {
22+
clarificationNeeded: false,
23+
planReady: true,
24+
questionBudget: 3,
25+
};
26+
}
27+
28+
function budgetExhaustedClarification(): ClarificationMetadata {
29+
return {
30+
clarificationNeeded: false,
31+
planReady: true,
32+
questionBudget: 0,
33+
assumptionNote: 'Proceeding with assumptions.',
34+
};
35+
}
36+
37+
function discoverStage(): PlanningStageMetadata {
38+
return {
39+
currentStage: 'discover',
40+
stageDescription: 'Discover: Surface questions...',
41+
nextStage: 'design',
42+
stageTransitionHint: 'Answer clarification to proceed.',
43+
recommendedAgent: 'solution-architect',
44+
recommendedSkill: 'brainstorming',
45+
};
46+
}
47+
48+
function designStage(): PlanningStageMetadata {
49+
return {
50+
currentStage: 'design',
51+
stageDescription: 'Design: Synthesize approaches...',
52+
nextStage: 'plan',
53+
stageTransitionHint: 'Confirm approach to proceed.',
54+
recommendedAgent: 'solution-architect',
55+
};
56+
}
57+
58+
function planStage(): PlanningStageMetadata {
59+
return {
60+
currentStage: 'plan',
61+
stageDescription: 'Plan: Produce implementation plan.',
62+
recommendedAgent: 'technical-planner',
63+
recommendedSkill: 'writing-plans',
64+
};
65+
}
66+
67+
const SAMPLE_SPECIALISTS = [
68+
'security-specialist',
69+
'performance-specialist',
70+
'code-quality-specialist',
71+
];
72+
73+
// ---------------------------------------------------------------------------
74+
// Tests
75+
// ---------------------------------------------------------------------------
76+
77+
describe('execution-gate', () => {
78+
describe('evaluateExecutionGate', () => {
79+
// ------------------------------------------------------------------
80+
// Gated scenarios
81+
// ------------------------------------------------------------------
82+
83+
describe('gated (ambiguous / early stage)', () => {
84+
it('gates when clarificationNeeded=true', () => {
85+
const input: ExecutionGateInput = {
86+
clarification: ambiguousClarification(),
87+
specialists: SAMPLE_SPECIALISTS,
88+
};
89+
90+
const result = evaluateExecutionGate(input);
91+
92+
expect(result.gated).toBe(true);
93+
expect(result.reason).toContain('ambiguous');
94+
expect(result.unblockCondition).toBeTruthy();
95+
expect(result.deferredSpecialists).toEqual(SAMPLE_SPECIALISTS);
96+
});
97+
98+
it('gates when currentStage=discover', () => {
99+
const input: ExecutionGateInput = {
100+
clarification: { clarificationNeeded: false, planReady: false },
101+
planningStage: discoverStage(),
102+
specialists: SAMPLE_SPECIALISTS,
103+
};
104+
105+
const result = evaluateExecutionGate(input);
106+
107+
expect(result.gated).toBe(true);
108+
expect(result.reason).toContain('discover');
109+
expect(result.unblockCondition).toContain('Design');
110+
});
111+
112+
it('gates when currentStage=design', () => {
113+
const input: ExecutionGateInput = {
114+
clarification: { clarificationNeeded: false, planReady: false },
115+
planningStage: designStage(),
116+
specialists: SAMPLE_SPECIALISTS,
117+
};
118+
119+
const result = evaluateExecutionGate(input);
120+
121+
expect(result.gated).toBe(true);
122+
expect(result.reason).toContain('design');
123+
expect(result.unblockCondition).toContain('Plan');
124+
});
125+
126+
it('defers specialists list when gated', () => {
127+
const input: ExecutionGateInput = {
128+
clarification: ambiguousClarification(),
129+
specialists: ['security-specialist', 'accessibility-specialist'],
130+
};
131+
132+
const result = evaluateExecutionGate(input);
133+
134+
expect(result.deferredSpecialists).toEqual([
135+
'security-specialist',
136+
'accessibility-specialist',
137+
]);
138+
});
139+
140+
it('omits deferredSpecialists when no specialists provided', () => {
141+
const input: ExecutionGateInput = {
142+
clarification: ambiguousClarification(),
143+
};
144+
145+
const result = evaluateExecutionGate(input);
146+
147+
expect(result.gated).toBe(true);
148+
expect(result.deferredSpecialists).toBeUndefined();
149+
});
150+
151+
it('omits deferredSpecialists when specialists list is empty', () => {
152+
const input: ExecutionGateInput = {
153+
clarification: ambiguousClarification(),
154+
specialists: [],
155+
};
156+
157+
const result = evaluateExecutionGate(input);
158+
159+
expect(result.gated).toBe(true);
160+
expect(result.deferredSpecialists).toBeUndefined();
161+
});
162+
});
163+
164+
// ------------------------------------------------------------------
165+
// Ungated scenarios
166+
// ------------------------------------------------------------------
167+
168+
describe('ungated (clear / plan stage)', () => {
169+
it('does not gate when planReady=true', () => {
170+
const input: ExecutionGateInput = {
171+
clarification: clearClarification(),
172+
specialists: SAMPLE_SPECIALISTS,
173+
};
174+
175+
const result = evaluateExecutionGate(input);
176+
177+
expect(result.gated).toBe(false);
178+
expect(result.reason).toContain('clear');
179+
expect(result.unblockCondition).toBeUndefined();
180+
expect(result.deferredSpecialists).toBeUndefined();
181+
});
182+
183+
it('does not gate when currentStage=plan', () => {
184+
const input: ExecutionGateInput = {
185+
clarification: clearClarification(),
186+
planningStage: planStage(),
187+
specialists: SAMPLE_SPECIALISTS,
188+
};
189+
190+
const result = evaluateExecutionGate(input);
191+
192+
expect(result.gated).toBe(false);
193+
});
194+
195+
it('does not gate when budget is exhausted (planReady forced)', () => {
196+
const input: ExecutionGateInput = {
197+
clarification: budgetExhaustedClarification(),
198+
specialists: SAMPLE_SPECIALISTS,
199+
};
200+
201+
const result = evaluateExecutionGate(input);
202+
203+
expect(result.gated).toBe(false);
204+
});
205+
206+
it('planReady=true overrides discover stage', () => {
207+
const input: ExecutionGateInput = {
208+
clarification: clearClarification(),
209+
planningStage: discoverStage(),
210+
specialists: SAMPLE_SPECIALISTS,
211+
};
212+
213+
const result = evaluateExecutionGate(input);
214+
215+
// planReady takes precedence over stage
216+
expect(result.gated).toBe(false);
217+
});
218+
219+
it('planReady=true overrides design stage', () => {
220+
const input: ExecutionGateInput = {
221+
clarification: clearClarification(),
222+
planningStage: designStage(),
223+
specialists: SAMPLE_SPECIALISTS,
224+
};
225+
226+
const result = evaluateExecutionGate(input);
227+
228+
expect(result.gated).toBe(false);
229+
});
230+
});
231+
232+
// ------------------------------------------------------------------
233+
// Priority / edge cases
234+
// ------------------------------------------------------------------
235+
236+
describe('priority and edge cases', () => {
237+
it('clarificationNeeded takes priority over planningStage', () => {
238+
const input: ExecutionGateInput = {
239+
clarification: ambiguousClarification(),
240+
planningStage: planStage(), // even though stage says plan
241+
specialists: SAMPLE_SPECIALISTS,
242+
};
243+
244+
const result = evaluateExecutionGate(input);
245+
246+
// clarificationNeeded=true is checked before stage
247+
expect(result.gated).toBe(true);
248+
expect(result.reason).toContain('ambiguous');
249+
});
250+
251+
it('works without planningStage metadata', () => {
252+
const input: ExecutionGateInput = {
253+
clarification: clearClarification(),
254+
// no planningStage
255+
};
256+
257+
const result = evaluateExecutionGate(input);
258+
259+
expect(result.gated).toBe(false);
260+
});
261+
262+
it('does not mutate the input specialists array', () => {
263+
const specialists = ['a', 'b', 'c'];
264+
const input: ExecutionGateInput = {
265+
clarification: ambiguousClarification(),
266+
specialists,
267+
};
268+
269+
const result = evaluateExecutionGate(input);
270+
271+
expect(result.deferredSpecialists).toEqual(['a', 'b', 'c']);
272+
expect(result.deferredSpecialists).not.toBe(specialists); // different reference
273+
});
274+
});
275+
});
276+
});
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* Execution Gate — delay expensive specialist dispatch until clarification
3+
* is confirmed (#1378).
4+
*
5+
* When a request is still ambiguous (discover/design stage or
6+
* clarificationNeeded=true), dispatching specialists and tool-heavy
7+
* execution creates unnecessary cost and noise. This gate holds
8+
* expensive work until the user has confirmed direction.
9+
*
10+
* Like the Clarification Gate and Planning Stage, this module is
11+
* intentionally pure and free of NestJS dependencies.
12+
*/
13+
14+
import type { ClarificationMetadata } from './clarification-gate';
15+
import type { PlanningStageMetadata } from './planning-stage';
16+
17+
// ---------------------------------------------------------------------------
18+
// Types
19+
// ---------------------------------------------------------------------------
20+
21+
/** Execution gate metadata included in the PLAN/AUTO parse_mode response. */
22+
export interface ExecutionGate {
23+
/** True when expensive execution (specialist dispatch, tool-heavy work) is held. */
24+
gated: boolean;
25+
/** Human-readable reason for the current gate state. */
26+
reason: string;
27+
/** What must happen for the gate to open. Undefined when not gated. */
28+
unblockCondition?: string;
29+
/** Specialists that would have been dispatched but are deferred. Present only when gated. */
30+
deferredSpecialists?: string[];
31+
}
32+
33+
/** Inputs consumed by the execution gate evaluator. */
34+
export interface ExecutionGateInput {
35+
/** Clarification metadata from the Clarification Gate (#1371). */
36+
clarification: ClarificationMetadata;
37+
/** Planning stage metadata from the Stage Router (#1372). */
38+
planningStage?: PlanningStageMetadata;
39+
/** Specialists that would normally be dispatched. */
40+
specialists?: string[];
41+
}
42+
43+
// ---------------------------------------------------------------------------
44+
// Constants
45+
// ---------------------------------------------------------------------------
46+
47+
const GATED_STAGES = new Set(['discover', 'design']);
48+
49+
// ---------------------------------------------------------------------------
50+
// Public entry point
51+
// ---------------------------------------------------------------------------
52+
53+
/**
54+
* Evaluate whether expensive execution should be gated based on
55+
* clarification status and planning stage.
56+
*
57+
* Gating rules (evaluated in order):
58+
* 1. `planReady=true` → ungated (request is clear enough to execute).
59+
* 2. `clarificationNeeded=true` → gated (ambiguous).
60+
* 3. `currentStage` in {discover, design} → gated (still exploring).
61+
* 4. Otherwise → ungated.
62+
*/
63+
export function evaluateExecutionGate(input: ExecutionGateInput): ExecutionGate {
64+
const { clarification, planningStage, specialists } = input;
65+
66+
// 1. planReady → always ungated
67+
if (clarification.planReady) {
68+
return {
69+
gated: false,
70+
reason: 'Request is clear — full specialist dispatch permitted.',
71+
};
72+
}
73+
74+
// 2. clarificationNeeded → gated
75+
if (clarification.clarificationNeeded) {
76+
return buildGatedResult(
77+
'Request is ambiguous — specialist dispatch deferred until clarification is resolved.',
78+
'Resolve clarification questions or provide an explicit override (e.g., "just do it").',
79+
specialists,
80+
);
81+
}
82+
83+
// 3. Stage-based gating (discover/design)
84+
if (planningStage && GATED_STAGES.has(planningStage.currentStage)) {
85+
const stageName = planningStage.currentStage;
86+
return buildGatedResult(
87+
`Currently in ${stageName} stage — specialist dispatch deferred until plan stage.`,
88+
stageName === 'discover'
89+
? 'Confirm direction to proceed through Design to Plan.'
90+
: 'Confirm approach to proceed to Plan.',
91+
specialists,
92+
);
93+
}
94+
95+
// 4. Fallback — ungated
96+
return {
97+
gated: false,
98+
reason: 'Execution permitted — no gating conditions active.',
99+
};
100+
}
101+
102+
// ---------------------------------------------------------------------------
103+
// Helpers
104+
// ---------------------------------------------------------------------------
105+
106+
function buildGatedResult(
107+
reason: string,
108+
unblockCondition: string,
109+
specialists?: string[],
110+
): ExecutionGate {
111+
return {
112+
gated: true,
113+
reason,
114+
unblockCondition,
115+
...(specialists?.length && { deferredSpecialists: [...specialists] }),
116+
};
117+
}

0 commit comments

Comments
 (0)