Skip to content
Draft
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
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,34 @@ il plan --yolo "Add GitLab integration"

See the [Complete Command Reference](docs/iloom-commands.md#il-plan) for all options including `--model`, `--planner`, and `--reviewer` flags.

### Swarm Mode (Autonomous Epic Execution)

For issues labeled `iloom-epic`, `il start` can enter swarm mode -- launching multiple Claude agents in parallel to work through all child issues automatically.

```bash
# Start swarm on an epic (interactive confirmation)
il start 50

# Skip confirmation with --swarm flag
il start 50 --swarm

# Limit concurrent agents
il start 50 --swarm --max-agents 5
```

Swarm mode uses the [Beads](https://github.com/steveyegge/beads) DAG engine for dependency-aware task ordering and atomic claiming. Each child issue gets its own minimal worktree. PRs are merged sequentially into the epic's integration branch to prevent conflicts.

Configure via `.iloom.yml`:

```yaml
swarm:
maxConcurrent: 3 # Default: 3
maxRetries: 1 # Default: 1
autoInstallBeads: false
```

See the [Complete Command Reference](docs/iloom-commands.md#il-start) for details.

System Requirements & Limitations
---------------------------------

Expand Down
32 changes: 32 additions & 0 deletions docs/iloom-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ il start "<issue-description>"
| `--dev-server` / `--no-dev-server` | - | Enable/disable dev server in terminal (default: enabled) |
| `--terminal` / `--no-terminal` | - | Enable/disable terminal without dev server (default: disabled) |
| `--body` | `<text>` | Body text for issue (skips AI enhancement) |
| `--swarm` | - | Bypass epic confirmation and start swarm mode immediately |
| `--max-agents` | `<n>` | Maximum concurrent agents for swarm mode (overrides `swarm.maxConcurrent` setting) |

**One-Shot Modes:**
- `default` - Standard behavior with approval prompts at each phase
Expand Down Expand Up @@ -113,6 +115,36 @@ il start 42 --child-loom

# Create independent loom even when inside another loom
il start 99 --no-child-loom

# Start swarm mode on an epic (skip confirmation prompt)
il start 50 --swarm

# Start swarm with custom concurrency
il start 50 --swarm --max-agents 5
```

**Swarm Mode:**

When `il start` is run on an issue with the `iloom-epic` label, it detects the issue as an epic and offers to enter swarm mode. In swarm mode, iloom:

1. Creates an integration branch (epic loom) with no interactive components
2. Initializes the Beads DAG engine to track child issue dependencies
3. Syncs all child issues and their dependency graph into the DAG
4. Launches a supervisor that concurrently spawns Claude agents for ready tasks
5. Merges completed PRs sequentially into the epic branch
6. Reports aggregate results on completion

The `--swarm` flag skips the confirmation prompt for automated workflows. The `--max-agents` flag overrides the `swarm.maxConcurrent` setting. In non-interactive environments, `--swarm` is required to enter swarm mode. In JSON mode (`--json`), the command returns the epic loom metadata without running the supervisor.

Swarm behavior is configured via the `swarm` section in `.iloom.yml`:

```yaml
swarm:
maxConcurrent: 3 # Max concurrent agents (default: 3)
maxRetries: 1 # Retries per failed task (default: 1)
maxConflictRetries: 3 # Merge conflict resolution retries (default: 3)
beadsDir: ~/.config/iloom-ai/beads # Beads state directory
autoInstallBeads: false # Auto-install Beads CLI without prompting
```

**Notes:**
Expand Down
13 changes: 12 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,18 @@ program
.default('default')
)
.option('--yolo', 'Enable autonomous mode (shorthand for --one-shot=bypassPermissions)')
.action(async (identifier: string | undefined, options: StartOptions & { yolo?: boolean }) => {
.option('--swarm', 'Bypass epic confirmation and start swarm mode immediately')
.option('--max-agents <n>', 'Maximum concurrent agents for swarm mode (overrides swarm.maxConcurrent setting)', (value: string) => {
const parsed = parseInt(value, 10)
if (isNaN(parsed)) {
throw new Error('--max-agents must be a number')
}
if (parsed < 1 || parsed > 20) {
throw new Error('--max-agents must be between 1 and 20')
}
return parsed
})
.action(async (identifier: string | undefined, options: StartOptions & { yolo?: boolean; maxAgents?: number }) => {
// Handle --yolo flag: set oneShot to bypassPermissions
if (options.yolo) {
options.oneShot = 'bypassPermissions'
Expand Down
120 changes: 120 additions & 0 deletions src/commands/ignite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2836,6 +2836,126 @@ describe('IgniteCommand', () => {
})
})

describe('SWARM_MODE template variables', () => {
it('should set SWARM_MODE, EPIC_BRANCH, EPIC_ISSUE_NUMBER, and GIT_REMOTE when env vars are set', async () => {
const launchClaudeSpy = vi.spyOn(claudeUtils, 'launchClaude').mockResolvedValue(undefined)

const originalCwd = process.cwd
process.cwd = vi.fn().mockReturnValue('/path/to/feat/issue-100__swarm-test')

// Set swarm env vars
const originalSwarmMode = process.env.ILOOM_SWARM_MODE
const originalEpicBranch = process.env.ILOOM_EPIC_BRANCH
const originalEpicIssue = process.env.ILOOM_EPIC_ISSUE
process.env.ILOOM_SWARM_MODE = '1'
process.env.ILOOM_EPIC_BRANCH = 'issue-42-swarm-mode'
process.env.ILOOM_EPIC_ISSUE = '42'

try {
await command.execute()

expect(mockTemplateManager.getPrompt).toHaveBeenCalledWith(
'issue',
expect.objectContaining({
SWARM_MODE: true,
EPIC_BRANCH: 'issue-42-swarm-mode',
EPIC_ISSUE_NUMBER: '42',
GIT_REMOTE: 'origin',
})
)
} finally {
process.cwd = originalCwd
launchClaudeSpy.mockRestore()
// Restore env vars
if (originalSwarmMode === undefined) delete process.env.ILOOM_SWARM_MODE
else process.env.ILOOM_SWARM_MODE = originalSwarmMode
if (originalEpicBranch === undefined) delete process.env.ILOOM_EPIC_BRANCH
else process.env.ILOOM_EPIC_BRANCH = originalEpicBranch
if (originalEpicIssue === undefined) delete process.env.ILOOM_EPIC_ISSUE
else process.env.ILOOM_EPIC_ISSUE = originalEpicIssue
}
})

it('should not set SWARM_MODE when ILOOM_SWARM_MODE env var is not set', async () => {
const launchClaudeSpy = vi.spyOn(claudeUtils, 'launchClaude').mockResolvedValue(undefined)

const originalCwd = process.cwd
process.cwd = vi.fn().mockReturnValue('/path/to/feat/issue-101__no-swarm')

// Ensure swarm env vars are not set
const originalSwarmMode = process.env.ILOOM_SWARM_MODE
delete process.env.ILOOM_SWARM_MODE

try {
await command.execute()

const templateCall = vi.mocked(mockTemplateManager.getPrompt).mock.calls[0]
expect(templateCall[1].SWARM_MODE).toBeUndefined()
expect(templateCall[1].EPIC_BRANCH).toBeUndefined()
expect(templateCall[1].EPIC_ISSUE_NUMBER).toBeUndefined()
} finally {
process.cwd = originalCwd
launchClaudeSpy.mockRestore()
if (originalSwarmMode === undefined) delete process.env.ILOOM_SWARM_MODE
else process.env.ILOOM_SWARM_MODE = originalSwarmMode
}
})

it('should not set SWARM_MODE when ILOOM_SWARM_MODE is not "1"', async () => {
const launchClaudeSpy = vi.spyOn(claudeUtils, 'launchClaude').mockResolvedValue(undefined)

const originalCwd = process.cwd
process.cwd = vi.fn().mockReturnValue('/path/to/feat/issue-102__swarm-false')

const originalSwarmMode = process.env.ILOOM_SWARM_MODE
process.env.ILOOM_SWARM_MODE = 'false'

try {
await command.execute()

const templateCall = vi.mocked(mockTemplateManager.getPrompt).mock.calls[0]
expect(templateCall[1].SWARM_MODE).toBeUndefined()
} finally {
process.cwd = originalCwd
launchClaudeSpy.mockRestore()
if (originalSwarmMode === undefined) delete process.env.ILOOM_SWARM_MODE
else process.env.ILOOM_SWARM_MODE = originalSwarmMode
}
})

it('should throw error when ILOOM_SWARM_MODE is set but ILOOM_EPIC_BRANCH is missing', async () => {
const launchClaudeSpy = vi.spyOn(claudeUtils, 'launchClaude').mockResolvedValue(undefined)

const originalCwd = process.cwd
process.cwd = vi.fn().mockReturnValue('/path/to/feat/issue-103__swarm-minimal')

const originalSwarmMode = process.env.ILOOM_SWARM_MODE
const originalEpicBranch = process.env.ILOOM_EPIC_BRANCH
const originalEpicIssue = process.env.ILOOM_EPIC_ISSUE
process.env.ILOOM_SWARM_MODE = '1'
delete process.env.ILOOM_EPIC_BRANCH
delete process.env.ILOOM_EPIC_ISSUE

try {
await expect(command.execute()).rejects.toThrow(
'ILOOM_EPIC_BRANCH is required when ILOOM_SWARM_MODE is enabled'
)

// Verify launchClaude was NOT called
expect(launchClaudeSpy).not.toHaveBeenCalled()
} finally {
process.cwd = originalCwd
launchClaudeSpy.mockRestore()
if (originalSwarmMode === undefined) delete process.env.ILOOM_SWARM_MODE
else process.env.ILOOM_SWARM_MODE = originalSwarmMode
if (originalEpicBranch === undefined) delete process.env.ILOOM_EPIC_BRANCH
else process.env.ILOOM_EPIC_BRANCH = originalEpicBranch
if (originalEpicIssue === undefined) delete process.env.ILOOM_EPIC_ISSUE
else process.env.ILOOM_EPIC_ISSUE = originalEpicIssue
}
})
})

describe('Main worktree validation', () => {
it('should throw WorktreeValidationError when running from main worktree', async () => {
const launchClaudeSpy = vi.spyOn(claudeUtils, 'launchClaude').mockResolvedValue(undefined)
Expand Down
21 changes: 21 additions & 0 deletions src/commands/ignite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,27 @@ export class IgniteCommand {
variables.STANDARD_ISSUE_MODE = true
}

// Set swarm mode variables from environment
if (process.env.ILOOM_SWARM_MODE === '1') {
variables.SWARM_MODE = true
if (process.env.ILOOM_EPIC_BRANCH) {
variables.EPIC_BRANCH = process.env.ILOOM_EPIC_BRANCH
} else {
throw new Error('ILOOM_EPIC_BRANCH is required when ILOOM_SWARM_MODE is enabled')
}
if (process.env.ILOOM_EPIC_ISSUE) {
variables.EPIC_ISSUE_NUMBER = process.env.ILOOM_EPIC_ISSUE
}
// Ensure GIT_REMOTE is set for swarm mode (needed for git push)
if (!variables.GIT_REMOTE) {
const remote = this.settings?.mergeBehavior?.remote ?? 'origin'
if (!/^[a-zA-Z0-9_-]+$/.test(remote)) {
throw new Error(`Invalid git remote name: "${remote}". Remote names can only contain alphanumeric characters, underscores, and hyphens.`)
}
variables.GIT_REMOTE = remote
}
}

return variables
}

Expand Down
Loading