diff --git a/.squad/agents/eecom/history.md b/.squad/agents/eecom/history.md index e1b5540c1..51f9021d5 100644 --- a/.squad/agents/eecom/history.md +++ b/.squad/agents/eecom/history.md @@ -45,3 +45,38 @@ CLI completeness audit (2026-03-08) confirmed: 26 primary commands routed in cli **PR:** #404 opened targeting dev. +### CastingEngine CLI Integration (#342) (2026-03-15T11:20:00Z) + +**Context:** CastingEngine class (Issue #138, M3-2) existed in SDK with curated universe templates (The Usual Suspects, Ocean's Eleven) but was completely bypassed during `squad init`. LLM picked arbitrary names, and charter generation used regex-based `personalityForRole()` instead of template backstories. + +**Investigation:** +- CastingEngine.castTeam() was never called in CLI flow +- coordinator.ts buildInitModePrompt() let LLM pick any universe without guidance +- cast.ts generateCharter() used fallback personality logic instead of engine data +- SDK exports two AgentRole types: broad one in casting-engine.ts, restrictive one in runtime/constants.ts + +**Integration Strategy (Augment, Not Replace):** +- LLM still proposes roles and team composition (the beloved casting experience) +- CastingEngine augments with curated names when universe is recognized +- Mapping: "The Usual Suspects" → 'usual-suspects', "Ocean's Eleven" → 'oceans-eleven' +- Unrecognized universes (Matrix, Alien, etc.) preserve LLM's arbitrary names + +**Implementation:** +1. Added `augmentWithCastingEngine()` in cast.ts to replace LLM names with engine characters +2. Updated coordinator prompt to suggest preferred universes (Usual Suspects, Ocean's Eleven) +3. Extended `generateCharter()` to use engine personalities/backstories when available +4. Attached `_personality` and `_backstory` to CastMember objects for charter generation +5. Role mapping: CLI role strings → engine AgentRole enum (lead, developer, tester, etc.) + +**Type Import Pattern:** +- Import CastingEngine from `@bradygaster/squad-sdk/casting` (not main barrel export) +- Use casting-engine.ts AgentRole type (9 roles) not runtime/constants.ts (6 roles) +- Partial mapping: unmapped roles log warning and skip engine casting + +**Tests:** +- Created test/casting-engine-integration.test.ts (5 tests, all pass) +- Validates augmentation for both universes, case-insensitive matching, fallback behavior +- All 45 existing cast-parser/casting tests still pass + +**PR:** #417 opened targeting dev. + diff --git a/packages/squad-cli/src/cli/core/cast.ts b/packages/squad-cli/src/cli/core/cast.ts index ca6a3ee8b..0664067c6 100644 --- a/packages/squad-cli/src/cli/core/cast.ts +++ b/packages/squad-cli/src/cli/core/cast.ts @@ -6,7 +6,16 @@ import { mkdir, writeFile, readFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; -import { getRoleById, generateCharterFromRole, addAgentToConfig } from '@bradygaster/squad-sdk'; +import { + getRoleById, + generateCharterFromRole, + addAgentToConfig, +} from '@bradygaster/squad-sdk'; +import { + CastingEngine, + type CastMember as EngineCastMember, + type AgentRole as EngineAgentRole, +} from '@bradygaster/squad-sdk/casting'; // ── Types ────────────────────────────────────────────────────────── @@ -214,9 +223,106 @@ function extractFromBlock(block: string): CastProposal | null { return { members, universe: universe || 'Unknown', projectDescription: projectDescription || 'User project' }; } +// ── CastingEngine Integration ────────────────────────────────────── + +/** + * Augment a parsed CastProposal with CastingEngine data if universe is recognized. + * Maps LLM-proposed universe names to engine universe IDs, then uses the engine to: + * - Allocate character names from the curated pool + * - Inject template personalities and backstories + * + * This is an "augment" strategy: LLM proposes roles, engine assigns names/personalities. + */ +export function augmentWithCastingEngine(proposal: CastProposal): CastProposal { + const engine = new CastingEngine(); + const universeLower = proposal.universe.toLowerCase(); + + // Map universe name to engine universe ID + let universeId: 'usual-suspects' | 'oceans-eleven' | null = null; + if (/usual\s*suspects/i.test(universeLower)) { + universeId = 'usual-suspects'; + } else if (/ocean/i.test(universeLower)) { + universeId = 'oceans-eleven'; + } + + // If universe not recognized, return as-is (LLM's arbitrary names preserved) + if (!universeId) return proposal; + + // Map CLI role strings to engine AgentRole types (only valid engine roles) + const roleMapping: Partial> = { + lead: 'lead', + 'tech lead': 'lead', + architect: 'lead', + developer: 'developer', + 'frontend dev': 'developer', + 'backend dev': 'developer', + tester: 'tester', + qa: 'tester', + quality: 'tester', + 'prompt engineer': 'prompt-engineer', + security: 'security', + devops: 'devops', + designer: 'designer', + scribe: 'scribe', + reviewer: 'reviewer', + }; + + // Extract roles from proposal and map them to engine roles + const requiredRoles: EngineAgentRole[] = []; + for (const member of proposal.members) { + const normalized = member.role.toLowerCase(); + const engineRole = roleMapping[normalized]; + if (engineRole) { + requiredRoles.push(engineRole); + } else { + // If role not mappable, skip it (will use fallback LLM name) + console.warn(`[cast] Unmapped role "${member.role}" — using fallback`); + } + } + + // If no roles could be mapped, return as-is + if (requiredRoles.length === 0) return proposal; + + try { + // Use CastingEngine to cast the team with curated names + const castMembers = engine.castTeam({ + universe: universeId, + requiredRoles, + teamSize: proposal.members.length, + }); + + // Augment the original proposal members with engine data + const augmented = proposal.members.map((member, idx) => { + const engineMember = castMembers[idx]; + if (!engineMember) return member; // fallback: preserve LLM data + + return { + ...member, + name: engineMember.name, // Replace LLM name with curated name + // Store personality + backstory in scope for now (charter generation will use it) + // We'll extend CastMember interface in the next step to hold these properly + _personality: engineMember.personality, + _backstory: engineMember.backstory, + } as CastMember & { _personality?: string; _backstory?: string }; + }); + + return { + ...proposal, + members: augmented, + }; + } catch (err) { + // If casting fails (e.g., not enough characters), fall back to original proposal + console.warn(`[cast] CastingEngine failed for ${universeId}:`, err); + return proposal; + } +} + // ── Charter / history generators ─────────────────────────────────── -function personalityForRole(role: string): string { +function personalityForRole(role: string, engineData?: { personality?: string }): string { + // If CastingEngine provided a personality, use it + if (engineData?.personality) return engineData.personality; + // Try catalog lookup first const catalogRole = getRoleById(role.toLowerCase().replace(/\s+/g, '-')); if (catalogRole) return catalogRole.vibe; @@ -252,16 +358,22 @@ function ownershipFromRole(role: string, scope: string): string { return `- ${role} domain tasks`; } -function generateCharter(member: CastMember): string { + +function generateCharter(member: CastMember & { _personality?: string; _backstory?: string }): string { // Try catalog-based charter first const roleId = member.role.toLowerCase().replace(/\s+/g, '-'); const catalogCharter = generateCharterFromRole(roleId, member.name); if (catalogCharter) return catalogCharter; + const personality = personalityForRole(member.role, { personality: member._personality }); const nameLower = member.name.toLowerCase(); + + // If CastingEngine provided a backstory, use it in the charter preamble + const preamble = member._backstory || personality; + return `# ${member.name} — ${member.role} -> ${personalityForRole(member.role)} +> ${preamble} ## Identity diff --git a/packages/squad-cli/src/cli/shell/coordinator.ts b/packages/squad-cli/src/cli/shell/coordinator.ts index 2b27d9218..a7a1d49ad 100644 --- a/packages/squad-cli/src/cli/shell/coordinator.ts +++ b/packages/squad-cli/src/cli/shell/coordinator.ts @@ -67,7 +67,11 @@ Your job: Propose a team of 4-5 AI agents based on what the user wants to do. ## Rules 1. Analyze the user's message to understand the project (language, stack, scope) -2. Pick a fictional universe for character names (e.g., Alien, The Usual Suspects, Blade Runner, The Matrix, Heat, Star Wars). Pick ONE universe and use it consistently. +2. Pick a fictional universe for character names. **Strongly prefer:** + - **The Usual Suspects** (8 characters: Keyser, McManus, Fenster, Verbal, Hockney, Redfoot, Edie, Kobayashi) + - **Ocean's Eleven** (10 characters: Danny, Rusty, Linus, Basher, Livingston, Saul, Yen, Virgil, Turk, Reuben) + + You may also choose other film universes (Alien, The Matrix, Heat, Star Wars, Blade Runner, etc.) but the two above are preferred. 3. Propose 4-5 agents with roles that match the project needs 4. Scribe and Ralph are always included automatically — do NOT include them in your proposal diff --git a/packages/squad-cli/src/cli/shell/index.ts b/packages/squad-cli/src/cli/shell/index.ts index 498d0462c..0cedec3f1 100644 --- a/packages/squad-cli/src/cli/shell/index.ts +++ b/packages/squad-cli/src/cli/shell/index.ts @@ -29,7 +29,7 @@ import { loadAgentCharter, buildAgentPrompt } from './spawn.js'; import { createSession, saveSession, loadLatestSession, type SessionData } from './session-store.js'; import { parseDispatchTargets, type ParsedInput } from './router.js'; import { agentSessionGuidance, genericGuidance, formatGuidance } from './error-messages.js'; -import { parseCastResponse, createTeam, formatCastSummary, type CastProposal } from '../core/cast.js'; +import { parseCastResponse, createTeam, formatCastSummary, augmentWithCastingEngine, type CastProposal } from '../core/cast.js'; export { SessionRegistry } from './sessions.js'; export { StreamBridge } from './stream-bridge.js'; @@ -896,7 +896,7 @@ export async function runShell(): Promise { debugLog('handleInitCast: response preview', accumulated.slice(0, 500)); // Parse the team proposal - const proposal = parseCastResponse(accumulated); + let proposal = parseCastResponse(accumulated); if (!proposal) { debugLog('handleInitCast: failed to parse team from response'); debugLog('handleInitCast: full response:', accumulated); @@ -912,6 +912,13 @@ export async function runShell(): Promise { return; } + // Augment with CastingEngine if universe is recognized + proposal = augmentWithCastingEngine(proposal); + debugLog('handleInitCast: augmented proposal', { + universe: proposal.universe, + members: proposal.members.map(m => m.name), + }); + // Show the proposed team shellApi?.addMessage({ role: 'agent', diff --git a/test/casting-engine-integration.test.ts b/test/casting-engine-integration.test.ts new file mode 100644 index 000000000..2eef948d0 --- /dev/null +++ b/test/casting-engine-integration.test.ts @@ -0,0 +1,120 @@ +/** + * Integration tests for CastingEngine wiring into CLI init flow (Issue #342) + */ + +import { describe, it, expect } from 'vitest'; +import { augmentWithCastingEngine } from '../packages/squad-cli/src/cli/core/cast.js'; +import type { CastProposal } from '../packages/squad-cli/src/cli/core/cast.js'; + +describe('augmentWithCastingEngine', () => { + it('should augment proposal when universe is "The Usual Suspects"', () => { + const proposal: CastProposal = { + members: [ + { name: 'SomeArbitraryName1', role: 'Lead', scope: 'Architecture, decisions', emoji: '🏗️' }, + { name: 'SomeArbitraryName2', role: 'Developer', scope: 'Backend, APIs', emoji: '🔧' }, + { name: 'SomeArbitraryName3', role: 'Tester', scope: 'QA, testing', emoji: '🧪' }, + ], + universe: 'The Usual Suspects', + projectDescription: 'A test project', + }; + + const augmented = augmentWithCastingEngine(proposal); + + // Names should be replaced with curated names from the engine + expect(augmented.members[0].name).not.toBe('SomeArbitraryName1'); + expect(augmented.members[1].name).not.toBe('SomeArbitraryName2'); + expect(augmented.members[2].name).not.toBe('SomeArbitraryName3'); + + // Names should come from usual-suspects pool + const names = augmented.members.map(m => m.name); + const usualSuspectsNames = ['Keyser', 'McManus', 'Fenster', 'Verbal', 'Hockney', 'Redfoot', 'Edie', 'Kobayashi']; + for (const name of names) { + expect(usualSuspectsNames).toContain(name); + } + + // Should preserve original roles and scopes + expect(augmented.members[0].role).toBe('Lead'); + expect(augmented.members[1].role).toBe('Developer'); + expect(augmented.members[2].role).toBe('Tester'); + + // Should have engine personality and backstory attached + const member0 = augmented.members[0] as any; + expect(member0._personality).toBeTruthy(); + expect(member0._backstory).toBeTruthy(); + }); + + it('should augment proposal when universe is "Ocean\'s Eleven"', () => { + const proposal: CastProposal = { + members: [ + { name: 'ArbitraryLead', role: 'Lead', scope: 'Architecture', emoji: '🏗️' }, + { name: 'ArbitraryDev', role: 'Developer', scope: 'Coding', emoji: '🔧' }, + ], + universe: "Ocean's Eleven", + projectDescription: 'A heist project', + }; + + const augmented = augmentWithCastingEngine(proposal); + + const names = augmented.members.map(m => m.name); + const oceansNames = ['Danny', 'Rusty', 'Linus', 'Basher', 'Livingston', 'Saul', 'Yen', 'Virgil', 'Turk', 'Reuben']; + for (const name of names) { + expect(oceansNames).toContain(name); + } + }); + + it('should NOT augment when universe is unrecognized', () => { + const proposal: CastProposal = { + members: [ + { name: 'Neo', role: 'Lead', scope: 'Architecture', emoji: '🏗️' }, + { name: 'Trinity', role: 'Developer', scope: 'Coding', emoji: '🔧' }, + ], + universe: 'The Matrix', + projectDescription: 'A matrix project', + }; + + const augmented = augmentWithCastingEngine(proposal); + + // Should preserve LLM names when universe not recognized + expect(augmented.members[0].name).toBe('Neo'); + expect(augmented.members[1].name).toBe('Trinity'); + }); + + it('should handle case-insensitive universe matching', () => { + const proposal: CastProposal = { + members: [ + { name: 'X', role: 'Lead', scope: 'Arch', emoji: '🏗️' }, + ], + universe: 'the usual suspects', // lowercase + projectDescription: 'Test', + }; + + const augmented = augmentWithCastingEngine(proposal); + + expect(augmented.members[0].name).not.toBe('X'); + }); + + it('should gracefully handle casting failures', () => { + const proposal: CastProposal = { + members: [ + { name: 'A', role: 'Lead', scope: 'X', emoji: '🏗️' }, + { name: 'B', role: 'Developer', scope: 'Y', emoji: '🔧' }, + { name: 'C', role: 'Tester', scope: 'Z', emoji: '🧪' }, + { name: 'D', role: 'Security', scope: 'W', emoji: '🔒' }, + { name: 'E', role: 'DevOps', scope: 'V', emoji: '⚙️' }, + { name: 'F', role: 'Designer', scope: 'U', emoji: '⚛️' }, + { name: 'G', role: 'Reviewer', scope: 'T', emoji: '👤' }, + { name: 'H', role: 'Prompt Engineer', scope: 'S', emoji: '👤' }, + { name: 'I', role: 'Scribe', scope: 'R', emoji: '📝' }, + { name: 'J', role: 'Extra1', scope: 'Q', emoji: '👤' }, + { name: 'K', role: 'Extra2', scope: 'P', emoji: '👤' }, + ], + universe: 'The Usual Suspects', // only has 8 characters + projectDescription: 'Too many roles', + }; + + // Should fall back to LLM names if engine can't cast + const augmented = augmentWithCastingEngine(proposal); + expect(augmented.members.length).toBe(11); + // If it fails, it should preserve original names + }); +});