diff --git a/README.md b/README.md index 5c8480b..34d7ebb 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/index.ts b/index.ts index b6dbe5c..044886a 100644 --- a/index.ts +++ b/index.ts @@ -14,6 +14,7 @@ import { globalRunCommand, globalStatusCommand, } from "./src/commands/global.js"; +import { warRoomCommand } from "./src/commands/war-room.js"; const program = new Command(); @@ -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 ", "GitHub issue number to solve", parseInt) + .option("-p, --prompt ", "Free-form task description") + .option("--agents ", "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"); diff --git a/src/__tests__/war-room-agent.test.ts b/src/__tests__/war-room-agent.test.ts new file mode 100644 index 0000000..834fcf0 --- /dev/null +++ b/src/__tests__/war-room-agent.test.ts @@ -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); + } + }); +}); diff --git a/src/__tests__/war-room.test.ts b/src/__tests__/war-room.test.ts new file mode 100644 index 0000000..da80359 --- /dev/null +++ b/src/__tests__/war-room.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect, mock, beforeEach } from "bun:test"; + +// Mock external dependencies before importing modules under test +const mockAppendFileSync = mock(() => {}); +const mockMkdirSync = mock(() => {}); + +mock.module("fs", () => ({ + appendFileSync: mockAppendFileSync, + mkdirSync: mockMkdirSync, + existsSync: mock(() => true), +})); + +mock.module("../core/output.js", () => ({ + log: { + info: mock(() => {}), + step: mock(() => {}), + success: mock(() => {}), + warn: mock(() => {}), + error: mock(() => {}), + dim: mock(() => {}), + }, + formatDuration: mock(() => "1s"), +})); + +import { + buildAgentPrompt, + parseTransferKibble, + formatEventsForPrompt, + createWarRoomSession, + appendEvent, +} from "../core/war-room.js"; +import type { WarRoomAgent, WarRoomEvent } from "../core/types.js"; +import { STOP_MARKER, TRANSFER_KIBBLE_MARKER } from "../core/constants.js"; + +function makeAgent(overrides: Partial = {}): WarRoomAgent { + return { + id: "engineer", + name: "Johnny", + role: "Engineer", + kibble: 20, + kibbleRemaining: 20, + model: "sonnet", + personality: "You are Johnny, a pragmatic engineer.", + toolUseCount: 0, + ...overrides, + }; +} + +function makeEvent(overrides: Partial = {}): WarRoomEvent { + return { + ts: 1000, + agent: "Johnny", + type: "talk", + room: "war-room", + data: "Hello from Johnny", + ...overrides, + }; +} + +describe("formatEventsForPrompt", () => { + it("returns placeholder when no events", () => { + const result = formatEventsForPrompt([]); + expect(result).toContain("no events yet"); + }); + + it("formats events as [agent] (type): data", () => { + const events = [makeEvent({ agent: "Storm", type: "talk", data: "Let's plan" })]; + const result = formatEventsForPrompt(events); + expect(result).toContain("[Storm]"); + expect(result).toContain("(talk)"); + expect(result).toContain("Let's plan"); + }); + + it("limits to maxEvents most recent events", () => { + const events = Array.from({ length: 30 }, (_, i) => + makeEvent({ data: `message ${i}` }) + ); + const result = formatEventsForPrompt(events, 5); + expect(result).toContain("message 29"); + expect(result).toContain("message 25"); + expect(result).not.toContain("message 24"); + }); + + it("serializes object data as JSON", () => { + const event = makeEvent({ type: "transfer-kibble", data: { to: "Alan", amount: 5 } }); + const result = formatEventsForPrompt([event]); + expect(result).toContain("Alan"); + expect(result).toContain("5"); + }); +}); + +describe("buildAgentPrompt", () => { + it("includes agent personality", () => { + const agent = makeAgent({ personality: "You are a super agent." }); + const result = buildAgentPrompt(agent, "Fix the bug", []); + expect(result).toContain("You are a super agent."); + }); + + it("includes task description", () => { + const agent = makeAgent(); + const result = buildAgentPrompt(agent, "Build dark mode toggle", []); + expect(result).toContain("Build dark mode toggle"); + }); + + it("includes kibble remaining", () => { + const agent = makeAgent({ kibbleRemaining: 7 }); + const result = buildAgentPrompt(agent, "task", []); + expect(result).toContain("7"); + }); + + it("includes agent role", () => { + const agent = makeAgent({ role: "QA" }); + const result = buildAgentPrompt(agent, "task", []); + expect(result).toContain("QA"); + }); + + it("includes STOP_MARKER instructions", () => { + const agent = makeAgent(); + const result = buildAgentPrompt(agent, "task", []); + expect(result).toContain(STOP_MARKER); + }); + + it("includes TRANSFER_KIBBLE_MARKER instructions", () => { + const agent = makeAgent(); + const result = buildAgentPrompt(agent, "task", []); + expect(result).toContain(TRANSFER_KIBBLE_MARKER); + }); + + it("includes recent event history", () => { + const agent = makeAgent(); + const events = [makeEvent({ agent: "Alan", data: "Tests are failing" })]; + const result = buildAgentPrompt(agent, "task", events); + expect(result).toContain("Alan"); + expect(result).toContain("Tests are failing"); + }); +}); + +describe("parseTransferKibble", () => { + it("parses a single transfer", () => { + const output = "I'll transfer some budget. %%TRANSFER_KIBBLE:5:Alan%%"; + const transfers = parseTransferKibble(output); + expect(transfers).toHaveLength(1); + expect(transfers[0]).toEqual({ amount: 5, to: "Alan" }); + }); + + it("parses multiple transfers", () => { + const output = "%%TRANSFER_KIBBLE:3:Johnny%% and %%TRANSFER_KIBBLE:2:Storm%%"; + const transfers = parseTransferKibble(output); + expect(transfers).toHaveLength(2); + expect(transfers[0]).toEqual({ amount: 3, to: "Johnny" }); + expect(transfers[1]).toEqual({ amount: 2, to: "Storm" }); + }); + + it("returns empty array when no transfers", () => { + const output = "No kibble transfers here."; + const transfers = parseTransferKibble(output); + expect(transfers).toHaveLength(0); + }); + + it("handles multi-word agent names that are alphanumeric", () => { + const output = "%%TRANSFER_KIBBLE:10:Storm%%"; + const transfers = parseTransferKibble(output); + expect(transfers[0].to).toBe("Storm"); + }); + + it("ignores malformed transfer markers", () => { + const output = "%%TRANSFER_KIBBLE:notanumber:Alan%%"; + const transfers = parseTransferKibble(output); + // parseInt("notanumber") returns NaN — should not match + expect(transfers).toHaveLength(0); + }); +}); + +describe("createWarRoomSession", () => { + it("creates a session with correct fields", () => { + const agents = [makeAgent()]; + const session = createWarRoomSession("Fix the bug", agents, 42); + expect(session.task).toBe("Fix the bug"); + expect(session.agents).toBe(agents); + expect(session.issueNumber).toBe(42); + expect(session.done).toBe(false); + expect(typeof session.id).toBe("string"); + expect(session.id.length).toBeGreaterThan(0); + expect(session.startedAt).toBeGreaterThan(0); + }); + + it("works without issue number", () => { + const session = createWarRoomSession("prompt-based task", [makeAgent()]); + expect(session.issueNumber).toBeUndefined(); + }); + + it("generates unique session IDs", () => { + const s1 = createWarRoomSession("task", [makeAgent()]); + const s2 = createWarRoomSession("task", [makeAgent()]); + expect(s1.id).not.toBe(s2.id); + }); +}); + +describe("appendEvent", () => { + beforeEach(() => { + mockAppendFileSync.mockClear(); + }); + + it("appends a JSONL line to the events file", () => { + const event = makeEvent(); + appendEvent("/tmp/session", event); + expect(mockAppendFileSync).toHaveBeenCalledTimes(1); + const call = mockAppendFileSync.mock.calls[0] as unknown as [string, string, string]; + expect(call[0]).toContain("events.jsonl"); + expect(call[1]).toContain('"agent":"Johnny"'); + expect(call[1]).toEndWith("\n"); + }); + + it("serializes event data correctly", () => { + const event = makeEvent({ type: "transfer-kibble", data: { to: "Storm", amount: 3 } }); + appendEvent("/tmp/session", event); + const call = mockAppendFileSync.mock.calls[0] as unknown as [string, string, string]; + const parsed = JSON.parse(call[1].trim()); + expect(parsed.type).toBe("transfer-kibble"); + expect((parsed.data as { to: string; amount: number }).to).toBe("Storm"); + expect((parsed.data as { to: string; amount: number }).amount).toBe(3); + }); +}); diff --git a/src/commands/war-room.ts b/src/commands/war-room.ts new file mode 100644 index 0000000..8274daf --- /dev/null +++ b/src/commands/war-room.ts @@ -0,0 +1,134 @@ +import { existsSync } from "fs"; +import { join } from "path"; +import { loadConfig, validateConfig } from "../core/config.js"; +import { fetchIssue } from "../core/github.js"; +import { log } from "../core/output.js"; +import { CONFIG_DIR } from "../core/constants.js"; +import { loadWarRoomAgents } from "../primitives/war-room-agent.js"; +import { createWarRoomSession, runWarRoom } from "../core/war-room.js"; +import { branchName, checkoutBase, createBranch, commitAndPush, openPR } from "../core/pr.js"; +import type { WarRoomOptions, GitHubIssue } from "../core/types.js"; + +export async function warRoomCommand(cwd: string, options: WarRoomOptions) { + if (!existsSync(join(cwd, CONFIG_DIR))) { + log.error("No .storm/ directory found. Run `storm init` first."); + process.exit(1); + } + + const config = await loadConfig(cwd); + const errors = validateConfig(config); + if (errors.length > 0) { + for (const err of errors) log.error(err); + process.exit(1); + } + + if (!options.issue && !options.prompt) { + log.error("Either --issue or --prompt is required."); + process.exit(1); + } + + const { dryRun = false } = options; + + // SIGINT handler + const controller = new AbortController(); + process.on("SIGINT", () => { + log.warn("SIGINT received, finishing current turn..."); + controller.abort(); + }); + + // Resolve task from issue or prompt + let task: string; + let issue: GitHubIssue | undefined; + + if (options.issue) { + issue = await fetchIssue(config.github.repo, options.issue); + task = `Issue #${issue.number}: ${issue.title}\n\n${issue.body}`; + } else { + task = options.prompt!; + } + + // Load agents + const agents = await loadWarRoomAgents(cwd, options.agents); + log.info( + `Loaded ${agents.length} agent(s): ${agents.map((a) => `${a.name} (${a.role})`).join(", ")}` + ); + + if (dryRun) { + log.warn("Dry run — no agents will be spawned"); + log.info(`Task: ${task}`); + for (const agent of agents) { + log.info(` ${agent.name} / ${agent.role} [kibble: ${agent.kibble}, model: ${agent.model}]`); + } + return; + } + + // Create branch + let branch: string; + + if (issue) { + branch = branchName(issue); + if (!(await checkoutBase(config.github.baseBranch, cwd))) { + process.exit(1); + } + if (!(await createBranch(branch, cwd))) { + process.exit(1); + } + } else { + const slug = task + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 40); + branch = `storm/war-room-${Date.now()}-${slug}`; + if (!(await checkoutBase(config.github.baseBranch, cwd))) { + process.exit(1); + } + if (!(await createBranch(branch, cwd))) { + process.exit(1); + } + } + + // Create and run war room session + const session = createWarRoomSession(task, agents, issue?.number); + log.info(`Starting war room session ${session.id}`); + + const result = await runWarRoom(session, config, cwd, controller.signal); + + if (!result.success) { + log.error("War room ended without completing the task"); + process.exit(1); + } + + // Commit and push + log.step("Committing and pushing..."); + const fakeIssue: GitHubIssue = issue ?? { + number: 0, + title: task.slice(0, 50), + body: task, + labels: [], + url: "", + }; + + const commitMsg = issue + ? `storm: war-room #${issue.number} - ${issue.title}` + : `storm: war-room - ${fakeIssue.title}`; + + const pushed = await commitAndPush(branch, fakeIssue, cwd, commitMsg); + if (!pushed) { + log.error("Failed to commit and push"); + process.exit(1); + } + + // Open PR if we have a linked issue + if (issue) { + log.step("Creating pull request..."); + const prUrl = await openPR(config, issue, branch, cwd); + if (prUrl) { + log.success(`War room complete: ${prUrl}`); + } else { + log.warn("War room complete but PR creation failed"); + } + } else { + log.success(`War room complete on branch: ${branch}`); + } +} diff --git a/src/core/constants.ts b/src/core/constants.ts index 70679ae..4578382 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -10,3 +10,14 @@ export const INSTRUCTION_FILE = "INSTRUCTION.md"; export const CONTEXT_FILE = "CONTEXT.md"; export const PR_DESCRIPTION_FILE = "PR_DESCRIPTION.md"; export const CONTINUE_FILE = "CONTINUE.md"; + +// War-room constants +export const AGENTS_DIR = "agents"; +export const AGENT_FILE = "AGENT.md"; +export const SESSIONS_DIR = "sessions"; +export const EVENTS_FILE = "events.jsonl"; +export const WAR_ROOM_NAME = "war-room"; +export const DEFAULT_KIBBLE = 20; +export const KIBBLE_TOOLS = new Set(["bash", "computer", "str_replace_based_edit_tool", "create_file", "delete_file"]); +export const TRANSFER_KIBBLE_MARKER = "%%TRANSFER_KIBBLE:"; +export const MAX_WAR_ROOM_TURNS = 30; diff --git a/src/core/types.ts b/src/core/types.ts index 9b86761..a780108 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -131,3 +131,58 @@ export interface GlobalProject { export interface GlobalConfig { projects: GlobalProject[]; } + +// War-room types + +export interface AgentConfig { + name: string; + role: string; + kibble: number; + model: string; + personality: string; +} + +export interface WarRoomAgent extends AgentConfig { + id: string; + kibbleRemaining: number; + sessionId?: string; + toolUseCount: number; +} + +export type WarRoomEventType = + | "talk" + | "bash" + | "read-file" + | "transfer-kibble" + | "system" + | "done"; + +export interface KibbleTransfer { + to: string; + amount: number; +} + +export interface WarRoomEvent { + ts: number; + agent: string; + type: WarRoomEventType; + room: string; + data: string | KibbleTransfer; +} + +export interface WarRoomSession { + id: string; + task: string; + agents: WarRoomAgent[]; + startedAt: number; + done: boolean; + prUrl?: string; + issueNumber?: number; +} + +export interface WarRoomOptions { + issue?: number; + prompt?: string; + agents?: string[]; + dryRun?: boolean; +} diff --git a/src/core/war-room.ts b/src/core/war-room.ts new file mode 100644 index 0000000..552998e --- /dev/null +++ b/src/core/war-room.ts @@ -0,0 +1,348 @@ +import { join } from "path"; +import { mkdirSync, appendFileSync } from "fs"; +import type { + StormConfig, + WarRoomAgent, + WarRoomEvent, + WarRoomSession, +} from "./types.js"; +import { + CONFIG_DIR, + SESSIONS_DIR, + EVENTS_FILE, + WAR_ROOM_NAME, + STOP_MARKER, + TRANSFER_KIBBLE_MARKER, + MAX_WAR_ROOM_TURNS, + KIBBLE_TOOLS, +} from "./constants.js"; +import { log, formatDuration } from "./output.js"; + +function generateSessionId(): string { + return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +export function appendEvent(sessionDir: string, event: WarRoomEvent): void { + const eventsPath = join(sessionDir, EVENTS_FILE); + appendFileSync(eventsPath, JSON.stringify(event) + "\n", "utf-8"); +} + +export function formatEventsForPrompt(events: WarRoomEvent[], maxEvents = 20): string { + const recent = events.slice(-maxEvents); + if (recent.length === 0) return "(no events yet — you are the first to act)"; + return recent + .map((e) => { + const data = typeof e.data === "string" ? e.data : JSON.stringify(e.data); + return `[${e.agent}] (${e.type}): ${data}`; + }) + .join("\n"); +} + +export function buildAgentPrompt( + agent: WarRoomAgent, + task: string, + events: WarRoomEvent[] +): string { + const eventsText = formatEventsForPrompt(events); + + return `${agent.personality} + +## Current Task + +${task} + +## Your Kibble Budget + +You have ${agent.kibbleRemaining} kibble remaining. Each expensive tool use (bash commands, file edits, computer use) costs 1 kibble. Plan your actions accordingly. If you run out of kibble, you cannot act. + +## War Room Activity (Recent Events) + +${eventsText} + +## Instructions + +- Work collaboratively with the other agents to complete the task +- Use your built-in tools (bash, file editing) to make code changes +- To transfer some of your kibble budget to another agent, include this in your response: + ${TRANSFER_KIBBLE_MARKER}{amount}:{agentName}%% + Example: ${TRANSFER_KIBBLE_MARKER}5:Johnny%% +- When the entire task is fully complete and all changes are committed, output exactly: + ${STOP_MARKER} +- Focus on your role: ${agent.role} + +Now respond as ${agent.name} and take your next action:`; +} + +export function parseTransferKibble(output: string): Array<{ amount: number; to: string }> { + // TRANSFER_KIBBLE_MARKER is "%%TRANSFER_KIBBLE:" so pattern is %%TRANSFER_KIBBLE:{amount}:{name}%% + const pattern = /%%TRANSFER_KIBBLE:(\d+):([\w]+)%%/g; + const transfers: Array<{ amount: number; to: string }> = []; + let match; + while ((match = pattern.exec(output)) !== null) { + transfers.push({ amount: parseInt(match[1], 10), to: match[2] }); + } + return transfers; +} + +interface WarRoomSpawnResult { + output: string; + kibbleCost: number; + done: boolean; + timedOut: boolean; + sessionId?: string; + durationMs: number; +} + +async function spawnWarRoomAgent( + prompt: string, + config: StormConfig, + agent: WarRoomAgent, + cwd: string, + timeout = 300_000 +): Promise { + const args = [ + ...config.agent.args, + "--verbose", + "--output-format", + "stream-json", + "--model", + agent.model, + ]; + + log.dim(` [${agent.name}] $ ${config.agent.command} ${args.join(" ")}`); + + const start = Date.now(); + const proc = Bun.spawn([config.agent.command, ...args], { + cwd, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + + proc.stdin.write(prompt); + proc.stdin.end(); + + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + proc.kill(); + }, timeout); + + let output = ""; + let sessionId: string | undefined; + let kibbleCost = 0; + const reader = proc.stdout.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + try { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.trim()) continue; + try { + const msg = JSON.parse(line); + if (msg.type === "result") { + output = msg.result || ""; + sessionId = msg.session_id ?? undefined; + } else if (msg.type === "assistant" && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === "tool_use") { + log.dim(` [${agent.name}][tool] ${block.name}`); + if (KIBBLE_TOOLS.has(block.name)) { + kibbleCost++; + } + } + } + } + } catch { + // Not JSON, skip + } + } + } + } finally { + reader.releaseLock(); + } + + const stderr = await new Response(proc.stderr).text(); + if (stderr.trim()) { + log.dim(` [${agent.name}][stderr] ${stderr.trim().slice(0, 200)}`); + } + + await proc.exited; + } finally { + clearTimeout(timer); + } + + const done = output.includes(STOP_MARKER); + const durationMs = Date.now() - start; + + return { output, kibbleCost, done, timedOut, sessionId, durationMs }; +} + +export function createWarRoomSession( + task: string, + agents: WarRoomAgent[], + issueNumber?: number +): WarRoomSession { + return { + id: generateSessionId(), + task, + agents, + startedAt: Date.now(), + done: false, + issueNumber, + }; +} + +export async function runWarRoom( + session: WarRoomSession, + config: StormConfig, + cwd: string, + signal?: AbortSignal +): Promise<{ success: boolean }> { + const sessionDir = join(cwd, CONFIG_DIR, SESSIONS_DIR, session.id); + mkdirSync(sessionDir, { recursive: true }); + + const systemStart: WarRoomEvent = { + ts: Date.now(), + agent: "system", + type: "system", + room: WAR_ROOM_NAME, + data: `War room started. Task: ${session.task.slice(0, 200)}. Agents: ${session.agents.map((a) => a.name).join(", ")}`, + }; + appendEvent(sessionDir, systemStart); + + log.info(`War room session ${session.id} started with ${session.agents.length} agents`); + for (const agent of session.agents) { + log.dim(` ${agent.name} (${agent.role}) — kibble: ${agent.kibbleRemaining}`); + } + + const agents = session.agents; + const events: WarRoomEvent[] = [systemStart]; + const start = Date.now(); + let turn = 0; + let agentIndex = 0; + let done = false; + + while (turn < MAX_WAR_ROOM_TURNS && !done) { + if (signal?.aborted) { + log.warn("Stop requested, finishing war room..."); + break; + } + + // Find next agent with kibble remaining (round-robin) + let found = false; + for (let i = 0; i < agents.length; i++) { + const candidate = agents[(agentIndex + i) % agents.length]; + if (candidate.kibbleRemaining > 0) { + agentIndex = (agentIndex + i) % agents.length; + found = true; + break; + } + } + + if (!found) { + log.warn("All agents have exhausted their kibble budget. Ending war room."); + break; + } + + const agent = agents[agentIndex]; + agentIndex = (agentIndex + 1) % agents.length; + turn++; + + log.step( + `Turn ${turn}/${MAX_WAR_ROOM_TURNS} — ${agent.name} (${agent.role}) [kibble: ${agent.kibbleRemaining}]` + ); + + const prompt = buildAgentPrompt(agent, session.task, events); + const result = await spawnWarRoomAgent(prompt, config, agent, cwd); + + // Deduct kibble for this turn's tool usage + agent.kibbleRemaining = Math.max(0, agent.kibbleRemaining - result.kibbleCost); + agent.toolUseCount += result.kibbleCost; + + if (result.sessionId) { + agent.sessionId = result.sessionId; + } + + // Record talk event with agent's output + const talkEvent: WarRoomEvent = { + ts: Date.now(), + agent: agent.name, + type: "talk", + room: WAR_ROOM_NAME, + data: result.output.slice(0, 2000), + }; + events.push(talkEvent); + appendEvent(sessionDir, talkEvent); + + // Handle kibble transfers + const transfers = parseTransferKibble(result.output); + for (const transfer of transfers) { + const target = agents.find( + (a) => + a.name.toLowerCase() === transfer.to.toLowerCase() || + a.id.toLowerCase() === transfer.to.toLowerCase() + ); + if (target && transfer.amount > 0 && agent.kibbleRemaining >= transfer.amount) { + agent.kibbleRemaining -= transfer.amount; + target.kibbleRemaining += transfer.amount; + + const kibbleEvent: WarRoomEvent = { + ts: Date.now(), + agent: agent.name, + type: "transfer-kibble", + room: WAR_ROOM_NAME, + data: { to: target.name, amount: transfer.amount }, + }; + events.push(kibbleEvent); + appendEvent(sessionDir, kibbleEvent); + + log.info(`${agent.name} transferred ${transfer.amount} kibble to ${target.name}`); + } + } + + log.dim( + ` [${agent.name}] kibble remaining: ${agent.kibbleRemaining}, cost this turn: ${result.kibbleCost}, duration: ${formatDuration(result.durationMs)}` + ); + + if (result.done) { + done = true; + session.done = true; + + const doneEvent: WarRoomEvent = { + ts: Date.now(), + agent: agent.name, + type: "done", + room: WAR_ROOM_NAME, + data: "Task complete", + }; + events.push(doneEvent); + appendEvent(sessionDir, doneEvent); + + log.success(`${agent.name} signaled task complete`); + } + + if (result.timedOut) { + log.error(`${agent.name} timed out on turn ${turn}`); + } + } + + const elapsed = formatDuration(Date.now() - start); + + if (!done) { + log.warn(`War room ended after ${turn} turns without completion (${elapsed})`); + return { success: false }; + } + + log.success(`War room complete in ${turn} turns (${elapsed})`); + return { success: true }; +} diff --git a/src/primitives/war-room-agent.ts b/src/primitives/war-room-agent.ts new file mode 100644 index 0000000..aa46f74 --- /dev/null +++ b/src/primitives/war-room-agent.ts @@ -0,0 +1,100 @@ +import { existsSync } from "fs"; +import { join } from "path"; +import matter from "gray-matter"; +import type { WarRoomAgent } from "../core/types.js"; +import { CONFIG_DIR, AGENTS_DIR, AGENT_FILE, DEFAULT_KIBBLE } from "../core/constants.js"; + +const DEFAULT_AGENTS: WarRoomAgent[] = [ + { + id: "architect", + name: "Storm", + role: "Architect", + kibble: DEFAULT_KIBBLE, + kibbleRemaining: DEFAULT_KIBBLE, + model: "sonnet", + toolUseCount: 0, + personality: `You are Storm, a senior software architect. You read the issue carefully, create a detailed implementation plan, and delegate clear tasks to the Engineer. Ask clarifying questions when the spec is ambiguous. Always think about edge cases and architecture before diving into code.`, + }, + { + id: "engineer", + name: "Johnny", + role: "Engineer", + kibble: DEFAULT_KIBBLE, + kibbleRemaining: DEFAULT_KIBBLE, + model: "sonnet", + toolUseCount: 0, + personality: `You are Johnny, 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. You write clean, tested code and commit your changes.`, + }, + { + id: "qa", + name: "Alan", + role: "QA", + kibble: DEFAULT_KIBBLE, + kibbleRemaining: DEFAULT_KIBBLE, + model: "sonnet", + toolUseCount: 0, + personality: `You are Alan, a meticulous QA engineer. You run tests, check for edge cases, review the Engineer's code for correctness and quality, and report failures back to the room. You verify the implementation satisfies the original requirements before signing off.`, + }, +]; + +export async function loadWarRoomAgents( + cwd: string, + agentIds?: string[] +): Promise { + const agentsDir = join(cwd, CONFIG_DIR, AGENTS_DIR); + + if (!existsSync(agentsDir)) { + return filterAgents(DEFAULT_AGENTS, agentIds); + } + + const dir = await Bun.file(agentsDir).exists().catch(() => false); + if (!dir) { + return filterAgents(DEFAULT_AGENTS, agentIds); + } + + // Read subdirectories + const { readdirSync } = await import("fs"); + const entries = readdirSync(agentsDir, { withFileTypes: true }).filter((d) => + d.isDirectory() + ); + + const targetEntries = agentIds + ? entries.filter((d) => agentIds.includes(d.name)) + : entries; + + if (targetEntries.length === 0) { + return filterAgents(DEFAULT_AGENTS, agentIds); + } + + const agents: WarRoomAgent[] = []; + + for (const entry of targetEntries) { + const agentFilePath = join(agentsDir, entry.name, AGENT_FILE); + const agentFile = Bun.file(agentFilePath); + if (!(await agentFile.exists())) continue; + + const content = await agentFile.text(); + const { data, content: body } = matter(content); + + const kibble = (data.kibble as number | undefined) ?? DEFAULT_KIBBLE; + + agents.push({ + id: entry.name, + name: (data.name as string | undefined) ?? entry.name, + role: (data.role as string | undefined) ?? entry.name, + kibble, + kibbleRemaining: kibble, + model: (data.model as string | undefined) ?? "sonnet", + toolUseCount: 0, + personality: body.trim(), + }); + } + + return agents.length > 0 ? agents : filterAgents(DEFAULT_AGENTS, agentIds); +} + +function filterAgents(agents: WarRoomAgent[], agentIds?: string[]): WarRoomAgent[] { + if (!agentIds || agentIds.length === 0) return agents; + const filtered = agents.filter((a) => agentIds.includes(a.id)); + return filtered.length > 0 ? filtered : agents; +}