Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .squad/agents/eecom/history.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

120 changes: 116 additions & 4 deletions packages/squad-cli/src/cli/core/cast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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<Record<string, EngineAgentRole>> = {
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;
Expand Down Expand Up @@ -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

Expand Down
6 changes: 5 additions & 1 deletion packages/squad-cli/src/cli/shell/coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 9 additions & 2 deletions packages/squad-cli/src/cli/shell/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -896,7 +896,7 @@ export async function runShell(): Promise<void> {
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);
Expand All @@ -912,6 +912,13 @@ export async function runShell(): Promise<void> {
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',
Expand Down
120 changes: 120 additions & 0 deletions test/casting-engine-integration.test.ts
Original file line number Diff line number Diff line change
@@ -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
});
});
Loading