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 new file mode 100644 index 00000000..32b59032 --- /dev/null +++ b/apps/mcp-server/src/mcp/handlers/council-scene.builder.spec.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from 'vitest'; +import { buildCouncilScene } from './council-scene.builder'; +import type { CouncilPreset } from '../../agent/council-preset.types'; +import type { VisualData } from '../../keyword/keyword.types'; + +describe('buildCouncilScene', () => { + const planPreset: CouncilPreset = { + mode: 'PLAN', + primary: 'technical-planner', + specialists: ['architecture-specialist', 'security-specialist'], + }; + + const evalPreset: CouncilPreset = { + mode: 'EVAL', + primary: 'code-reviewer', + specialists: ['security-specialist', 'performance-specialist'], + }; + + const visual: VisualData = { + banner: '╭━━━━━╮\n┃ ◊‿◊ ┃ PLAN mode!\n╰━━┳━━╯', + agents: [ + { name: 'Technical Planner', face: '◇‿◇', color: 'magenta', status: 'analyzing' }, + { name: 'Architecture Specialist', face: '⬡‿⬡', color: 'blue', status: 'waiting' }, + { name: 'Security Specialist', face: '◮‿◮', color: 'red', status: 'waiting' }, + ], + collaboration: { format: 'minimal', renderHint: 'Display agent collaboration' }, + }; + + it('returns undefined for ACT mode', () => { + expect(buildCouncilScene('ACT', planPreset, visual)).toBeUndefined(); + }); + + it('returns councilScene for PLAN mode with councilPreset', () => { + const scene = buildCouncilScene('PLAN', planPreset, undefined); + expect(scene).toBeDefined(); + expect(scene!.enabled).toBe(true); + expect(scene!.format).toBe('tiny-actor-grid'); + expect(scene!.moderatorCopy).toContain('design this together'); + expect(scene!.cast).toHaveLength(3); + expect(scene!.cast[0]).toEqual({ + name: 'technical-planner', + role: 'primary', + face: '●‿●', + }); + expect(scene!.cast[1].role).toBe('specialist'); + }); + + it('returns councilScene for EVAL mode with councilPreset', () => { + const scene = buildCouncilScene('EVAL', evalPreset, undefined); + expect(scene).toBeDefined(); + expect(scene!.enabled).toBe(true); + expect(scene!.moderatorCopy).toContain('Review council'); + expect(scene!.cast[0].name).toBe('code-reviewer'); + expect(scene!.cast[0].role).toBe('primary'); + }); + + it('returns councilScene for AUTO mode from visual agents', () => { + const scene = buildCouncilScene('AUTO', undefined, visual); + expect(scene).toBeDefined(); + expect(scene!.enabled).toBe(true); + expect(scene!.moderatorCopy).toContain('Autonomous council'); + expect(scene!.cast[0].role).toBe('primary'); + expect(scene!.cast[0].name).toBe('Technical Planner'); + expect(scene!.cast[0].face).toBe('◇‿◇'); + expect(scene!.cast.slice(1).every(m => m.role === 'specialist')).toBe(true); + }); + + it('returns councilScene for AUTO mode from fallback when visual is undefined', () => { + const scene = buildCouncilScene('AUTO', undefined, undefined, { + delegatesTo: 'agent-architect', + specialists: ['security-specialist'], + }); + expect(scene).toBeDefined(); + expect(scene!.cast[0]).toEqual({ + name: 'agent-architect', + role: 'primary', + face: '●‿●', + }); + expect(scene!.cast[1]).toEqual({ + name: 'security-specialist', + role: 'specialist', + face: '●‿●', + }); + }); + + it('returns undefined when no data is available', () => { + const scene = buildCouncilScene('AUTO', undefined, undefined); + expect(scene).toBeUndefined(); + }); + + it('cross-references faces from visual data for councilPreset agents', () => { + const scene = buildCouncilScene('PLAN', planPreset, visual); + expect(scene).toBeDefined(); + // "technical-planner" slug matches "Technical Planner" → face "◇‿◇" + expect(scene!.cast[0].face).toBe('◇‿◇'); + // "architecture-specialist" slug matches "Architecture Specialist" → face "⬡‿⬡" + expect(scene!.cast[1].face).toBe('⬡‿⬡'); + }); + + it('cast has exactly one primary member', () => { + const scene = buildCouncilScene('PLAN', planPreset, visual); + expect(scene).toBeDefined(); + const primaries = scene!.cast.filter(m => m.role === 'primary'); + expect(primaries).toHaveLength(1); + }); + + it('is serializable JSON', () => { + const scene = buildCouncilScene('PLAN', planPreset, visual); + expect(scene).toBeDefined(); + const roundTripped = JSON.parse(JSON.stringify(scene)); + expect(roundTripped).toEqual(scene); + }); +}); diff --git a/apps/mcp-server/src/mcp/handlers/council-scene.builder.ts b/apps/mcp-server/src/mcp/handlers/council-scene.builder.ts new file mode 100644 index 00000000..f60b41f4 --- /dev/null +++ b/apps/mcp-server/src/mcp/handlers/council-scene.builder.ts @@ -0,0 +1,183 @@ +/** + * Council Scene Builder — constructs the opening council-scene contract (#1366). + * + * Pure function: no I/O, no side-effects. Depends only on data already + * resolved by the handler (councilPreset, visual data, agent IDs). + */ + +import type { CouncilScene, CouncilSceneCastMember } from './council-scene.types'; +import type { CouncilPreset } from '../../agent/council-preset.types'; +import type { Mode, VisualData, AgentVisualInfo } from '../../keyword/keyword.types'; + +/** Default face when no visual data is available */ +const DEFAULT_FACE = '●‿●'; + +/** Mode-specific moderator opening lines (deterministic, testable) */ +const MODERATOR_COPY: Partial> = { + PLAN: 'Council assembled — let us design this together.', + EVAL: 'Review council convened — specialists are ready.', + AUTO: 'Autonomous council activated — full cycle begins.', +}; + +/** Optional agent ID fallback for modes without a council preset */ +export interface CouncilSceneFallback { + /** Primary agent ID (e.g. "agent-architect") */ + delegatesTo?: string; + /** Specialist agent IDs (e.g. ["security-specialist", ...]) */ + specialists?: string[]; +} + +/** + * Build a CouncilScene for the given mode, or return undefined for ACT mode. + * + * Resolution strategy: + * - PLAN/EVAL → uses councilPreset (primary + specialists), faces from visual + * - AUTO → visual.agents first, then agent ID fallback + * - ACT → undefined (no council scene) + */ +export function buildCouncilScene( + mode: Mode, + councilPreset: CouncilPreset | undefined, + visual: VisualData | undefined, + fallback?: CouncilSceneFallback, +): CouncilScene | undefined { + if (mode === 'ACT') { + return undefined; + } + + const moderatorCopy = MODERATOR_COPY[mode]; + if (!moderatorCopy) { + return undefined; + } + + const cast = buildCast(councilPreset, visual, fallback); + if (cast.length === 0) { + return undefined; + } + + return { + enabled: true, + cast, + moderatorCopy, + format: 'tiny-actor-grid', + }; +} + +/** + * Build the cast list for the council scene. + */ +function buildCast( + councilPreset: CouncilPreset | undefined, + visual: VisualData | undefined, + fallback?: CouncilSceneFallback, +): CouncilSceneCastMember[] { + // Build a face lookup from visual agents (keyed by display name and slug) + const faceLookup = new Map(); + if (visual?.agents) { + for (const agent of visual.agents) { + faceLookup.set(agent.name, agent.face); + const slug = agent.name.toLowerCase().replace(/\s+/g, '-'); + faceLookup.set(slug, agent.face); + } + } + + // Priority 1: councilPreset (PLAN/EVAL) + if (councilPreset) { + return buildCastFromPreset(councilPreset, faceLookup); + } + + // Priority 2: visual agents (when loaded) + if (visual?.agents?.length) { + return buildCastFromVisual(visual.agents); + } + + // Priority 3: agent ID fallback (AUTO mode when visual loading fails) + if (fallback?.delegatesTo) { + return buildCastFromFallback(fallback, faceLookup); + } + + return []; +} + +/** + * Build cast from a council preset, cross-referencing faces from visual data. + */ +function buildCastFromPreset( + preset: CouncilPreset, + faceLookup: Map, +): CouncilSceneCastMember[] { + const cast: CouncilSceneCastMember[] = []; + + cast.push({ + name: preset.primary, + role: 'primary', + face: faceLookup.get(preset.primary) ?? DEFAULT_FACE, + }); + + for (const specialist of preset.specialists) { + cast.push({ + name: specialist, + role: 'specialist', + face: faceLookup.get(specialist) ?? DEFAULT_FACE, + }); + } + + return cast; +} + +/** + * Build cast from visual agent info. + * First agent is tagged as primary, rest as specialists. + */ +function buildCastFromVisual(agents: AgentVisualInfo[]): CouncilSceneCastMember[] { + const [first, ...rest] = agents; + const cast: CouncilSceneCastMember[] = []; + + if (first) { + cast.push({ + name: first.name, + role: 'primary', + face: first.face || DEFAULT_FACE, + }); + } + + for (const agent of rest) { + cast.push({ + name: agent.name, + role: 'specialist', + face: agent.face || DEFAULT_FACE, + }); + } + + return cast; +} + +/** + * Build cast from agent ID fallback (when visual data is unavailable). + */ +function buildCastFromFallback( + fallback: CouncilSceneFallback, + faceLookup: Map, +): CouncilSceneCastMember[] { + const cast: CouncilSceneCastMember[] = []; + + if (fallback.delegatesTo) { + cast.push({ + name: fallback.delegatesTo, + role: 'primary', + face: faceLookup.get(fallback.delegatesTo) ?? DEFAULT_FACE, + }); + } + + if (fallback.specialists) { + for (const specialist of fallback.specialists) { + cast.push({ + name: specialist, + role: 'specialist', + face: faceLookup.get(specialist) ?? DEFAULT_FACE, + }); + } + } + + return cast; +} diff --git a/apps/mcp-server/src/mcp/handlers/council-scene.types.ts b/apps/mcp-server/src/mcp/handlers/council-scene.types.ts new file mode 100644 index 00000000..7c95668d --- /dev/null +++ b/apps/mcp-server/src/mcp/handlers/council-scene.types.ts @@ -0,0 +1,34 @@ +/** + * Council Scene Types — first-response council-scene contract (#1366). + * + * When present in the parse_mode response, the consuming assistant should + * open its first visible response with a Tiny Actor council scene followed + * by structured consensus output (risks / disagreements / next step). + */ + +/** A single cast member in the council scene */ +export interface CouncilSceneCastMember { + /** Agent identifier (kebab-case slug, e.g. "technical-planner") */ + name: string; + /** Role in the council */ + role: 'primary' | 'specialist'; + /** ASCII face expression (e.g. "◇‿◇") */ + face: string; +} + +/** + * Opening council-scene contract for eligible workflow modes. + * + * Scoped to PLAN, EVAL, and AUTO modes. ACT mode and unrelated + * prompts must NOT include this field. + */ +export interface CouncilScene { + /** Whether the council scene should be rendered */ + enabled: boolean; + /** Ordered list of council members (primary first) */ + cast: CouncilSceneCastMember[]; + /** Opening moderator line for the scene */ + moderatorCopy: string; + /** Rendering format hint */ + format: 'tiny-actor-grid'; +} 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 1cdca3e6..416027a1 100644 --- a/apps/mcp-server/src/mcp/handlers/mode.handler.spec.ts +++ b/apps/mcp-server/src/mcp/handlers/mode.handler.spec.ts @@ -1923,4 +1923,117 @@ describe('ModeHandler', () => { expect(parsed.executionGate).toBeUndefined(); }); }); + + describe('councilScene contract', () => { + it('should include councilScene with enabled=true in PLAN mode', async () => { + const result = await handler.handle('parse_mode', { + prompt: 'PLAN design auth feature', + }); + + expect(result?.isError).toBeFalsy(); + const parsed = JSON.parse((result?.content[0] as { text: string }).text); + expect(parsed.councilScene).toBeDefined(); + expect(parsed.councilScene.enabled).toBe(true); + expect(parsed.councilScene.format).toBe('tiny-actor-grid'); + expect(parsed.councilScene.moderatorCopy).toEqual(expect.any(String)); + expect(parsed.councilScene.moderatorCopy.length).toBeGreaterThan(0); + expect(parsed.councilScene.cast).toEqual(expect.any(Array)); + expect(parsed.councilScene.cast.length).toBeGreaterThan(0); + }); + + it('should include councilScene with enabled=true in EVAL mode', async () => { + mockKeywordService.parseMode = vi.fn().mockResolvedValue({ + ...mockParseModeResult, + mode: 'EVAL', + 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.councilScene).toBeDefined(); + expect(parsed.councilScene.enabled).toBe(true); + expect(parsed.councilScene.format).toBe('tiny-actor-grid'); + expect(parsed.councilScene.cast).toEqual(expect.any(Array)); + expect(parsed.councilScene.cast.length).toBeGreaterThan(0); + }); + + it('should include councilScene with enabled=true in AUTO mode', async () => { + mockKeywordService.parseMode = vi.fn().mockResolvedValue({ + ...mockParseModeResult, + mode: 'AUTO', + originalPrompt: 'implement dashboard', + delegates_to: 'agent-architect', + }); + + const result = await handler.handle('parse_mode', { + prompt: 'AUTO implement dashboard', + }); + + expect(result?.isError).toBeFalsy(); + const parsed = JSON.parse((result?.content[0] as { text: string }).text); + expect(parsed.councilScene).toBeDefined(); + expect(parsed.councilScene.enabled).toBe(true); + expect(parsed.councilScene.format).toBe('tiny-actor-grid'); + }); + + it('should NOT include councilScene in ACT mode', async () => { + mockKeywordService.parseMode = vi.fn().mockResolvedValue({ + ...mockParseModeResult, + mode: 'ACT', + 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.councilScene).toBeUndefined(); + }); + + it('councilScene cast members should have name, role, and face', async () => { + const result = await handler.handle('parse_mode', { + prompt: 'PLAN design feature', + }); + + expect(result?.isError).toBeFalsy(); + const parsed = JSON.parse((result?.content[0] as { text: string }).text); + expect(parsed.councilScene).toBeDefined(); + + for (const member of parsed.councilScene.cast) { + expect(member.name).toEqual(expect.any(String)); + expect(member.role).toMatch(/^(primary|specialist)$/); + expect(member.face).toEqual(expect.any(String)); + } + }); + + it('councilScene cast should have exactly one primary in PLAN mode', async () => { + const result = await handler.handle('parse_mode', { + prompt: 'PLAN design feature', + }); + + expect(result?.isError).toBeFalsy(); + const parsed = JSON.parse((result?.content[0] as { text: string }).text); + const primaries = parsed.councilScene.cast.filter( + (m: { role: string }) => m.role === 'primary', + ); + expect(primaries).toHaveLength(1); + }); + + it('councilScene should be serializable JSON', async () => { + const result = await handler.handle('parse_mode', { + prompt: 'PLAN design feature', + }); + + expect(result?.isError).toBeFalsy(); + const parsed = JSON.parse((result?.content[0] as { text: string }).text); + const reserialized = JSON.stringify(parsed.councilScene); + expect(JSON.parse(reserialized)).toEqual(parsed.councilScene); + }); + }); }); diff --git a/apps/mcp-server/src/mcp/handlers/mode.handler.ts b/apps/mcp-server/src/mcp/handlers/mode.handler.ts index 3049c66e..944a772c 100644 --- a/apps/mcp-server/src/mcp/handlers/mode.handler.ts +++ b/apps/mcp-server/src/mcp/handlers/mode.handler.ts @@ -45,6 +45,7 @@ import { RuleEventCollector } from '../../rules/rule-event-collector'; import { evaluateClarification, type ClarificationMetadata } from './clarification-gate'; import { resolvePlanningStage, type PlanningStageMetadata } from './planning-stage'; import { evaluateExecutionGate, type ExecutionGate } from './execution-gate'; +import { buildCouncilScene } from './council-scene.builder'; /** Maximum length for context title slug generation */ const CONTEXT_TITLE_MAX_LENGTH = 50; @@ -336,6 +337,17 @@ export class ModeHandler extends AbstractHandler { settings?.eco, ); + // Council Scene contract (#1366) — opening scene for PLAN/EVAL/AUTO modes + const councilScene = buildCouncilScene( + result.mode as Mode, + councilPreset ?? undefined, + visual, + { + delegatesTo: result.delegates_to, + specialists: result.parallelAgentsRecommendation?.specialists, + }, + ); + // 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. @@ -379,6 +391,8 @@ export class ModeHandler extends AbstractHandler { ...(visual && { visual }), // Include council preset for PLAN/EVAL modes ...(councilPreset && { councilPreset }), + // Include council scene contract for PLAN/EVAL/AUTO modes (#1366) + ...(councilScene && { councilScene }), // Include execution plan metadata when dispatch is active ...(executionPlan && { executionPlan: serializeExecutionPlan(executionPlan) }), // Include Teams capability status diff --git a/packages/claude-code-plugin/hooks/lib/mode_engine.py b/packages/claude-code-plugin/hooks/lib/mode_engine.py index 82cbf5c3..32ff11dc 100644 --- a/packages/claude-code-plugin/hooks/lib/mode_engine.py +++ b/packages/claude-code-plugin/hooks/lib/mode_engine.py @@ -15,6 +15,43 @@ CHAR_LIMIT = 2000 +# Council presets per eligible mode (mirrors MCP server CouncilPresetService) +COUNCIL_PRESETS = { + "PLAN": { + "primary": "technical-planner", + "specialists": [ + "architecture-specialist", + "test-strategy-specialist", + "code-quality-specialist", + "security-specialist", + ], + }, + "EVAL": { + "primary": "code-reviewer", + "specialists": [ + "security-specialist", + "performance-specialist", + "accessibility-specialist", + ], + }, + "AUTO": { + "primary": "auto-mode", + "specialists": [ + "architecture-specialist", + "test-strategy-specialist", + "security-specialist", + "code-quality-specialist", + ], + }, +} + +# Mode-specific moderator opening lines (mirrors MCP server council-scene.builder) +MODERATOR_COPY = { + "PLAN": "Council assembled — let us design this together.", + "EVAL": "Review council convened — specialists are ready.", + "AUTO": "Autonomous council activated — full cycle begins.", +} + # Default agents per mode DEFAULT_AGENTS = { "PLAN": {"name": "technical-planner", "title": "Technical Planner"}, @@ -216,6 +253,43 @@ def _load_agent_details(self, agent_name: str) -> Optional[dict]: except (OSError, json.JSONDecodeError, ValueError): return None + def build_council_scene(self, mode: str) -> Optional[dict]: + """ + Build council scene contract for eligible modes. + + Mirrors the MCP server's ``buildCouncilScene`` output so that + standalone mode produces an equivalent first-response contract. + + Args: + mode: Mode name (PLAN, ACT, EVAL, AUTO) + + Returns: + Dict matching the councilScene JSON schema, or None for ACT mode. + """ + mode_upper = mode.upper() + if mode_upper == "ACT": + return None + + preset = COUNCIL_PRESETS.get(mode_upper) + moderator = MODERATOR_COPY.get(mode_upper) + if not preset or not moderator: + return None + + cast = [ + {"name": preset["primary"], "role": "primary", "face": "●‿●"} + ] + for specialist in preset["specialists"]: + cast.append( + {"name": specialist, "role": "specialist", "face": "●‿●"} + ) + + return { + "enabled": True, + "cast": cast, + "moderatorCopy": moderator, + "format": "tiny-actor-grid", + } + def build_instructions(self, mode: str) -> str: """ Build complete mode instructions for hook output. @@ -236,6 +310,12 @@ def build_instructions(self, mode: str) -> str: instructions = template.format(agent_name=agent["name"]) + # Council scene contract for eligible modes (#1366) + 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}" + # Enrich with .ai-rules data enrichment = self._build_rules_snippet(mode_upper, agent["name"]) if enrichment: 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 d0c62c03..33b8685f 100644 --- a/packages/claude-code-plugin/hooks/lib/test_mode_engine.py +++ b/packages/claude-code-plugin/hooks/lib/test_mode_engine.py @@ -5,7 +5,15 @@ import tempfile import unittest -from mode_engine import ModeEngine, _resolve_rules_dir, DEFAULT_AGENTS, MODE_TEMPLATES, CHAR_LIMIT +from mode_engine import ( + ModeEngine, + _resolve_rules_dir, + DEFAULT_AGENTS, + MODE_TEMPLATES, + CHAR_LIMIT, + COUNCIL_PRESETS, + MODERATOR_COPY, +) class TestResolveRulesDir(unittest.TestCase): @@ -367,6 +375,74 @@ def test_all_modes_within_limit_with_real_rules(self): self.assertLessEqual(len(result), CHAR_LIMIT) +class TestCouncilScene(unittest.TestCase): + """Test council scene contract generation (#1366).""" + + def setUp(self): + self.engine = ModeEngine(rules_dir=None) + + def test_plan_returns_council_scene(self): + scene = self.engine.build_council_scene("PLAN") + self.assertIsNotNone(scene) + self.assertTrue(scene["enabled"]) + self.assertEqual(scene["format"], "tiny-actor-grid") + self.assertIn("design this together", scene["moderatorCopy"]) + + def test_eval_returns_council_scene(self): + scene = self.engine.build_council_scene("EVAL") + self.assertIsNotNone(scene) + self.assertTrue(scene["enabled"]) + self.assertIn("Review council", scene["moderatorCopy"]) + + def test_auto_returns_council_scene(self): + scene = self.engine.build_council_scene("AUTO") + self.assertIsNotNone(scene) + self.assertTrue(scene["enabled"]) + self.assertIn("Autonomous council", scene["moderatorCopy"]) + + def test_act_returns_none(self): + scene = self.engine.build_council_scene("ACT") + self.assertIsNone(scene) + + def test_cast_has_one_primary(self): + scene = self.engine.build_council_scene("PLAN") + primaries = [m for m in scene["cast"] if m["role"] == "primary"] + self.assertEqual(len(primaries), 1) + + def test_cast_members_have_required_fields(self): + scene = self.engine.build_council_scene("PLAN") + for member in scene["cast"]: + self.assertIn("name", member) + self.assertIn("role", member) + self.assertIn("face", member) + self.assertIn(member["role"], ("primary", "specialist")) + + def test_plan_cast_matches_preset(self): + scene = self.engine.build_council_scene("PLAN") + preset = COUNCIL_PRESETS["PLAN"] + self.assertEqual(scene["cast"][0]["name"], preset["primary"]) + specialist_names = [m["name"] for m in scene["cast"][1:]] + self.assertEqual(specialist_names, preset["specialists"]) + + def test_case_insensitive(self): + scene = self.engine.build_council_scene("plan") + self.assertIsNotNone(scene) + + def test_council_scene_in_build_instructions(self): + result = self.engine.build_instructions("PLAN") + 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) + + def test_serializable_json(self): + scene = self.engine.build_council_scene("PLAN") + roundtripped = json.loads(json.dumps(scene)) + self.assertEqual(roundtripped, scene) + + class TestModeEngineGracefulFallback(unittest.TestCase): """Test graceful degradation when .ai-rules/ is missing.""" diff --git a/packages/claude-code-plugin/scripts/migrate-legacy-hooks.js b/packages/claude-code-plugin/scripts/migrate-legacy-hooks.js index 50c8042f..fff3ffbb 100644 --- a/packages/claude-code-plugin/scripts/migrate-legacy-hooks.js +++ b/packages/claude-code-plugin/scripts/migrate-legacy-hooks.js @@ -224,7 +224,7 @@ function migrateLegacyHooks(opts = {}) { if (!fs.existsSync(claudeDir)) return result; const hooksDir = path.join(claudeDir, 'hooks'); - const log = (line) => logger(line); + const log = line => logger(line); if (fs.existsSync(hooksDir)) { migrateStaleScript({ hooksDir, dryRun, log, result });