diff --git a/apps/mcp-server/src/mcp/handlers/council-scene.builder.spec.ts b/apps/mcp-server/src/mcp/handlers/council-scene.builder.spec.ts index 32b59032..1226de3f 100644 --- a/apps/mcp-server/src/mcp/handlers/council-scene.builder.spec.ts +++ b/apps/mcp-server/src/mcp/handlers/council-scene.builder.spec.ts @@ -1,7 +1,8 @@ import { describe, it, expect } from 'vitest'; -import { buildCouncilScene } from './council-scene.builder'; +import { buildCouncilScene, buildCouncilSceneInstructions } from './council-scene.builder'; import type { CouncilPreset } from '../../agent/council-preset.types'; import type { VisualData } from '../../keyword/keyword.types'; +import type { CouncilScene } from './council-scene.types'; describe('buildCouncilScene', () => { const planPreset: CouncilPreset = { @@ -111,3 +112,87 @@ describe('buildCouncilScene', () => { expect(roundTripped).toEqual(scene); }); }); + +describe('buildCouncilSceneInstructions', () => { + it('returns undefined when councilScene is undefined', () => { + expect(buildCouncilSceneInstructions(undefined)).toBeUndefined(); + }); + + it('returns undefined when councilScene has enabled=false', () => { + const scene: CouncilScene = { + enabled: false, + cast: [{ name: 'test', role: 'primary', face: '●‿●' }], + moderatorCopy: 'test copy', + format: 'tiny-actor-grid', + }; + expect(buildCouncilSceneInstructions(scene)).toBeUndefined(); + }); + + it('returns undefined when cast is empty', () => { + const scene: CouncilScene = { + enabled: true, + cast: [], + moderatorCopy: 'test copy', + format: 'tiny-actor-grid', + }; + expect(buildCouncilSceneInstructions(scene)).toBeUndefined(); + }); + + it('returns instruction text for a valid PLAN scene', () => { + const scene: CouncilScene = { + enabled: true, + cast: [ + { name: 'technical-planner', role: 'primary', face: '◇‿◇' }, + { name: 'architecture-specialist', role: 'specialist', face: '⬡‿⬡' }, + { name: 'security-specialist', role: 'specialist', face: '◮‿◮' }, + ], + moderatorCopy: 'Council assembled — let us design this together.', + format: 'tiny-actor-grid', + }; + + const result = buildCouncilSceneInstructions(scene); + expect(result).toBeDefined(); + expect(result).toContain('COUNCIL SCENE'); + expect(result).toContain('Council assembled'); + }); + + it('includes all cast faces, names, and roles', () => { + const scene: CouncilScene = { + enabled: true, + cast: [ + { name: 'technical-planner', role: 'primary', face: '◇‿◇' }, + { name: 'security-specialist', role: 'specialist', face: '◮‿◮' }, + ], + moderatorCopy: 'Council assembled.', + format: 'tiny-actor-grid', + }; + + const result = buildCouncilSceneInstructions(scene)!; + expect(result).toContain('◇‿◇ technical-planner [primary]'); + expect(result).toContain('◮‿◮ security-specialist [specialist]'); + }); + + it('includes moderatorCopy in the output', () => { + const scene: CouncilScene = { + enabled: true, + cast: [{ name: 'code-reviewer', role: 'primary', face: '●‿●' }], + moderatorCopy: 'Review council convened — specialists are ready.', + format: 'tiny-actor-grid', + }; + + const result = buildCouncilSceneInstructions(scene)!; + expect(result).toContain('Review council convened — specialists are ready.'); + }); + + it('includes format hint in the output', () => { + const scene: CouncilScene = { + enabled: true, + cast: [{ name: 'test', role: 'primary', face: '●‿●' }], + moderatorCopy: 'test copy', + format: 'tiny-actor-grid', + }; + + const result = buildCouncilSceneInstructions(scene)!; + expect(result).toContain('tiny-actor-grid'); + }); +}); diff --git a/apps/mcp-server/src/mcp/handlers/council-scene.builder.ts b/apps/mcp-server/src/mcp/handlers/council-scene.builder.ts index f60b41f4..109fdd78 100644 --- a/apps/mcp-server/src/mcp/handlers/council-scene.builder.ts +++ b/apps/mcp-server/src/mcp/handlers/council-scene.builder.ts @@ -27,6 +27,25 @@ export interface CouncilSceneFallback { specialists?: string[]; } +/** + * Build first-response rendering instructions for the council scene. + * Returns instruction text to append to the mode instructions, or undefined if no scene. + */ +export function buildCouncilSceneInstructions(councilScene?: CouncilScene): string | undefined { + if (!councilScene?.enabled || !councilScene.cast?.length) { + return undefined; + } + + const castLines = councilScene.cast.map(m => `${m.face} ${m.name} [${m.role}]`).join('\n'); + + return ( + `\n\n📋 COUNCIL SCENE — Render this at the start of your first response, ` + + `right after the mode header:\n\n` + + `${councilScene.moderatorCopy}\n${castLines}\n\n` + + `Then proceed with your normal ${councilScene.format || 'standard'} mode response.` + ); +} + /** * Build a CouncilScene for the given mode, or return undefined for ACT mode. * diff --git a/apps/mcp-server/src/mcp/handlers/mode.handler.spec.ts b/apps/mcp-server/src/mcp/handlers/mode.handler.spec.ts index 277ffd94..a3dbbbde 100644 --- a/apps/mcp-server/src/mcp/handlers/mode.handler.spec.ts +++ b/apps/mcp-server/src/mcp/handlers/mode.handler.spec.ts @@ -2184,6 +2184,58 @@ describe('ModeHandler', () => { const reserialized = JSON.stringify(parsed.councilScene); expect(JSON.parse(reserialized)).toEqual(parsed.councilScene); }); + + it('PLAN mode instructions should include council scene rendering block', async () => { + mockKeywordService.parseMode = vi.fn().mockResolvedValue({ + ...mockParseModeResult, + mode: 'PLAN', + instructions: 'Base PLAN instructions', + originalPrompt: + 'PLAN implement password reset endpoint that sends an email with a reset link', + }); + + const result = await handler.handle('parse_mode', { + prompt: 'PLAN implement password reset endpoint that sends an email with a reset link', + }); + + expect(result?.isError).toBeFalsy(); + const parsed = JSON.parse((result?.content[0] as { text: string }).text); + expect(parsed.instructions).toContain('COUNCIL SCENE'); + }); + + it('ACT mode instructions should NOT include council scene rendering block', async () => { + mockKeywordService.parseMode = vi.fn().mockResolvedValue({ + ...mockParseModeResult, + mode: 'ACT', + instructions: 'Base ACT instructions', + originalPrompt: 'implement feature', + }); + + const result = await handler.handle('parse_mode', { + prompt: 'ACT implement feature', + }); + + expect(result?.isError).toBeFalsy(); + const parsed = JSON.parse((result?.content[0] as { text: string }).text); + expect(parsed.instructions).not.toContain('COUNCIL SCENE'); + }); + + it('EVAL mode instructions should include council scene rendering block', async () => { + mockKeywordService.parseMode = vi.fn().mockResolvedValue({ + ...mockParseModeResult, + mode: 'EVAL', + instructions: 'Base EVAL instructions', + originalPrompt: 'evaluate implementation', + }); + + const result = await handler.handle('parse_mode', { + prompt: 'EVAL evaluate implementation', + }); + + expect(result?.isError).toBeFalsy(); + const parsed = JSON.parse((result?.content[0] as { text: string }).text); + expect(parsed.instructions).toContain('COUNCIL SCENE'); + }); }); describe('reviewContext (#1411)', () => { diff --git a/apps/mcp-server/src/mcp/handlers/mode.handler.ts b/apps/mcp-server/src/mcp/handlers/mode.handler.ts index dec12ba5..7239236c 100644 --- a/apps/mcp-server/src/mcp/handlers/mode.handler.ts +++ b/apps/mcp-server/src/mcp/handlers/mode.handler.ts @@ -50,7 +50,7 @@ import { suppressDispatchWhileGated, type ExecutionGate, } from './execution-gate'; -import { buildCouncilScene } from './council-scene.builder'; +import { buildCouncilScene, buildCouncilSceneInstructions } from './council-scene.builder'; /** Maximum length for context title slug generation */ const CONTEXT_TITLE_MAX_LENGTH = 50; @@ -356,6 +356,15 @@ export class ModeHandler extends AbstractHandler { }, ); + // Council Scene rendering instructions (#1421) — append to instructions + // so the AI client renders the opening scene in its first response. + // Must come before clarification override since clarification replaces + // instructions entirely when the request is ambiguous. + const councilRenderInstructions = buildCouncilSceneInstructions(councilScene); + if (councilRenderInstructions) { + result.instructions += councilRenderInstructions; + } + // Clarification Gate (#1371) — only applies to PLAN/AUTO modes where the // response might otherwise produce a plan. ACT and EVAL skip the gate // because they assume PLAN has already set context. diff --git a/packages/claude-code-plugin/hooks/lib/mode_engine.py b/packages/claude-code-plugin/hooks/lib/mode_engine.py index 7af10e8d..54fd5d97 100644 --- a/packages/claude-code-plugin/hooks/lib/mode_engine.py +++ b/packages/claude-code-plugin/hooks/lib/mode_engine.py @@ -454,11 +454,19 @@ def build_instructions( instructions = template.format(agent_name=agent["name"]) - # Council scene contract for eligible modes (#1366) + # Council scene rendering instructions for eligible modes (#1366, #1421) council = self.build_council_scene(mode_upper) if council: - names = ", ".join(m["name"] for m in council["cast"]) - instructions += f"\n\nCouncil Scene: {council['moderatorCopy']}\nCast: {names}" + cast_lines = "\n".join( + f"{m.get('face', '●‿●')} {m['name']} [{m['role']}]" + for m in council["cast"] + ) + instructions += ( + f"\n\n📋 COUNCIL SCENE — Render this at the start of your " + f"first response, right after the mode header:\n\n" + f"{council['moderatorCopy']}\n{cast_lines}\n\n" + f"Then proceed with your normal mode response." + ) # Enrich with .ai-rules data enrichment = self._build_rules_snippet(mode_upper, agent["name"]) diff --git a/packages/claude-code-plugin/hooks/lib/test_mode_engine.py b/packages/claude-code-plugin/hooks/lib/test_mode_engine.py index 17eb46cd..6944e52e 100644 --- a/packages/claude-code-plugin/hooks/lib/test_mode_engine.py +++ b/packages/claude-code-plugin/hooks/lib/test_mode_engine.py @@ -430,12 +430,26 @@ def test_case_insensitive(self): def test_council_scene_in_build_instructions(self): result = self.engine.build_instructions("PLAN") - self.assertIn("Council Scene:", result) + self.assertIn("COUNCIL SCENE", result) self.assertIn("technical-planner", result) def test_no_council_scene_in_act_instructions(self): result = self.engine.build_instructions("ACT") - self.assertNotIn("Council Scene:", result) + self.assertNotIn("COUNCIL SCENE", result) + + def test_council_scene_includes_face_name_role(self): + result = self.engine.build_instructions("PLAN") + self.assertIn("●‿● technical-planner [primary]", result) + self.assertIn("[specialist]", result) + + def test_council_scene_includes_moderator_copy(self): + result = self.engine.build_instructions("PLAN") + self.assertIn("Council assembled", result) + + def test_eval_council_scene_rendering(self): + result = self.engine.build_instructions("EVAL") + self.assertIn("COUNCIL SCENE", result) + self.assertIn("Review council convened", result) def test_serializable_json(self): scene = self.engine.build_council_scene("PLAN")