From bc80121f12ed3c157c7e1c8d779b40a0331e2e7f Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Tue, 7 Apr 2026 19:29:15 +0900 Subject: [PATCH] feat(mcp-server): preserve planning agent question-first guidance at standard verbosity (#1419) Add planningContract field to IncludedAgent that survives all verbosity levels. Planning agents (technical-planner, solution-architect, plan-mode, auto-mode) in PLAN/AUTO modes receive core question-first rules regardless of whether systemPrompt is truncated to a summary. - Create pure planning-contract.ts module with resolvePlanningContract() - Add planningContract optional field to IncludedAgent interface - Wire into addIncludedAgentToResult after both full and standard paths - 14 unit tests covering mode/agent filtering and edge cases --- .../mcp-server/src/keyword/keyword.service.ts | 7 ++ apps/mcp-server/src/keyword/keyword.types.ts | 2 + .../mcp/handlers/planning-contract.spec.ts | 77 +++++++++++++++++++ .../src/mcp/handlers/planning-contract.ts | 39 ++++++++++ 4 files changed, 125 insertions(+) create mode 100644 apps/mcp-server/src/mcp/handlers/planning-contract.spec.ts create mode 100644 apps/mcp-server/src/mcp/handlers/planning-contract.ts diff --git a/apps/mcp-server/src/keyword/keyword.service.ts b/apps/mcp-server/src/keyword/keyword.service.ts index e34b2bbb..1af7c051 100644 --- a/apps/mcp-server/src/keyword/keyword.service.ts +++ b/apps/mcp-server/src/keyword/keyword.service.ts @@ -37,6 +37,7 @@ import { isTaskmaestroAvailable } from './taskmaestro-detector'; import { type ClientType } from '../shared/client-type'; import { getDiffFiles, analyzeDiffFiles, type DiffAnalysisResult } from './diff-analyzer'; import { matchStack, type StackMatchInput, type StackMatchResult } from '../agent/stack-matcher'; +import { resolvePlanningContract } from '../mcp/handlers/planning-contract'; /** * Options for parseMode method @@ -992,6 +993,12 @@ export class KeywordService { }; } + // Attach planning contract regardless of verbosity level + const planningContract = resolvePlanningContract(mode, result.delegates_to); + if (planningContract) { + result.included_agent.planningContract = [...planningContract]; + } + this.logger.log(`Auto-included agent: ${agentPrompt.displayName} (verbosity: ${verbosity})`); } } diff --git a/apps/mcp-server/src/keyword/keyword.types.ts b/apps/mcp-server/src/keyword/keyword.types.ts index d299338f..a6329906 100644 --- a/apps/mcp-server/src/keyword/keyword.types.ts +++ b/apps/mcp-server/src/keyword/keyword.types.ts @@ -388,6 +388,8 @@ export interface IncludedAgent { systemPrompt: string; /** Agent's areas of expertise */ expertise: string[]; + /** Core planning behavior rules that survive all verbosity levels. */ + planningContract?: string[]; } /** Source of Primary Agent selection */ diff --git a/apps/mcp-server/src/mcp/handlers/planning-contract.spec.ts b/apps/mcp-server/src/mcp/handlers/planning-contract.spec.ts new file mode 100644 index 00000000..46fb26f2 --- /dev/null +++ b/apps/mcp-server/src/mcp/handlers/planning-contract.spec.ts @@ -0,0 +1,77 @@ +import { resolvePlanningContract, PLANNING_CONTRACT } from './planning-contract'; + +describe('planning-contract', () => { + describe('PLANNING_CONTRACT', () => { + it('should be a readonly array with expected length', () => { + expect(PLANNING_CONTRACT).toHaveLength(5); + }); + + it('should contain question-first guidance rules', () => { + expect(PLANNING_CONTRACT[0]).toContain('clarifying question'); + expect(PLANNING_CONTRACT[1]).toContain('Wait for user confirmation'); + }); + }); + + describe('resolvePlanningContract', () => { + it('should return contract for PLAN mode with technical-planner', () => { + const result = resolvePlanningContract('PLAN', 'technical-planner'); + expect(result).toBe(PLANNING_CONTRACT); + }); + + it('should return contract for PLAN mode with solution-architect', () => { + const result = resolvePlanningContract('PLAN', 'solution-architect'); + expect(result).toBe(PLANNING_CONTRACT); + }); + + it('should return contract for PLAN mode with plan-mode', () => { + const result = resolvePlanningContract('PLAN', 'plan-mode'); + expect(result).toBe(PLANNING_CONTRACT); + }); + + it('should return contract for AUTO mode with auto-mode', () => { + const result = resolvePlanningContract('AUTO', 'auto-mode'); + expect(result).toBe(PLANNING_CONTRACT); + }); + + it('should return contract for AUTO mode with technical-planner', () => { + const result = resolvePlanningContract('AUTO', 'technical-planner'); + expect(result).toBe(PLANNING_CONTRACT); + }); + + it('should return undefined for ACT mode', () => { + const result = resolvePlanningContract('ACT', 'technical-planner'); + expect(result).toBeUndefined(); + }); + + it('should return undefined for EVAL mode', () => { + const result = resolvePlanningContract('EVAL', 'technical-planner'); + expect(result).toBeUndefined(); + }); + + it('should return undefined for non-planning agents in PLAN mode', () => { + const result = resolvePlanningContract('PLAN', 'code-reviewer'); + expect(result).toBeUndefined(); + }); + + it('should return undefined for non-planning agents in AUTO mode', () => { + const result = resolvePlanningContract('AUTO', 'frontend-developer'); + expect(result).toBeUndefined(); + }); + + it('should handle case-insensitive mode matching', () => { + expect(resolvePlanningContract('plan', 'technical-planner')).toBe(PLANNING_CONTRACT); + expect(resolvePlanningContract('Plan', 'technical-planner')).toBe(PLANNING_CONTRACT); + expect(resolvePlanningContract('auto', 'auto-mode')).toBe(PLANNING_CONTRACT); + }); + + it('should return undefined when agentId is undefined', () => { + const result = resolvePlanningContract('PLAN'); + expect(result).toBeUndefined(); + }); + + it('should return undefined when agentId is undefined in AUTO mode', () => { + const result = resolvePlanningContract('AUTO'); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/apps/mcp-server/src/mcp/handlers/planning-contract.ts b/apps/mcp-server/src/mcp/handlers/planning-contract.ts new file mode 100644 index 00000000..00893276 --- /dev/null +++ b/apps/mcp-server/src/mcp/handlers/planning-contract.ts @@ -0,0 +1,39 @@ +/** + * Minimum planning behavior contract that must survive standard verbosity. + * These rules define the core question-first planning workflow. + */ +export const PLANNING_CONTRACT: readonly string[] = [ + 'Ask one clarifying question at a time — do not batch questions.', + 'Wait for user confirmation before advancing to the next planning stage.', + 'Use the recommended skill for the current stage (brainstorming for discover, writing-plans for plan).', + 'Present 2-3 alternative approaches with trade-offs before settling on a direction.', + 'Break implementation into bite-sized tasks (2-5 minutes each).', +]; + +/** Agent IDs that qualify for the planning contract. */ +const PLANNING_AGENT_IDS = new Set([ + 'technical-planner', + 'solution-architect', + 'plan-mode', + 'auto-mode', +]); + +/** Modes that qualify for the planning contract. */ +const PLANNING_MODES = new Set(['PLAN', 'AUTO']); + +/** + * Determine whether the planning contract should be included. + * Returns the contract array if applicable, undefined otherwise. + */ +export function resolvePlanningContract( + mode: string, + agentId?: string, +): readonly string[] | undefined { + if (!PLANNING_MODES.has(mode.toUpperCase())) { + return undefined; + } + if (agentId && PLANNING_AGENT_IDS.has(agentId)) { + return PLANNING_CONTRACT; + } + return undefined; +}