diff --git a/docs/iloom-commands.md b/docs/iloom-commands.md index f62dfded..3f5998f4 100644 --- a/docs/iloom-commands.md +++ b/docs/iloom-commands.md @@ -99,7 +99,7 @@ The override follows a two-level model: **Effort Override:** -The `--effort` flag controls Claude's reasoning depth via the `CLAUDE_CODE_EFFORT_LEVEL` environment variable: +The `--effort` flag controls Claude's reasoning depth: - `low` - Quick, straightforward implementation with minimal overhead - `medium` - Balanced approach with standard implementation and testing - `high` - Comprehensive implementation with extensive testing and documentation @@ -626,15 +626,13 @@ When `il spin` detects an epic loom (created via `il start --epic` or by confirm 1. **Fetches/refreshes child data** - Re-fetches child issue details and dependency map from the issue tracker 2. **Creates child worktrees** - One worktree per child issue, branched off the epic branch, with dependencies installed -3. **Renders swarm agents** - Writes swarm-mode agent templates to `.claude/agents/` in the epic worktree -4. **Renders swarm worker agent** - Writes the iloom workflow as a custom agent type to `.claude/agents/iloom-swarm-worker.md` -5. **Copies agents to child worktrees** - Copies `.claude/agents/` from the epic worktree to each child worktree so workers can resolve agent files locally -6. **Launches orchestrator** - Starts Claude with agent teams enabled and `bypassPermissions` mode +3. **Configures swarm agents** - Sets up the orchestrator and worker agents for the epic and all child worktrees +4. **Launches orchestrator** - Starts the swarm orchestrator session The orchestrator then: - Analyzes the dependency DAG to identify initially unblocked issues -- Spawns parallel agents for all unblocked child issues simultaneously -- Each agent uses the `iloom-swarm-worker` custom agent type, receiving the full iloom workflow as its system prompt +- Spawns parallel worker agents for all unblocked child issues simultaneously +- Each worker receives full issue context and implements its assigned issue autonomously - Completed work is rebased and fast-forward merged into the epic branch for clean linear history - Newly unblocked issues are spawned as their dependencies complete - Failed children are isolated and do not block unrelated issues @@ -653,7 +651,7 @@ il spin --skip-cleanup After all child agents complete and their work is merged into the epic branch, the orchestrator automatically runs a full code review using the `iloom-code-reviewer` agent. This catches cross-cutting issues that individual child agents miss because they each only see their own changes, not the integrated result. -If the review finds any issues (confidence score 80+), a fix agent is spawned to address them before the final commit. The review is non-blocking -- if the reviewer or fix agent fails, the swarm continues to finalization without interruption. Only a single review-fix pass is performed (no re-review loops). +If the review finds issues, they are automatically fixed before the final commit. The review is non-blocking -- if the reviewer or fix agent fails, the swarm continues to finalization without interruption. Only a single review-fix pass is performed (no re-review loops). Post-swarm review is enabled by default. To disable it, set `spin.postSwarmReview` to `false` in your settings: @@ -1908,12 +1906,9 @@ Only sibling dependencies (between child issues of the same epic) are included. Each child agent runs in complete isolation: -1. The orchestrator spawns the agent with `subagent_type: "iloom-swarm-worker"`, passing the child's issue number, title, worktree path, and issue body in the Task prompt -2. The agent's system prompt contains the full iloom issue workflow adapted for swarm mode (high-authority instructions) -3. The agent implements the issue autonomously in its own worktree (branched off the epic branch) -4. On completion, the agent reports back to the orchestrator with status and summary - -The orchestrator uses `bypassPermissions` mode and Claude's agent teams feature, both set automatically. +1. The orchestrator spawns a worker for each child issue, passing its issue number, title, worktree path, and issue body +2. The worker implements the issue autonomously in its own worktree (branched off the epic branch) +3. On completion, the worker reports back to the orchestrator with status and summary **Worker Model Configuration:** @@ -1965,9 +1960,9 @@ With the configuration above: | Agent | Non-swarm mode | Swarm mode | |-------|---------------|------------| -| `iloom-issue-implementer` | `opus` | `sonnet` (swarmModel) | -| `iloom-issue-complexity-evaluator` | `haiku` | `haiku` (swarmModel) | -| `iloom-issue-analyzer` | `.md` default | `opus` (Balanced mode default) | +| `iloom-issue-implementer` | `opus` (settings) | `sonnet` (swarmModel) | +| `iloom-issue-complexity-evaluator` | `haiku` (settings) | `haiku` (swarmModel) | +| `iloom-issue-analyzer` | `opus` (default) | `opus` (default) | **Example using the `--set` flag:** @@ -2077,13 +2072,13 @@ Example settings for each mode: } ``` -These modes use `swarmModel` on phase agents (not `model`), so non-swarm behavior is preserved. When agents run outside of swarm mode, their base `model` setting is used. Mode settings merge with existing agent settings — only the `swarmModel` (and worker `model`) fields are overwritten. +These modes only affect swarm behavior — non-swarm sessions continue using each agent's base `model` setting. To configure, run `il init` — you'll be asked during setup, or you can change it later in the advanced configuration section. ### Effort Configuration -Effort levels control Claude's reasoning depth. iloom propagates effort to Claude Code via the `CLAUDE_CODE_EFFORT_LEVEL` environment variable for top-level sessions and via per-agent `effort:` frontmatter for agent-level overrides. +Effort levels control Claude's reasoning depth. **Valid effort levels:** `low`, `medium`, `high`, `max` @@ -2106,7 +2101,7 @@ Configure default effort levels for spin and plan commands in `.iloom/settings.j **Swarm Orchestrator Effort:** -Set the effort level for the swarm orchestrator using `spin.swarmEffort`. This defaults to `medium` when not configured (matching the current hardcoded behavior): +Set the effort level for the swarm orchestrator using `spin.swarmEffort`. This defaults to `medium` when not configured: ```json { @@ -2148,7 +2143,7 @@ When no user configuration is provided, swarm agents use these defaults: | `iloom-issue-implementer` | `medium` | | `iloom-issue-enhancer` | `medium` | | `iloom-code-reviewer` | `medium` | -| `iloom-issue-complexity-evaluator` | `low` | +| `iloom-issue-complexity-evaluator` | `high` | **Effort Resolution Order:** @@ -2159,8 +2154,6 @@ Effort is resolved with the following priority (highest first): 3. Settings (`spin.effort` / `plan.effort`) 4. No effort set (defers to Claude Code default) -For per-agent effort, Claude Code resolves: agent frontmatter `effort:` > `CLAUDE_CODE_EFFORT_LEVEL` env var > session default. - **Example using the `--set` flag:** ```bash diff --git a/src/lib/AgentManager.integration.test.ts b/src/lib/AgentManager.integration.test.ts new file mode 100644 index 00000000..c1482042 --- /dev/null +++ b/src/lib/AgentManager.integration.test.ts @@ -0,0 +1,221 @@ +import { describe, it, expect } from 'vitest' +import path from 'path' +import { AgentManager } from './AgentManager.js' +import { PromptTemplateManager } from './PromptTemplateManager.js' + +/** + * Integration test for AgentManager + PromptTemplateManager + real agent templates. + * + * These tests read the actual agent markdown files from templates/agents/, + * run real Handlebars substitution, and verify the end-to-end result of + * frontmatter parsing with template variables. + */ + +// Resolve the real templates/agents/ directory relative to the project root +const PROJECT_ROOT = path.resolve(import.meta.dirname, '..', '..') +const AGENTS_DIR = path.join(PROJECT_ROOT, 'templates', 'agents') +const PROMPTS_DIR = path.join(PROJECT_ROOT, 'templates', 'prompts') + +// All agent names expected in the templates/agents/ directory +const ALL_AGENT_NAMES = [ + 'iloom-artifact-reviewer', + 'iloom-code-reviewer', + 'iloom-framework-detector', + 'iloom-issue-analyze-and-plan', + 'iloom-issue-analyzer', + 'iloom-issue-complexity-evaluator', + 'iloom-issue-enhancer', + 'iloom-issue-implementer', + 'iloom-issue-planner', + 'iloom-wave-verifier', +] + +describe('AgentManager integration (real templates)', () => { + // Use real PromptTemplateManager and real agent files + const templateManager = new PromptTemplateManager(PROMPTS_DIR) + + describe('loadAgents with SWARM_MODE=true', () => { + it('should load all agents successfully', async () => { + const manager = new AgentManager(AGENTS_DIR, templateManager) + const agents = await manager.loadAgents(undefined, { SWARM_MODE: true }) + + const loadedNames = Object.keys(agents).sort() + expect(loadedNames).toEqual(ALL_AGENT_NAMES) + }) + + it('should resolve swarm-mode model overrides from Handlebars conditionals', async () => { + const manager = new AgentManager(AGENTS_DIR, templateManager) + const agents = await manager.loadAgents(undefined, { SWARM_MODE: true }) + + // Agents with conditional model: sonnet in swarm mode + expect(agents['iloom-issue-implementer'].model).toBe('sonnet') + expect(agents['iloom-issue-planner'].model).toBe('sonnet') + expect(agents['iloom-issue-enhancer'].model).toBe('sonnet') + expect(agents['iloom-code-reviewer'].model).toBe('sonnet') + + // Agents with unconditional model (always opus regardless of mode) + expect(agents['iloom-issue-analyzer'].model).toBe('opus') + expect(agents['iloom-issue-analyze-and-plan'].model).toBe('opus') + expect(agents['iloom-wave-verifier'].model).toBe('opus') + expect(agents['iloom-framework-detector'].model).toBe('opus') + expect(agents['iloom-artifact-reviewer'].model).toBe('opus') + expect(agents['iloom-issue-complexity-evaluator'].model).toBe('haiku') + }) + + it('should resolve swarm-mode effort defaults from Handlebars conditionals', async () => { + const manager = new AgentManager(AGENTS_DIR, templateManager) + const agents = await manager.loadAgents(undefined, { SWARM_MODE: true }) + + // Agents with conditional effort: {{#if SWARM_MODE}}effort: {{/if}} + expect(agents['iloom-issue-analyzer'].effort).toBe('high') + expect(agents['iloom-issue-planner'].effort).toBe('high') + expect(agents['iloom-issue-implementer'].effort).toBe('medium') + expect(agents['iloom-issue-enhancer'].effort).toBe('medium') + expect(agents['iloom-code-reviewer'].effort).toBe('medium') + expect(agents['iloom-issue-complexity-evaluator'].effort).toBe('high') + expect(agents['iloom-issue-analyze-and-plan'].effort).toBe('high') + + // Agents with unconditional effort (always set regardless of SWARM_MODE) + expect(agents['iloom-wave-verifier'].effort).toBe('high') + expect(agents['iloom-framework-detector'].effort).toBe('high') + + // Agents with no effort field at all + expect(agents['iloom-artifact-reviewer'].effort).toBeUndefined() + }) + + it('should have non-empty prompts for all agents', async () => { + const manager = new AgentManager(AGENTS_DIR, templateManager) + const agents = await manager.loadAgents(undefined, { SWARM_MODE: true }) + + for (const [name, config] of Object.entries(agents)) { + expect(config.prompt.length, `${name} should have a non-empty prompt`).toBeGreaterThan(0) + } + }) + + it('should have non-empty descriptions for all agents', async () => { + const manager = new AgentManager(AGENTS_DIR, templateManager) + const agents = await manager.loadAgents(undefined, { SWARM_MODE: true }) + + for (const [name, config] of Object.entries(agents)) { + expect(config.description.length, `${name} should have a non-empty description`).toBeGreaterThan(0) + } + }) + }) + + describe('loadAgents with SWARM_MODE=false (non-swarm)', () => { + it('should load all agents successfully', async () => { + const manager = new AgentManager(AGENTS_DIR, templateManager) + const agents = await manager.loadAgents(undefined, { SWARM_MODE: false }) + + const loadedNames = Object.keys(agents).sort() + expect(loadedNames).toEqual(ALL_AGENT_NAMES) + }) + + it('should resolve non-swarm model defaults', async () => { + const manager = new AgentManager(AGENTS_DIR, templateManager) + const agents = await manager.loadAgents(undefined, { SWARM_MODE: false }) + + // Agents with conditional model resolve to opus in non-swarm mode + expect(agents['iloom-issue-implementer'].model).toBe('opus') + expect(agents['iloom-issue-planner'].model).toBe('opus') + expect(agents['iloom-issue-enhancer'].model).toBe('opus') + expect(agents['iloom-code-reviewer'].model).toBe('opus') + + // Agents with unconditional model are the same regardless of mode + expect(agents['iloom-issue-analyzer'].model).toBe('opus') + expect(agents['iloom-issue-analyze-and-plan'].model).toBe('opus') + expect(agents['iloom-wave-verifier'].model).toBe('opus') + expect(agents['iloom-framework-detector'].model).toBe('opus') + expect(agents['iloom-artifact-reviewer'].model).toBe('opus') + expect(agents['iloom-issue-complexity-evaluator'].model).toBe('haiku') + }) + + it('should have undefined effort for agents that only set effort in swarm mode', async () => { + const manager = new AgentManager(AGENTS_DIR, templateManager) + const agents = await manager.loadAgents(undefined, { SWARM_MODE: false }) + + // These agents use {{#if SWARM_MODE}}effort: {{/if}} - resolves to empty/undefined + expect(agents['iloom-issue-analyzer'].effort).toBeUndefined() + expect(agents['iloom-issue-planner'].effort).toBeUndefined() + expect(agents['iloom-issue-implementer'].effort).toBeUndefined() + expect(agents['iloom-issue-enhancer'].effort).toBeUndefined() + expect(agents['iloom-code-reviewer'].effort).toBeUndefined() + expect(agents['iloom-issue-complexity-evaluator'].effort).toBeUndefined() + expect(agents['iloom-issue-analyze-and-plan'].effort).toBeUndefined() + + // These agents have unconditional effort - always present + expect(agents['iloom-wave-verifier'].effort).toBe('high') + expect(agents['iloom-framework-detector'].effort).toBe('high') + + // No effort field at all + expect(agents['iloom-artifact-reviewer'].effort).toBeUndefined() + }) + }) + + describe('loadAgents without templateVariables (no substitution)', () => { + it('should load all agents successfully even without template variables', async () => { + const manager = new AgentManager(AGENTS_DIR, templateManager) + const agents = await manager.loadAgents() + + const loadedNames = Object.keys(agents).sort() + expect(loadedNames).toEqual(ALL_AGENT_NAMES) + }) + }) + + describe('swarm vs non-swarm model differences', () => { + it('should produce different models for swarm-conditional agents', async () => { + const manager = new AgentManager(AGENTS_DIR, templateManager) + + const swarmAgents = await manager.loadAgents(undefined, { SWARM_MODE: true }) + const nonSwarmAgents = await manager.loadAgents(undefined, { SWARM_MODE: false }) + + // Agents that change model between swarm and non-swarm + const conditionalModelAgents = [ + 'iloom-issue-implementer', + 'iloom-issue-planner', + 'iloom-issue-enhancer', + 'iloom-code-reviewer', + ] + + for (const name of conditionalModelAgents) { + expect( + swarmAgents[name].model, + `${name} swarm model should be sonnet`, + ).toBe('sonnet') + expect( + nonSwarmAgents[name].model, + `${name} non-swarm model should be opus`, + ).toBe('opus') + } + }) + + it('should produce different effort levels for swarm-conditional agents', async () => { + const manager = new AgentManager(AGENTS_DIR, templateManager) + + const swarmAgents = await manager.loadAgents(undefined, { SWARM_MODE: true }) + const nonSwarmAgents = await manager.loadAgents(undefined, { SWARM_MODE: false }) + + // Agents that only have effort in swarm mode + const conditionalEffortAgents = [ + 'iloom-issue-analyzer', + 'iloom-issue-planner', + 'iloom-issue-implementer', + 'iloom-issue-enhancer', + 'iloom-code-reviewer', + 'iloom-issue-complexity-evaluator', + 'iloom-issue-analyze-and-plan', + ] + + for (const name of conditionalEffortAgents) { + expect( + swarmAgents[name].effort, + `${name} should have effort in swarm mode`, + ).toBeDefined() + expect( + nonSwarmAgents[name].effort, + `${name} should NOT have effort in non-swarm mode`, + ).toBeUndefined() + } + }) + }) +}) diff --git a/src/lib/AgentManager.test.ts b/src/lib/AgentManager.test.ts index 62ddabda..9a13d73a 100644 --- a/src/lib/AgentManager.test.ts +++ b/src/lib/AgentManager.test.ts @@ -953,16 +953,24 @@ Prompt` } const templateVariables = {} as Record + + // Spy on substituteVariables to capture the enriched variables + const substituteSpy = vi.spyOn(manager['templateManager'], 'substituteVariables') + await manager.loadAgents(settings as never, templateVariables) - // Verify key review fields are populated (detailed logic tested in PromptTemplateManager.test.ts) - expect(templateVariables.REVIEW_ENABLED).toBe(true) - expect(templateVariables.ARTIFACT_REVIEW_ENABLED).toBe(true) - expect(templateVariables.HAS_ARTIFACT_REVIEW_CLAUDE).toBe(true) - expect(templateVariables.HAS_ARTIFACT_REVIEW_GEMINI).toBe(true) - expect(templateVariables.ENHANCER_REVIEW_ENABLED).toBe(true) - expect(templateVariables.PLANNER_REVIEW_ENABLED).toBe(true) - expect(templateVariables.ANALYZER_REVIEW_ENABLED).toBe(false) + // Verify enriched variables were passed to substituteVariables (not the original object) + const enrichedVars = substituteSpy.mock.calls[0][1] as Record + expect(enrichedVars.REVIEW_ENABLED).toBe(true) + expect(enrichedVars.ARTIFACT_REVIEW_ENABLED).toBe(true) + expect(enrichedVars.HAS_ARTIFACT_REVIEW_CLAUDE).toBe(true) + expect(enrichedVars.HAS_ARTIFACT_REVIEW_GEMINI).toBe(true) + expect(enrichedVars.ENHANCER_REVIEW_ENABLED).toBe(true) + expect(enrichedVars.PLANNER_REVIEW_ENABLED).toBe(true) + expect(enrichedVars.ANALYZER_REVIEW_ENABLED).toBe(false) + + // Original templateVariables should NOT be mutated + expect(templateVariables.REVIEW_ENABLED).toBeUndefined() }) it('should not populate review fields when templateVariables is not provided', async () => { @@ -1011,10 +1019,13 @@ Prompt` } const templateVariables = { SWARM_MODE: true } as Record + const substituteSpy = vi.spyOn(manager['templateManager'], 'substituteVariables') + await manager.loadAgents(settings as never, templateVariables) // When SWARM_MODE is true, swarmReview: false should override review: true - expect(templateVariables.PLANNER_REVIEW_ENABLED).toBe(false) + const enrichedVars = substituteSpy.mock.calls[0][1] as Record + expect(enrichedVars.PLANNER_REVIEW_ENABLED).toBe(false) }) it('should pass false for isSwarmMode when SWARM_MODE is not in templateVariables', async () => { @@ -1038,10 +1049,145 @@ Prompt` } const templateVariables = {} as Record + const substituteSpy = vi.spyOn(manager['templateManager'], 'substituteVariables') + await manager.loadAgents(settings as never, templateVariables) // When SWARM_MODE is not set, review: true should be used (swarmReview ignored) - expect(templateVariables.PLANNER_REVIEW_ENABLED).toBe(true) + const enrichedVars = substituteSpy.mock.calls[0][1] as Record + expect(enrichedVars.PLANNER_REVIEW_ENABLED).toBe(true) + }) + }) + + describe('template substitution in frontmatter', () => { + it('should resolve Handlebars expressions in frontmatter model field before parsing', async () => { + const mockTemplateManager = { + substituteVariables: vi.fn((content: string, vars: Record) => { + // Simulate Handlebars: resolve {{#if SWARM_MODE}}sonnet{{else}}opus{{/if}} + return content.replace( + /\{\{#if SWARM_MODE\}\}sonnet\{\{else\}\}opus\{\{\/if\}\}/g, + vars.SWARM_MODE ? 'sonnet' : 'opus', + ) + }), + } + + const managerWithTemplate = new AgentManager('templates/agents', mockTemplateManager as never) + + vi.mocked(fg).mockResolvedValueOnce(['agent.md']) + vi.mocked(readFile).mockResolvedValueOnce(`--- +name: test-agent +description: Test agent +model: {{#if SWARM_MODE}}sonnet{{else}}opus{{/if}} +--- + +Prompt content`) + + const result = await managerWithTemplate.loadAgents(undefined, { SWARM_MODE: true }) + + expect(result['test-agent'].model).toBe('sonnet') + expect(mockTemplateManager.substituteVariables).toHaveBeenCalled() + }) + + it('should resolve to non-swarm defaults when SWARM_MODE is falsy', async () => { + const mockTemplateManager = { + substituteVariables: vi.fn((content: string, vars: Record) => { + return content.replace( + /\{\{#if SWARM_MODE\}\}sonnet\{\{else\}\}opus\{\{\/if\}\}/g, + vars.SWARM_MODE ? 'sonnet' : 'opus', + ) + }), + } + + const managerWithTemplate = new AgentManager('templates/agents', mockTemplateManager as never) + + vi.mocked(fg).mockResolvedValueOnce(['agent.md']) + vi.mocked(readFile).mockResolvedValueOnce(`--- +name: test-agent +description: Test agent +model: {{#if SWARM_MODE}}sonnet{{else}}opus{{/if}} +--- + +Prompt content`) + + const result = await managerWithTemplate.loadAgents(undefined, {}) + + expect(result['test-agent'].model).toBe('opus') + }) + + it('should resolve Handlebars expressions in frontmatter effort field', async () => { + const mockTemplateManager = { + substituteVariables: vi.fn((content: string, vars: Record) => { + return content.replace( + /\{\{#if SWARM_MODE\}\}medium\{\{\/if\}\}/g, + vars.SWARM_MODE ? 'medium' : '', + ) + }), + } + + const managerWithTemplate = new AgentManager('templates/agents', mockTemplateManager as never) + + vi.mocked(fg).mockResolvedValueOnce(['agent.md']) + vi.mocked(readFile).mockResolvedValueOnce(`--- +name: test-agent +description: Test agent +model: sonnet +effort: {{#if SWARM_MODE}}medium{{/if}} +--- + +Prompt content`) + + const result = await managerWithTemplate.loadAgents(undefined, { SWARM_MODE: true }) + + expect(result['test-agent'].effort).toBe('medium') + }) + + it('should handle empty effort when SWARM_MODE is falsy', async () => { + const mockTemplateManager = { + substituteVariables: vi.fn((content: string, vars: Record) => { + return content.replace( + /\{\{#if SWARM_MODE\}\}medium\{\{\/if\}\}/g, + vars.SWARM_MODE ? 'medium' : '', + ) + }), + } + + const managerWithTemplate = new AgentManager('templates/agents', mockTemplateManager as never) + + vi.mocked(fg).mockResolvedValueOnce(['agent.md']) + vi.mocked(readFile).mockResolvedValueOnce(`--- +name: test-agent +description: Test agent +model: sonnet +effort: {{#if SWARM_MODE}}medium{{/if}} +--- + +Prompt content`) + + const result = await managerWithTemplate.loadAgents(undefined, {}) + + // Empty effort string should not pass isEffortLevel check, so effort is undefined + expect(result['test-agent'].effort).toBeUndefined() + }) + + it('should not apply template substitution when templateVariables is not provided', async () => { + const mockTemplateManager = { + substituteVariables: vi.fn(), + } + + const managerWithTemplate = new AgentManager('templates/agents', mockTemplateManager as never) + + vi.mocked(fg).mockResolvedValueOnce(['agent.md']) + vi.mocked(readFile).mockResolvedValueOnce(`--- +name: test-agent +description: Test agent +model: sonnet +--- + +Prompt content`) + + await managerWithTemplate.loadAgents() + + expect(mockTemplateManager.substituteVariables).not.toHaveBeenCalled() }) }) diff --git a/src/lib/AgentManager.ts b/src/lib/AgentManager.ts index 7384227e..6ff4519c 100644 --- a/src/lib/AgentManager.ts +++ b/src/lib/AgentManager.ts @@ -87,13 +87,26 @@ export class AgentManager { caseSensitiveMatch: false, }) + // Enrich template variables with review config before substitution + // (must happen before the loop so all agents get the same enriched variables) + // Use a local copy to avoid mutating the caller's object + const enrichedVariables = templateVariables + ? { ...templateVariables, ...buildReviewTemplateVariables(!!templateVariables.SWARM_MODE, settings?.agents) } + : undefined + const agents: AgentConfigs = {} for (const filename of agentFiles) { const agentPath = path.join(this.agentDir, filename) try { - const content = await readFile(agentPath, 'utf-8') + let content = await readFile(agentPath, 'utf-8') + + // Apply template substitution to raw content BEFORE parsing frontmatter + // This allows frontmatter fields (model, effort) to use Handlebars expressions + if (enrichedVariables) { + content = this.templateManager.substituteVariables(content, enrichedVariables) + } // Parse markdown with frontmatter const parsed = this.parseMarkdownAgent(content, filename) @@ -110,20 +123,6 @@ export class AgentManager { } } - // Apply template variable substitution to agent prompts if variables provided - if (templateVariables) { - // Extract review config from settings and add to template variables - Object.assign(templateVariables, buildReviewTemplateVariables(!!templateVariables.SWARM_MODE, settings?.agents)) - - for (const [agentName, agentConfig] of Object.entries(agents)) { - agents[agentName] = { - ...agentConfig, - prompt: this.templateManager.substituteVariables(agentConfig.prompt, templateVariables), - } - logger.debug(`Applied template substitution to agent: ${agentName}`) - } - } - // Apply settings overrides if provided if (settings?.agents) { for (const [agentName, agentSettings] of Object.entries(settings.agents)) { diff --git a/src/lib/SwarmSetupService.test.ts b/src/lib/SwarmSetupService.test.ts index a0a4b170..ea60a23f 100644 --- a/src/lib/SwarmSetupService.test.ts +++ b/src/lib/SwarmSetupService.test.ts @@ -168,46 +168,48 @@ describe('SwarmSetupService', () => { expect(getAgentContent('iloom-swarm-issue-implementer')).toContain('model: haiku') }) - it('applies default swarmModel (sonnet) for agents in default map when no swarmModel configured', async () => { + it('uses model from loadAgents (frontmatter swarm conditional) when no swarmModel configured', async () => { vi.mocked(mockSettingsManager.loadSettings).mockResolvedValueOnce({ agents: { 'iloom-issue-implementer': { model: 'opus' }, }, } as unknown as IloomSettings) + // loadAgents now returns swarm-appropriate model from frontmatter conditional vi.mocked(mockAgentManager.loadAgents).mockResolvedValueOnce({ 'iloom-issue-implementer': { description: 'Implementer agent', prompt: 'Implement things', tools: ['Bash', 'Read'], - model: 'opus', + model: 'sonnet', // frontmatter resolves to sonnet in SWARM_MODE }, }) await service.renderSwarmAgents('/Users/dev/project-epic-610') - // iloom-issue-implementer is in the default swarmModel map, so it should be sonnet + // Model comes from loadAgents (frontmatter swarm conditional), not a hardcoded map expect(getAgentContent('iloom-swarm-issue-implementer')).toContain('model: sonnet') }) - it('applies default swarmModel (opus) for analyzer agent', async () => { + it('uses model from loadAgents (frontmatter) for analyzer agent', async () => { vi.mocked(mockSettingsManager.loadSettings).mockResolvedValueOnce({} as unknown as IloomSettings) + // Analyzer frontmatter declares model: opus (same in both modes) vi.mocked(mockAgentManager.loadAgents).mockResolvedValueOnce({ 'iloom-issue-analyzer': { description: 'Analyzer agent', prompt: 'Analyze things', - model: 'sonnet', + model: 'opus', }, }) await service.renderSwarmAgents('/Users/dev/project-epic-610') - // iloom-issue-analyzer is in the default swarmModel map as opus + // Model comes from loadAgents (frontmatter), which is opus for analyzer expect(getAgentContent('iloom-swarm-issue-analyzer')).toContain('model: opus') }) - it('non-swarm model override does not affect swarm mode when default map covers the agent', async () => { + it('user model setting applies in swarm mode when no swarmModel is set', async () => { vi.mocked(mockSettingsManager.loadSettings).mockResolvedValueOnce({ agents: { 'iloom-issue-implementer': { model: 'haiku' }, @@ -216,6 +218,8 @@ describe('SwarmSetupService', () => { }, } as unknown as IloomSettings) + // loadAgents now returns agents with user-overridden model (haiku) + // since settings.model overrides frontmatter in loadAgents vi.mocked(mockAgentManager.loadAgents).mockResolvedValueOnce({ 'iloom-issue-implementer': { description: 'Implementer agent', @@ -236,11 +240,11 @@ describe('SwarmSetupService', () => { await service.renderSwarmAgents('/Users/dev/project-epic-610') - // Even though non-swarm model is set to haiku, swarm defaults override - // Check agent files (which carry the full prompt and model) - expect(getAgentContent('iloom-swarm-issue-implementer')).toContain('model: sonnet') - expect(getAgentContent('iloom-swarm-issue-analyzer')).toContain('model: opus') - expect(getAgentContent('iloom-swarm-issue-planner')).toContain('model: sonnet') + // Without swarmModel set, user's model setting now applies in swarm mode + // (previously hardcoded defaults would override) + expect(getAgentContent('iloom-swarm-issue-implementer')).toContain('model: haiku') + expect(getAgentContent('iloom-swarm-issue-analyzer')).toContain('model: haiku') + expect(getAgentContent('iloom-swarm-issue-planner')).toContain('model: haiku') }) it('explicit swarmModel overrides both non-swarm model and default map', async () => { @@ -333,35 +337,37 @@ describe('SwarmSetupService', () => { return call ? (call[1] as string) : undefined } - it('applies default swarmEffort for agents in default effort map', async () => { + it('uses effort from loadAgents (frontmatter swarm conditionals) when no user override', async () => { vi.mocked(mockSettingsManager.loadSettings).mockResolvedValueOnce({} as unknown as IloomSettings) + // loadAgents now returns agents with effort from frontmatter conditionals vi.mocked(mockAgentManager.loadAgents).mockResolvedValueOnce({ 'iloom-issue-analyzer': { description: 'Analyzer agent', prompt: 'Analyze things', - model: 'sonnet', + model: 'opus', + effort: 'high', // from frontmatter: {{#if SWARM_MODE}}high{{/if}} }, 'iloom-issue-implementer': { description: 'Implementer agent', prompt: 'Implement things', model: 'sonnet', + effort: 'medium', // from frontmatter: {{#if SWARM_MODE}}medium{{/if}} }, 'iloom-issue-complexity-evaluator': { description: 'Evaluator agent', prompt: 'Evaluate things', - model: 'sonnet', + model: 'haiku', + effort: 'high', // from frontmatter: {{#if SWARM_MODE}}high{{/if}} }, }) await service.renderSwarmAgents('/Users/dev/project-epic-610') - // Analyzer default effort is 'high' + // Effort values come from loadAgents (frontmatter conditionals) expect(getAgentContent('iloom-swarm-issue-analyzer')).toContain('effort: high') - // Implementer default effort is 'medium' expect(getAgentContent('iloom-swarm-issue-implementer')).toContain('effort: medium') - // Complexity evaluator default effort is 'low' - expect(getAgentContent('iloom-swarm-issue-complexity-evaluator')).toContain('effort: low') + expect(getAgentContent('iloom-swarm-issue-complexity-evaluator')).toContain('effort: high') }) it('uses per-agent swarmEffort when configured', async () => { @@ -384,7 +390,7 @@ describe('SwarmSetupService', () => { expect(getAgentContent('iloom-swarm-issue-implementer')).toContain('effort: max') }) - it('explicit swarmEffort overrides default effort map', async () => { + it('explicit swarmEffort overrides frontmatter effort', async () => { vi.mocked(mockSettingsManager.loadSettings).mockResolvedValueOnce({ agents: { 'iloom-issue-analyzer': { swarmEffort: 'low' }, @@ -395,24 +401,27 @@ describe('SwarmSetupService', () => { 'iloom-issue-analyzer': { description: 'Analyzer agent', prompt: 'Analyze things', - model: 'sonnet', + model: 'opus', + effort: 'high', // from frontmatter swarm conditional }, }) await service.renderSwarmAgents('/Users/dev/project-epic-610') - // Default for analyzer is 'high' but explicit swarmEffort 'low' should win + // Frontmatter resolves to 'high' but explicit swarmEffort 'low' should win expect(getAgentContent('iloom-swarm-issue-analyzer')).toContain('effort: low') }) it('includes effort in skill wrapper frontmatter', async () => { vi.mocked(mockSettingsManager.loadSettings).mockResolvedValueOnce({} as unknown as IloomSettings) + // loadAgents now returns agents with effort from frontmatter conditionals vi.mocked(mockAgentManager.loadAgents).mockResolvedValueOnce({ 'iloom-issue-implementer': { description: 'Implementer agent', prompt: 'Implement things', model: 'sonnet', + effort: 'medium', // from frontmatter: {{#if SWARM_MODE}}medium{{/if}} }, }) @@ -422,7 +431,7 @@ describe('SwarmSetupService', () => { (call) => (call[0] as string).endsWith('SKILL.md'), ) const skillContent = skillFileCall![1] as string - // Implementer default effort is 'medium' + // Effort comes from frontmatter swarm conditional expect(skillContent).toContain('effort: medium') }) }) diff --git a/src/lib/SwarmSetupService.ts b/src/lib/SwarmSetupService.ts index 906e2400..ccbf6944 100644 --- a/src/lib/SwarmSetupService.ts +++ b/src/lib/SwarmSetupService.ts @@ -1,8 +1,7 @@ import path from 'path' import fs from 'fs-extra' import { AgentManager } from './AgentManager.js' -import { SettingsManager, type ClaudeModel } from './SettingsManager.js' -import type { EffortLevel } from '../types/index.js' +import { SettingsManager } from './SettingsManager.js' import { PromptTemplateManager, buildReviewTemplateVariables, type TemplateVariables } from './PromptTemplateManager.js' import { IssueManagementProviderFactory } from '../mcp/IssueManagementProviderFactory.js' import { getLogger } from '../utils/logger-context.js' @@ -62,41 +61,14 @@ export class SwarmSetupService { const agents = await this.agentManager.loadAgents(settings, templateVariables) - // Default swarmModel map for "Balanced" mode. All swarm phase agents are - // listed explicitly so that swarm mode never accidentally inherits a - // non-swarm model override. User-configured swarmModel values always - // take precedence. - const defaultSwarmModels: Record = { - 'iloom-issue-analyzer': 'opus', - 'iloom-issue-analyze-and-plan': 'opus', - 'iloom-issue-planner': 'sonnet', - 'iloom-issue-implementer': 'sonnet', - 'iloom-issue-enhancer': 'sonnet', - 'iloom-code-reviewer': 'sonnet', - 'iloom-issue-complexity-evaluator': 'haiku', - } - - // Default swarmEffort map for swarm phase agents. Agents that do - // deep analysis/planning get higher effort, while implementation - // and review agents use medium, and simple evaluators use low. - const defaultSwarmEfforts: Record = { - 'iloom-issue-analyzer': 'high', - 'iloom-issue-analyze-and-plan': 'high', - 'iloom-issue-planner': 'high', - 'iloom-issue-implementer': 'medium', - 'iloom-issue-enhancer': 'medium', - 'iloom-code-reviewer': 'medium', - 'iloom-issue-complexity-evaluator': 'low', - } - - // Apply per-agent swarmModel and swarmEffort overrides (user-configured takes precedence over defaults) + // Apply per-agent swarmModel and swarmEffort overrides from user settings. + // Default swarm model/effort values are now declared in agent template frontmatter + // using {{#if SWARM_MODE}} conditionals, so only user overrides are needed here. for (const [agentName, agentConfig] of Object.entries(agents)) { let updated = agentConfig const userSwarmModel = settings?.agents?.[agentName]?.swarmModel if (userSwarmModel) { updated = { ...updated, model: userSwarmModel } - } else if (defaultSwarmModels[agentName]) { - updated = { ...updated, model: defaultSwarmModels[agentName] } } const userSwarmEffort = settings?.agents?.[agentName]?.swarmEffort @@ -105,8 +77,6 @@ export class SwarmSetupService { updated = { ...updated, effort: userSwarmEffort } } else if (userBaseEffort) { updated = { ...updated, effort: userBaseEffort } - } else if (!updated.effort && defaultSwarmEfforts[agentName]) { - updated = { ...updated, effort: defaultSwarmEfforts[agentName] } } agents[agentName] = updated } diff --git a/templates/agents/CLAUDE.md b/templates/agents/CLAUDE.md index e542e416..27e09106 100644 --- a/templates/agents/CLAUDE.md +++ b/templates/agents/CLAUDE.md @@ -34,11 +34,14 @@ complexity-evaluator → analyzer (or analyze-and-plan for SIMPLE) → planner ## YAML Frontmatter Format +Frontmatter fields support Handlebars expressions because template substitution runs on the raw file content **before** YAML parsing. This allows conditional defaults based on execution mode (e.g., swarm vs regular). + ```yaml --- name: iloom-issue-implementer description: One-line description of this agent's role -model: opus # Default model (can be overridden by settings) +model: {{#if SWARM_MODE}}sonnet{{else}}opus{{/if}} +{{#if SWARM_MODE}}effort: medium{{/if}} color: green # Optional: terminal color for status display tools: # Optional: restrict available tools - Read @@ -49,15 +52,17 @@ tools: # Optional: restrict available tools Agent prompt content in Markdown... ``` -## Model Override Rules +When `SWARM_MODE` is falsy, conditional lines (like `{{#if SWARM_MODE}}effort: medium{{/if}}`) resolve to an empty line, which is ignored by the parser — the agent inherits the session default. + +## Model and Effort Override Rules -Agent models resolve in this order (highest priority first): +Agent models and effort resolve in this order (highest priority first): 1. **CLI flag**: `--set agents.iloom-issue-implementer.model=sonnet` -2. **Settings**: `settings.agents["iloom-issue-implementer"].model` -3. **Swarm model defaults**: `SwarmSetupService` applies swarm-specific defaults (e.g., haiku for complexity evaluator) -4. **Frontmatter**: The `model` field in the YAML above +2. **Settings**: `settings.agents["iloom-issue-implementer"].model` / `.effort` +3. **Swarm settings**: `settings.agents["iloom-issue-implementer"].swarmModel` / `.swarmEffort` (applied by `SwarmSetupService` in swarm mode) +4. **Frontmatter**: The `model` / `effort` field in YAML above (which may use `{{#if SWARM_MODE}}` conditionals for mode-specific defaults) -Do NOT hardcode model choices in agent templates to work around performance issues — use the settings system instead. +Swarm-specific model and effort defaults are declared directly in agent template frontmatter using `{{#if SWARM_MODE}}` conditionals, not in hardcoded maps. ## Swarm Agent Rendering diff --git a/templates/agents/iloom-code-reviewer.md b/templates/agents/iloom-code-reviewer.md index 099cd93f..c8b390c1 100644 --- a/templates/agents/iloom-code-reviewer.md +++ b/templates/agents/iloom-code-reviewer.md @@ -1,7 +1,8 @@ --- name: iloom-code-reviewer description: Use this agent to review code changes. -model: opus +model: {{#if SWARM_MODE}}sonnet{{else}}opus{{/if}} +{{#if SWARM_MODE}}effort: medium{{/if}} color: cyan --- diff --git a/templates/agents/iloom-framework-detector.md b/templates/agents/iloom-framework-detector.md index ced5c1b3..72d454b1 100644 --- a/templates/agents/iloom-framework-detector.md +++ b/templates/agents/iloom-framework-detector.md @@ -3,6 +3,7 @@ name: iloom-framework-detector description: Use this agent to detect a project's language and framework, then generate appropriate build/test/dev scripts for non-Node.js projects. The agent creates `.iloom/package.iloom.json` with shell commands tailored to the detected stack. Use this for Python, Rust, Ruby, Go, and other non-Node.js projects that don't have a package.json. color: cyan model: opus +effort: high --- You are Claude, a framework detection specialist. Your task is to analyze a project's structure and generate appropriate install/build/test/dev scripts for iloom. diff --git a/templates/agents/iloom-issue-analyze-and-plan.md b/templates/agents/iloom-issue-analyze-and-plan.md index 802a3eaa..6e9eef5d 100644 --- a/templates/agents/iloom-issue-analyze-and-plan.md +++ b/templates/agents/iloom-issue-analyze-and-plan.md @@ -3,6 +3,7 @@ name: iloom-issue-analyze-and-plan description: Combined analysis and planning agent for SIMPLE tasks. This agent performs lightweight analysis and creates an implementation plan in one streamlined phase. Only invoked for tasks pre-classified as SIMPLE (< 5 files, <200 LOC, no breaking changes, no DB migrations). Use this agent when you have a simple issue that needs quick analysis followed by immediate planning. color: teal model: opus +{{#if SWARM_MODE}}effort: high{{/if}} --- {{#if SWARM_MODE}} diff --git a/templates/agents/iloom-issue-analyzer.md b/templates/agents/iloom-issue-analyzer.md index 70973d5c..3cd1f736 100644 --- a/templates/agents/iloom-issue-analyzer.md +++ b/templates/agents/iloom-issue-analyzer.md @@ -3,6 +3,7 @@ name: iloom-issue-analyzer description: Use this agent when you need to analyze and research issues, bugs, or enhancement requests. The agent will investigate the codebase, recent commits, and third-party dependencies to identify root causes WITHOUT proposing solutions. Ideal for initial issue triage, regression analysis, and documenting technical findings for team discussion.\n\nExamples:\n\nContext: User wants to analyze a newly reported bug in issue #42\nuser: "Please analyze issue #42 - users are reporting that the login button doesn't work on mobile"\nassistant: "I'll use the issue-analyzer agent to investigate this issue and document my findings."\n\nSince this is a request to analyze an issue, use the Task tool to launch the issue-analyzer agent to research the problem.\n\n\n\nContext: User needs to understand a regression that appeared after recent changes\nuser: "Can you look into issue #78? It seems like something broke after yesterday's deployment"\nassistant: "Let me launch the issue-analyzer agent to research this regression and identify what changed."\n\nThe user is asking for issue analysis and potential regression investigation, so use the issue-analyzer agent.\n\n color: pink model: opus +{{#if SWARM_MODE}}effort: high{{/if}} --- {{#if SWARM_MODE}} diff --git a/templates/agents/iloom-issue-complexity-evaluator.md b/templates/agents/iloom-issue-complexity-evaluator.md index 269aac7a..64c7589b 100644 --- a/templates/agents/iloom-issue-complexity-evaluator.md +++ b/templates/agents/iloom-issue-complexity-evaluator.md @@ -3,6 +3,7 @@ name: iloom-issue-complexity-evaluator description: Use this agent when you need to quickly assess the complexity of an issue before deciding on the appropriate workflow. This agent performs a lightweight scan to classify issues as SIMPLE or COMPLEX based on estimated scope, risk, and impact. Runs first before any detailed analysis or planning. color: orange model: haiku +{{#if SWARM_MODE}}effort: high{{/if}} --- {{#if SWARM_MODE}} diff --git a/templates/agents/iloom-issue-enhancer.md b/templates/agents/iloom-issue-enhancer.md index 549c95ac..088a21cb 100644 --- a/templates/agents/iloom-issue-enhancer.md +++ b/templates/agents/iloom-issue-enhancer.md @@ -2,7 +2,8 @@ name: iloom-issue-enhancer description: Use this agent when you need to analyze bug or enhancement reports from a Product Manager perspective. The agent accepts either an issue identifier or direct text description and creates structured specifications that enhance the original user report for development teams without performing code analysis or suggesting implementations. Ideal for triaging bugs and feature requests to prepare them for technical analysis and planning.\n\nExamples:\n\nContext: User wants to triage and enhance a bug report from issue tracker\nuser: "Please analyze issue #42 - the login button doesn't work on mobile"\nassistant: "I'll use the iloom-issue-enhancer agent to analyze this bug report and create a structured specification."\n\nSince this is a request to triage and structure a bug report from a user experience perspective, use the iloom-issue-enhancer agent.\n\n\n\nContext: User needs to enhance an enhancement request that lacks detail\nuser: "Can you improve the description on issue #78? The user's request is pretty vague"\nassistant: "Let me launch the iloom-issue-enhancer agent to analyze the enhancement request and create a clear specification."\n\nThe user is asking for enhancement report structuring, so use the iloom-issue-enhancer agent.\n\n\n\nContext: User provides direct description without issue identifier\nuser: "Analyze this bug: Users report that the search function returns no results when they include special characters like & or # in their query"\nassistant: "I'll use the iloom-issue-enhancer agent to create a structured specification for this bug report."\n\nEven though no issue identifier was provided, the iloom-issue-enhancer agent can analyze the direct description and create a structured specification.\n\n\n\nContext: An issue has been labeled as a valid baug and needs structured analysis\nuser: "Structure issue #123 that was just labeled as a triaged bug"\nassistant: "I'll use the iloom-issue-enhancer agent to create a comprehensive bug specification."\n\nThe issue needs Product Manager-style analysis and structuring, so use the iloom-issue-enhancer agent.\n\n color: purple -model: opus +model: {{#if SWARM_MODE}}sonnet{{else}}opus{{/if}} +{{#if SWARM_MODE}}effort: medium{{/if}} --- {{#if SWARM_MODE}} diff --git a/templates/agents/iloom-issue-implementer.md b/templates/agents/iloom-issue-implementer.md index 239cfd08..d85b44f7 100644 --- a/templates/agents/iloom-issue-implementer.md +++ b/templates/agents/iloom-issue-implementer.md @@ -1,7 +1,8 @@ --- name: iloom-issue-implementer description: Use this agent when you need to implement an issue exactly as specified in its comments and description. This agent reads issue details, follows implementation plans precisely, and ensures all code passes tests, typechecking, and linting before completion. Examples:\n\n\nContext: User wants to implement a specific issue.\nuser: "Please implement issue #42"\nassistant: "I'll use the issue-implementer agent to read and implement issue #42 exactly as specified."\n\nSince the user is asking to implement an issue, use the Task tool to launch the issue-implementer agent.\n\n\n\n\nContext: User references an issue that needs implementation.\nuser: "Can you work on the authentication issue we discussed in #15?"\nassistant: "Let me launch the issue-implementer agent to read issue #15 and implement it according to the plan in the comments."\n\nThe user is referencing a specific issue number, so use the issue-implementer agent to handle the implementation.\n\n -model: opus +model: {{#if SWARM_MODE}}sonnet{{else}}opus{{/if}} +{{#if SWARM_MODE}}effort: medium{{/if}} color: green --- diff --git a/templates/agents/iloom-issue-planner.md b/templates/agents/iloom-issue-planner.md index a7271f57..559650bd 100644 --- a/templates/agents/iloom-issue-planner.md +++ b/templates/agents/iloom-issue-planner.md @@ -2,7 +2,8 @@ name: iloom-issue-planner description: Use this agent when you need to analyze issues and create detailed implementation plans. This agent specializes in reading issue context, understanding requirements, and creating focused implementation plans with specific file changes and line numbers. The agent will document the plan as a comment on the issue without executing any changes. Examples: Context: The user wants detailed implementation planning for an issue.\nuser: "Analyze issue #42 and create an implementation plan"\nassistant: "I'll use the issue-planner agent to analyze the issue and create a detailed implementation plan"\nSince the user wants issue analysis and implementation planning, use the issue-planner agent. Context: The user needs a plan for implementing a feature described in an issue.\nuser: "Read issue #15 and plan out what needs to be changed"\nassistant: "Let me use the issue-planner agent to analyze the issue and document a comprehensive implementation plan"\nThe user needs issue analysis and planning, so the issue-planner agent is the right choice. color: blue -model: opus +model: {{#if SWARM_MODE}}sonnet{{else}}opus{{/if}} +{{#if SWARM_MODE}}effort: high{{/if}} --- {{#if SWARM_MODE}} diff --git a/templates/agents/iloom-wave-verifier.md b/templates/agents/iloom-wave-verifier.md index 117110b7..f7ab3cb4 100644 --- a/templates/agents/iloom-wave-verifier.md +++ b/templates/agents/iloom-wave-verifier.md @@ -2,6 +2,7 @@ name: iloom-wave-verifier description: Wave verification agent that checks must-have criteria from child issues after each swarm wave, invokes fix skills for failures, and reports structured results.\n\nExamples:\n\nContext: Orchestrator wants to verify that wave 1 work meets acceptance criteria\nuser: "Verify must-haves for issues #101, #102, #103 from wave 1"\nassistant: "I'll check all must-have criteria from those issues against the codebase and report results."\n\nThe orchestrator needs wave verification after a completed wave, so use the iloom-wave-verifier agent.\n\n\n\nContext: Swarm orchestrator needs to gate the next wave on verification passing\nuser: "Run wave verification for child issues #45, #46 before proceeding to wave 2"\nassistant: "I'll verify all must-haves for the specified issues, fix any failures, and return a structured report."\n\nWave gating requires verification of completed work, so use the iloom-wave-verifier agent.\n\n model: opus +effort: high color: red ---