Skip to content
Closed
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
94 changes: 94 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,100 @@ storm global remove /path/to/project-a

Projects must have a valid `.storm/storm.json` to be registered. Invalid or missing projects are skipped with a warning during `global run` and `global status`.

## War-room mode

The `storm war-room` command spins up multiple named agents in a shared workspace. Instead of one agent working alone, a team with defined roles collaborates via a shared event log until the task is done.

```bash
# Start a war room for a GitHub issue (uses default agents)
storm war-room --issue 42

# Use a specific set of agents
storm war-room --issue 42 --agents architect,engineer,qa

# Start a war room from a free-form prompt
storm war-room --prompt "Build a dark mode toggle"

# Preview agents and task without spawning processes
storm war-room --issue 42 --dry-run
```

### How it works

1. Storm loads the task (from an issue or `--prompt`) and creates a session under `.storm/sessions/{id}/`
2. Each agent runs as a separate `claude -p` process with its own role and personality
3. Agents communicate only via a shared append-only event log (`.storm/sessions/{id}/events.jsonl`)
4. Each agent has a **kibble budget** — expensive tool uses (bash, file edits) cost 1 kibble
5. The orchestrator runs up to 30 turns, picking agents round-robin, skipping any with 0 kibble
6. When an agent outputs `%%STORM_DONE%%`, the session ends and a PR is opened

### Default agents

| ID | Name | Role | Responsibility |
|---|---|---|---|
| `architect` | Storm | Architect | Reads the issue, creates a plan, delegates tasks |
| `engineer` | Johnny | Engineer | Implements code based on the plan, runs typecheck |
| `qa` | Alan | QA | Runs tests, reviews code, signs off on quality |

### Kibble transfers

An agent can transfer part of its kibble budget to another agent by including this in its response:

```
%%TRANSFER_KIBBLE:5:Johnny%%
```

This deducts 5 kibble from the current agent and gives it to Johnny.

### Custom agents

Define custom agents in `.storm/agents/{id}/AGENT.md`:

```
.storm/
└── agents/
├── architect/
│ └── AGENT.md
├── engineer/
│ └── AGENT.md
└── qa/
└── AGENT.md
```

Each `AGENT.md` uses YAML frontmatter followed by the agent's personality prompt:

```markdown
---
name: Luna
role: Engineer
kibble: 20
model: sonnet
---
You are Luna, a pragmatic senior engineer. You implement what the Architect
specifies, ask clarifying questions when the spec is unclear, and always run
the typecheck before declaring work done.
```

| Field | Default | Description |
|---|---|---|
| `name` | directory name | Display name shown in the event log |
| `role` | directory name | Role label (Architect, Engineer, QA, etc.) |
| `kibble` | `20` | Starting budget for expensive tool uses |
| `model` | `"sonnet"` | Claude model for this agent |

If `.storm/agents/` does not exist or is empty, the three built-in default agents are used.

### Event log format

Each event is a JSON line appended to `.storm/sessions/{id}/events.jsonl`:

```jsonl
{"ts":1234567890,"agent":"Storm","type":"talk","room":"war-room","data":"Here is my plan..."}
{"ts":1234567890,"agent":"Johnny","type":"talk","room":"war-room","data":"Implementing now..."}
{"ts":1234567890,"agent":"Johnny","type":"transfer-kibble","room":"war-room","data":{"to":"Alan","amount":3}}
{"ts":1234567890,"agent":"Alan","type":"done","room":"war-room","data":"Task complete"}
```

## Updating storm-agent

If you installed via the quick install script, update to the latest version with:
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("Spin up multiple named agents in a shared workspace to collaborate on a task")
.option("-i, --issue <number>", "GitHub issue number to solve", parseInt)
.option("-p, --prompt <text>", "Free-form task description")
.option("--agents <list>", "Comma-separated list of agent IDs to use", (v: string) =>
v.split(",").map((s) => s.trim())
)
.option("--dry-run", "Preview agents and task without spawning processes")
.action(async (options) => {
await warRoomCommand(process.cwd(), {
issue: options.issue,
prompt: options.prompt,
agents: options.agents,
dryRun: options.dryRun,
});
});

const globalCmd = program
.command("global")
.description("Manage and run storm across multiple projects");
Expand Down
83 changes: 83 additions & 0 deletions src/__tests__/war-room-agent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, it, expect, mock } from "bun:test";

// We test loadWarRoomAgents by mocking the file system
const mockExistsSync = mock((path: string) => false);

mock.module("fs", () => ({
existsSync: mockExistsSync,
readdirSync: mock(() => []),
}));

import { loadWarRoomAgents } from "../primitives/war-room-agent.js";
import { DEFAULT_KIBBLE } from "../core/constants.js";

describe("loadWarRoomAgents (defaults)", () => {
it("returns all three default agents when .storm/agents does not exist", async () => {
mockExistsSync.mockImplementation(() => false);
const agents = await loadWarRoomAgents("/fake/cwd");
expect(agents).toHaveLength(3);
const ids = agents.map((a) => a.id);
expect(ids).toContain("architect");
expect(ids).toContain("engineer");
expect(ids).toContain("qa");
});

it("default agents have correct names", async () => {
mockExistsSync.mockImplementation(() => false);
const agents = await loadWarRoomAgents("/fake/cwd");
const names = agents.map((a) => a.name);
expect(names).toContain("Storm");
expect(names).toContain("Johnny");
expect(names).toContain("Alan");
});

it("default agents have correct roles", async () => {
mockExistsSync.mockImplementation(() => false);
const agents = await loadWarRoomAgents("/fake/cwd");
const roles = agents.map((a) => a.role);
expect(roles).toContain("Architect");
expect(roles).toContain("Engineer");
expect(roles).toContain("QA");
});

it("default agents start with DEFAULT_KIBBLE", async () => {
mockExistsSync.mockImplementation(() => false);
const agents = await loadWarRoomAgents("/fake/cwd");
for (const agent of agents) {
expect(agent.kibble).toBe(DEFAULT_KIBBLE);
expect(agent.kibbleRemaining).toBe(DEFAULT_KIBBLE);
}
});

it("filters default agents when agentIds provided", async () => {
mockExistsSync.mockImplementation(() => false);
const agents = await loadWarRoomAgents("/fake/cwd", ["architect", "qa"]);
expect(agents).toHaveLength(2);
const ids = agents.map((a) => a.id);
expect(ids).toContain("architect");
expect(ids).toContain("qa");
expect(ids).not.toContain("engineer");
});

it("returns all defaults when agentIds filter matches nothing", async () => {
mockExistsSync.mockImplementation(() => false);
const agents = await loadWarRoomAgents("/fake/cwd", ["nonexistent"]);
expect(agents).toHaveLength(3);
});

it("default agents have toolUseCount of 0", async () => {
mockExistsSync.mockImplementation(() => false);
const agents = await loadWarRoomAgents("/fake/cwd");
for (const agent of agents) {
expect(agent.toolUseCount).toBe(0);
}
});

it("default agents have non-empty personality strings", async () => {
mockExistsSync.mockImplementation(() => false);
const agents = await loadWarRoomAgents("/fake/cwd");
for (const agent of agents) {
expect(agent.personality.length).toBeGreaterThan(0);
}
});
});
Loading
Loading