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
74 changes: 74 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,21 @@ storm generate --dry-run
# Limit the number of issues created
storm generate --max-issues 5

# Multi-agent war room for complex tasks
storm war-room --issue 42

# War room with a free-form prompt
storm war-room --prompt "Refactor the auth module"

# War room with terminal UI enabled
storm war-room --issue 42 --ui

# Filter which agents participate
storm war-room --issue 42 --agents storm,johnny

# Preview war room setup without spawning agents
storm war-room --issue 42 --dry-run

# Address review feedback on an existing storm PR
storm continue 42

Expand Down Expand Up @@ -177,6 +192,65 @@ The continue template supports all standard placeholders plus PR-specific ones:
| `{{ pr.diff }}` | Diff stat summary |
| `{{ pr.reviews }}` | Formatted review comments with file paths and diff hunks |

## War room

The `storm war-room` command launches a multi-agent session where several specialized agents collaborate on a complex task in a round-robin loop.

```bash
# Work on a GitHub issue with all default agents
storm war-room --issue 42

# Use a free-form prompt instead
storm war-room --prompt "Add dark mode support"

# Enable the real-time terminal UI
storm war-room --issue 42 --ui

# Only use specific agents
storm war-room --issue 42 --agents storm,johnny
```

**How it works:**

1. Loads agents — 3 defaults (Storm/Architect, Johnny/Engineer, Alan/QA) or custom agents from `.storm/agents/`
2. Creates a branch and enters a round-robin loop (up to 30 turns):
- Each agent receives the task, its personality prompt, remaining kibble budget, and a log of all previous events
- Agents can transfer kibble to other agents via `%%TRANSFER_KIBBLE:{amount}:{name}%%`
- Tool uses that read or modify files cost 1 kibble; agents with 0 kibble are skipped
- When an agent outputs `%%STORM_DONE%%`, the session ends
3. Commits, pushes, and opens a PR

**Terminal UI (`--ui`):**

When enabled (or auto-detected on a TTY), a split-panel ANSI interface renders in the alternate screen buffer:

- **Left panel** — agent list with kibble bars and tool counts
- **Right panel** — scrolling event log
- **Status bar** — current turn, active agent, last tool used, elapsed time

Falls back to plain log output on small terminals (< 60 cols or < 10 rows) or when piped.

**Custom agents:**

Create `.storm/agents/{id}/AGENT.md` with frontmatter:

```markdown
---
name: Designer
role: UI/UX
kibble: 15
model: opus
---
You are a UI/UX specialist. Focus on component structure, accessibility, and visual consistency.
```

| Field | Default | Description |
|---|---|---|
| `name` | directory name | Display name |
| `role` | `"Agent"` | Role label |
| `kibble` | `20` | Tool budget |
| `model` | config default | Override Claude model |

## Global mode

The `storm global` command lets you manage and run storm across multiple projects from a single place. Project paths are stored in `~/.storm/global.json`.
Expand Down
19 changes: 19 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
globalRunCommand,
globalStatusCommand,
} from "./src/commands/global.js";
import { warRoomCommand } from "./src/commands/war-room.js";

const program = new Command();

Expand Down Expand Up @@ -82,6 +83,24 @@ program
await updateCommand();
});

program
.command("war-room")
.description("Multi-agent war room for complex tasks")
.option("-i, --issue <number>", "GitHub issue number", parseInt)
.option("-p, --prompt <text>", "Free-form task description")
.option("--agents <list>", "Comma-separated agent IDs")
.option("--dry-run", "Preview without spawning agents")
.option("--ui", "Enable terminal UI (default: auto-detect TTY)")
.action(async (options) => {
await warRoomCommand(process.cwd(), {
issue: options.issue,
prompt: options.prompt,
agents: options.agents?.split(",").map((s: string) => s.trim()),
dryRun: options.dryRun,
ui: options.ui,
});
});

const globalCmd = program
.command("global")
.description("Manage and run storm across multiple projects");
Expand Down
101 changes: 101 additions & 0 deletions src/__tests__/war-room-agent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { join } from "path";
import { mkdirSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { loadWarRoomAgents, DEFAULT_AGENTS } from "../primitives/war-room-agent.js";

describe("loadWarRoomAgents", () => {
let tempDir: string;

beforeEach(() => {
tempDir = join(tmpdir(), `war-room-agent-test-${Date.now()}`);
mkdirSync(join(tempDir, ".storm"), { recursive: true });
});

afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});

it("returns default agents when no .storm/agents/ dir", async () => {
const agents = await loadWarRoomAgents(tempDir);
expect(agents).toHaveLength(3);
expect(agents[0].id).toBe("storm");
expect(agents[1].id).toBe("johnny");
expect(agents[2].id).toBe("alan");
});

it("filters agents by IDs", async () => {
const agents = await loadWarRoomAgents(tempDir, ["storm", "alan"]);
expect(agents).toHaveLength(2);
expect(agents.map((a) => a.id)).toEqual(["storm", "alan"]);
});

it("filter is case-insensitive", async () => {
const agents = await loadWarRoomAgents(tempDir, ["STORM"]);
expect(agents).toHaveLength(1);
expect(agents[0].id).toBe("storm");
});

it("returns empty array for non-matching filter", async () => {
const agents = await loadWarRoomAgents(tempDir, ["nonexistent"]);
expect(agents).toHaveLength(0);
});

it("loads custom agents from .storm/agents/", async () => {
const agentDir = join(tempDir, ".storm", "agents", "custom");
mkdirSync(agentDir, { recursive: true });
writeFileSync(
join(agentDir, "AGENT.md"),
`---
name: Custom Agent
role: Specialist
kibble: 10
model: opus
---
You are a custom specialist agent.`
);

const agents = await loadWarRoomAgents(tempDir);
expect(agents).toHaveLength(1);
expect(agents[0].id).toBe("custom");
expect(agents[0].name).toBe("Custom Agent");
expect(agents[0].role).toBe("Specialist");
expect(agents[0].kibble).toBe(10);
expect(agents[0].model).toBe("opus");
expect(agents[0].personality).toBe("You are a custom specialist agent.");
});

it("defaults values for custom agents with minimal frontmatter", async () => {
const agentDir = join(tempDir, ".storm", "agents", "minimal");
mkdirSync(agentDir, { recursive: true });
writeFileSync(
join(agentDir, "AGENT.md"),
`---
---
Just a body.`
);

const agents = await loadWarRoomAgents(tempDir);
expect(agents).toHaveLength(1);
expect(agents[0].name).toBe("minimal");
expect(agents[0].role).toBe("Agent");
expect(agents[0].kibble).toBe(20);
expect(agents[0].model).toBeUndefined();
});
});

describe("DEFAULT_AGENTS", () => {
it("has three default agents", () => {
expect(DEFAULT_AGENTS).toHaveLength(3);
});

it("each has required fields", () => {
for (const agent of DEFAULT_AGENTS) {
expect(agent.id).toBeTruthy();
expect(agent.name).toBeTruthy();
expect(agent.role).toBeTruthy();
expect(agent.personality).toBeTruthy();
expect(agent.kibble).toBeGreaterThan(0);
}
});
});
91 changes: 91 additions & 0 deletions src/__tests__/war-room-ui.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, it, expect } from "bun:test";
import { renderKibbleBar, truncate, PlainRenderer } from "../core/war-room-ui.js";
import { createWarRoomSession } from "../core/war-room.js";
import type { AgentConfig, WarRoomAgent, WarRoomEvent, KibbleTransfer } from "../core/types.js";

function makeAgent(id = "test"): AgentConfig {
return {
id,
name: "Test",
role: "Tester",
personality: "Test agent",
kibble: 20,
};
}

function makeWarRoomAgent(config?: AgentConfig): WarRoomAgent {
return {
config: config ?? makeAgent(),
kibbleRemaining: 20,
toolsUsed: 0,
};
}

describe("renderKibbleBar", () => {
it("renders full bar", () => {
expect(renderKibbleBar(20, 20, 5)).toBe("█████");
});

it("renders empty bar", () => {
expect(renderKibbleBar(0, 20, 5)).toBe("░░░░░");
});

it("renders partial bar", () => {
expect(renderKibbleBar(10, 20, 5)).toBe("███░░");
});

it("handles custom width", () => {
expect(renderKibbleBar(5, 10, 10)).toBe("█████░░░░░");
});

it("handles zero total", () => {
expect(renderKibbleBar(0, 0, 5)).toBe("░░░░░");
});

it("rounds correctly", () => {
// 3/20 * 5 = 0.75 → rounds to 1
expect(renderKibbleBar(3, 20, 5)).toBe("█░░░░");
});
});

describe("truncate", () => {
it("returns string unchanged if within limit", () => {
expect(truncate("hello", 10)).toBe("hello");
});

it("truncates long string with ellipsis", () => {
expect(truncate("hello world", 8)).toBe("hello w…");
});

it("handles exact length", () => {
expect(truncate("hello", 5)).toBe("hello");
});

it("handles single char limit", () => {
expect(truncate("hello", 1)).toBe("…");
});
});

describe("PlainRenderer", () => {
it("can be instantiated and methods called without error", () => {
const renderer = new PlainRenderer();
const session = createWarRoomSession("test task", [makeAgent()]);
const agent = session.agents[0];
const event: WarRoomEvent = { type: "system", message: "test", timestamp: Date.now() };
const transfer: KibbleTransfer = { from: "A", to: "B", amount: 5 };

// All methods should not throw
renderer.init(session);
renderer.onTurnStart(session, agent);
renderer.onToolUse(session, agent, "Bash");
renderer.onEvent(session, event);
renderer.onTransfer(session, transfer);
renderer.onDone(session, agent);
renderer.onTimeout(session, agent);
renderer.onAllKibbleExhausted(session);
renderer.onAbort(session);
renderer.onTurnEnd(session, agent);
renderer.onComplete(session);
renderer.destroy();
});
});
Loading
Loading