diff --git a/README.md b/README.md index 5c8480b..8ef76e1 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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`. diff --git a/index.ts b/index.ts index b6dbe5c..193a2b4 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("Multi-agent war room for complex tasks") + .option("-i, --issue ", "GitHub issue number", parseInt) + .option("-p, --prompt ", "Free-form task description") + .option("--agents ", "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"); diff --git a/src/__tests__/war-room-agent.test.ts b/src/__tests__/war-room-agent.test.ts new file mode 100644 index 0000000..7e304f5 --- /dev/null +++ b/src/__tests__/war-room-agent.test.ts @@ -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); + } + }); +}); diff --git a/src/__tests__/war-room-ui.test.ts b/src/__tests__/war-room-ui.test.ts new file mode 100644 index 0000000..7658f0e --- /dev/null +++ b/src/__tests__/war-room-ui.test.ts @@ -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(); + }); +}); diff --git a/src/__tests__/war-room.test.ts b/src/__tests__/war-room.test.ts new file mode 100644 index 0000000..5552d18 --- /dev/null +++ b/src/__tests__/war-room.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { join } from "path"; +import { mkdirSync, rmSync, readFileSync, existsSync } from "fs"; +import { tmpdir } from "os"; +import { + createWarRoomSession, + buildAgentPrompt, + parseTransferKibble, + formatEventsForPrompt, + appendEvent, +} from "../core/war-room.js"; +import { TRANSFER_KIBBLE_MARKER, STOP_MARKER } from "../core/constants.js"; +import type { WarRoomEvent, WarRoomAgent, AgentConfig } from "../core/types.js"; + +function makeAgent(overrides: Partial = {}): AgentConfig { + return { + id: "test", + name: "Test", + role: "Tester", + personality: "You are a test agent.", + kibble: 20, + ...overrides, + }; +} + +function makeWarRoomAgent(overrides: Partial = {}): WarRoomAgent { + return { + config: makeAgent(), + kibbleRemaining: 20, + toolsUsed: 0, + ...overrides, + }; +} + +describe("createWarRoomSession", () => { + it("creates a session with agents", () => { + const agents = [makeAgent({ id: "a" }), makeAgent({ id: "b" })]; + const session = createWarRoomSession("Do something", agents, 42); + + expect(session.task).toBe("Do something"); + expect(session.agents).toHaveLength(2); + expect(session.agents[0].kibbleRemaining).toBe(20); + expect(session.agents[1].kibbleRemaining).toBe(20); + expect(session.turn).toBe(0); + expect(session.maxTurns).toBe(30); + expect(session.issueNumber).toBe(42); + expect(session.id).toContain("war-room-"); + expect(session.events).toEqual([]); + }); + + it("creates session without issue number", () => { + const session = createWarRoomSession("task", [makeAgent()]); + expect(session.issueNumber).toBeUndefined(); + }); + + it("initializes kibble from agent config", () => { + const agents = [makeAgent({ kibble: 10 })]; + const session = createWarRoomSession("task", agents); + expect(session.agents[0].kibbleRemaining).toBe(10); + }); +}); + +describe("buildAgentPrompt", () => { + it("includes role, personality, task, and kibble info", () => { + const agent = makeWarRoomAgent({ + config: makeAgent({ name: "Storm", role: "Architect", personality: "Lead the team." }), + kibbleRemaining: 15, + }); + agent.config.kibble = 20; + + const prompt = buildAgentPrompt(agent, "Build a feature", []); + + expect(prompt).toContain("# Role: Storm (Architect)"); + expect(prompt).toContain("Lead the team."); + expect(prompt).toContain("# Task"); + expect(prompt).toContain("Build a feature"); + expect(prompt).toContain("15 kibble remaining out of 20 total"); + expect(prompt).toContain(TRANSFER_KIBBLE_MARKER); + expect(prompt).toContain(STOP_MARKER); + }); + + it("includes recent events when provided", () => { + const agent = makeWarRoomAgent(); + const events: WarRoomEvent[] = [ + { type: "system", message: "Started", timestamp: 1 }, + { type: "agent_end", agent: "Johnny", message: "Done coding", timestamp: 2 }, + ]; + + const prompt = buildAgentPrompt(agent, "task", events); + + expect(prompt).toContain("# Recent Events"); + expect(prompt).toContain("[system] Started"); + expect(prompt).toContain("[Johnny] Done coding"); + }); + + it("omits events section when no events", () => { + const agent = makeWarRoomAgent(); + const prompt = buildAgentPrompt(agent, "task", []); + expect(prompt).not.toContain("# Recent Events"); + }); +}); + +describe("parseTransferKibble", () => { + it("parses valid transfer marker", () => { + const output = `Some text ${TRANSFER_KIBBLE_MARKER}:5:Johnny%% more text`; + const transfer = parseTransferKibble(output); + + expect(transfer).not.toBeNull(); + expect(transfer!.to).toBe("Johnny"); + expect(transfer!.amount).toBe(5); + }); + + it("returns null when no marker present", () => { + expect(parseTransferKibble("just normal output")).toBeNull(); + }); + + it("handles agent names with spaces", () => { + const output = `${TRANSFER_KIBBLE_MARKER}:3:Alan Test%%`; + const transfer = parseTransferKibble(output); + expect(transfer).not.toBeNull(); + expect(transfer!.to).toBe("Alan Test"); + expect(transfer!.amount).toBe(3); + }); + + it("parses first match only", () => { + const output = `${TRANSFER_KIBBLE_MARKER}:2:A%% ${TRANSFER_KIBBLE_MARKER}:3:B%%`; + const transfer = parseTransferKibble(output); + expect(transfer!.to).toBe("A"); + expect(transfer!.amount).toBe(2); + }); +}); + +describe("formatEventsForPrompt", () => { + it("formats events with agent prefix", () => { + const events: WarRoomEvent[] = [ + { type: "system", message: "Started", timestamp: 1 }, + { type: "agent_end", agent: "Storm", message: "Done planning", timestamp: 2 }, + ]; + + const result = formatEventsForPrompt(events); + expect(result).toBe("[system] Started\n[Storm] Done planning"); + }); + + it("limits to maxEvents", () => { + const events: WarRoomEvent[] = Array.from({ length: 30 }, (_, i) => ({ + type: "system" as const, + message: `Event ${i}`, + timestamp: i, + })); + + const result = formatEventsForPrompt(events, 5); + const lines = result.split("\n"); + expect(lines).toHaveLength(5); + expect(lines[0]).toContain("Event 25"); + expect(lines[4]).toContain("Event 29"); + }); + + it("handles empty events", () => { + expect(formatEventsForPrompt([])).toBe(""); + }); +}); + +describe("appendEvent", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), `war-room-test-${Date.now()}`); + mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("appends JSONL to events file", () => { + const event1: WarRoomEvent = { type: "system", message: "First", timestamp: 1 }; + const event2: WarRoomEvent = { type: "agent_start", agent: "Storm", message: "Start", timestamp: 2 }; + + appendEvent(tempDir, event1); + appendEvent(tempDir, event2); + + const content = readFileSync(join(tempDir, "events.jsonl"), "utf-8"); + const lines = content.trim().split("\n"); + expect(lines).toHaveLength(2); + + const parsed1 = JSON.parse(lines[0]); + expect(parsed1.type).toBe("system"); + expect(parsed1.message).toBe("First"); + + const parsed2 = JSON.parse(lines[1]); + expect(parsed2.agent).toBe("Storm"); + }); +}); diff --git a/src/commands/war-room.ts b/src/commands/war-room.ts new file mode 100644 index 0000000..04bf34e --- /dev/null +++ b/src/commands/war-room.ts @@ -0,0 +1,119 @@ +import { existsSync } from "fs"; +import { join } from "path"; +import { loadConfig, validateConfig } from "../core/config.js"; +import { CONFIG_DIR } from "../core/constants.js"; +import { log } from "../core/output.js"; +import { fetchIssue } from "../core/github.js"; +import { loadWarRoomAgents } from "../primitives/war-room-agent.js"; +import { createWarRoomSession, runWarRoom } from "../core/war-room.js"; +import { createRenderer } from "../core/war-room-ui.js"; +import { checkoutBase, createBranch, commitAndPush, openPR } from "../core/pr.js"; +import type { WarRoomOptions } from "../core/types.js"; + +export async function warRoomCommand( + cwd: string, + options: WarRoomOptions +): Promise { + // Validate .storm/ exists + 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); + } + + // Resolve task + let task: string; + let issueNumber: number | undefined; + let issueTitle: string | undefined; + + if (options.issue) { + const issue = await fetchIssue(config.github.repo, options.issue); + task = `# Issue #${issue.number}: ${issue.title}\n\n${issue.body}`; + issueNumber = issue.number; + issueTitle = issue.title; + } else if (options.prompt) { + task = options.prompt; + } else { + log.error("Provide --issue or --prompt"); + process.exit(1); + } + + // Load agents + const agentConfigs = await loadWarRoomAgents(cwd, options.agents); + if (agentConfigs.length === 0) { + log.error("No agents found. Check --agents filter or .storm/agents/ directory."); + process.exit(1); + } + + // Create session + const session = createWarRoomSession(task, agentConfigs, issueNumber); + + // Dry run + if (options.dryRun) { + log.warn("Dry run — no agents will be spawned"); + log.info(`Task: ${task.slice(0, 200)}`); + log.info(`Agents (${agentConfigs.length}):`); + for (const a of agentConfigs) { + log.dim(` ${a.name} (${a.role}) — kibble: ${a.kibble}`); + } + return; + } + + // Handle SIGINT + const controller = new AbortController(); + process.on("SIGINT", () => { + log.warn("SIGINT received, aborting war room..."); + controller.abort(); + }); + + // Create branch if working on an issue + if (issueNumber && issueTitle) { + const slug = issueTitle + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 50); + const branch = `storm/war-room-${issueNumber}-${slug}`; + + if (!(await checkoutBase(config.github.baseBranch, cwd))) { + return; + } + if (!(await createBranch(branch, cwd))) { + return; + } + } + + // Choose renderer + const useUi = options.ui ?? (process.stdout.isTTY ?? false); + const renderer = createRenderer(useUi); + + // Run war room + const result = await runWarRoom(session, config, cwd, controller.signal, renderer); + + // Commit and push if on an issue + if (issueNumber && issueTitle) { + const slug = issueTitle + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 50); + const branch = `storm/war-room-${issueNumber}-${slug}`; + const fakeIssue = { number: issueNumber, title: issueTitle, body: "", labels: [], url: "" }; + + log.step("Committing and pushing..."); + const pushed = await commitAndPush(branch, fakeIssue, cwd); + if (!pushed) return; + + log.step("Creating pull request..."); + const prUrl = await openPR(config, fakeIssue, branch, cwd); + if (prUrl) { + log.success(`PR created: ${prUrl}`); + } + } +} diff --git a/src/core/constants.ts b/src/core/constants.ts index 70679ae..3fe8ca8 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -10,3 +10,30 @@ 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 MAX_WAR_ROOM_TURNS = 30; + +export const KIBBLE_TOOLS = new Set([ + "bash", + "computer", + "text_editor", + "file", + "Read", + "Write", + "Edit", + "Bash", + "Glob", + "Grep", + "WebFetch", + "WebSearch", +]); + +export const TRANSFER_KIBBLE_MARKER = "%%TRANSFER_KIBBLE"; diff --git a/src/core/types.ts b/src/core/types.ts index 9b86761..cc332d8 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -131,3 +131,75 @@ export interface GlobalProject { export interface GlobalConfig { projects: GlobalProject[]; } + +// War Room types + +export interface AgentConfig { + id: string; + name: string; + role: string; + personality: string; + kibble: number; + model?: string; +} + +export interface WarRoomAgent { + config: AgentConfig; + kibbleRemaining: number; + toolsUsed: number; +} + +export type WarRoomEventType = + | "system" + | "agent_start" + | "agent_end" + | "tool_use" + | "transfer" + | "error"; + +export interface WarRoomEvent { + type: WarRoomEventType; + agent?: string; + message: string; + timestamp: number; +} + +export interface KibbleTransfer { + from: string; + to: string; + amount: number; +} + +export interface WarRoomSession { + id: string; + task: string; + agents: WarRoomAgent[]; + events: WarRoomEvent[]; + turn: number; + maxTurns: number; + startTime: number; + issueNumber?: number; +} + +export interface WarRoomOptions { + issue?: number; + prompt?: string; + agents?: string[]; + dryRun?: boolean; + ui?: boolean; +} + +export interface WarRoomRenderer { + init(session: WarRoomSession): void; + destroy(): void; + onTurnStart(session: WarRoomSession, agent: WarRoomAgent): void; + onTurnEnd(session: WarRoomSession, agent: WarRoomAgent): void; + onToolUse(session: WarRoomSession, agent: WarRoomAgent, toolName: string): void; + onEvent(session: WarRoomSession, event: WarRoomEvent): void; + onTransfer(session: WarRoomSession, transfer: KibbleTransfer): void; + onDone(session: WarRoomSession, agent: WarRoomAgent): void; + onTimeout(session: WarRoomSession, agent: WarRoomAgent): void; + onAllKibbleExhausted(session: WarRoomSession): void; + onAbort(session: WarRoomSession): void; + onComplete(session: WarRoomSession): void; +} diff --git a/src/core/war-room-ui.ts b/src/core/war-room-ui.ts new file mode 100644 index 0000000..0a9e92f --- /dev/null +++ b/src/core/war-room-ui.ts @@ -0,0 +1,304 @@ +import pc from "picocolors"; +import { log, formatDuration } from "./output.js"; +import type { + WarRoomRenderer, + WarRoomSession, + WarRoomAgent, + WarRoomEvent, + KibbleTransfer, +} from "./types.js"; + +export function truncate(str: string, maxLen: number): string { + if (str.length <= maxLen) return str; + return str.slice(0, maxLen - 1) + "…"; +} + +export function renderKibbleBar(remaining: number, total: number, width = 5): string { + if (total <= 0) return "░".repeat(width); + const filled = Math.round((remaining / total) * width); + return "█".repeat(filled) + "░".repeat(width - filled); +} + +// --- PlainRenderer --- + +export class PlainRenderer implements WarRoomRenderer { + init(session: WarRoomSession): void { + log.info(`War room started — ${session.agents.length} agent(s), max ${session.maxTurns} turns`); + for (const agent of session.agents) { + log.dim(` ${agent.config.name} (${agent.config.role}) — kibble: ${agent.kibbleRemaining}`); + } + } + + destroy(): void {} + + onTurnStart(session: WarRoomSession, agent: WarRoomAgent): void { + log.step(`Turn ${session.turn}/${session.maxTurns} — ${agent.config.name} (${agent.config.role})`); + } + + onTurnEnd(_session: WarRoomSession, agent: WarRoomAgent): void { + log.dim(` ${agent.config.name}: kibble ${agent.kibbleRemaining}/${agent.config.kibble}, tools: ${agent.toolsUsed}`); + } + + onToolUse(_session: WarRoomSession, agent: WarRoomAgent, toolName: string): void { + log.dim(` [${agent.config.name}] tool: ${toolName}`); + } + + onEvent(_session: WarRoomSession, event: WarRoomEvent): void { + const prefix = event.agent ? `[${event.agent}]` : "[system]"; + log.dim(` ${prefix} ${event.message}`); + } + + onTransfer(_session: WarRoomSession, transfer: KibbleTransfer): void { + log.info(`Kibble transfer: ${transfer.from} → ${transfer.to} (${transfer.amount})`); + } + + onDone(_session: WarRoomSession, agent: WarRoomAgent): void { + log.success(`${agent.config.name} signaled done`); + } + + onTimeout(_session: WarRoomSession, agent: WarRoomAgent): void { + log.warn(`${agent.config.name} timed out`); + } + + onAllKibbleExhausted(session: WarRoomSession): void { + log.warn(`All agents exhausted their kibble after ${session.turn} turns`); + } + + onAbort(session: WarRoomSession): void { + log.warn(`War room aborted at turn ${session.turn}`); + } + + onComplete(session: WarRoomSession): void { + const elapsed = formatDuration(Date.now() - session.startTime); + log.success(`War room complete — ${session.turn} turns, ${elapsed}`); + } +} + +// --- TuiRenderer --- + +const LEFT_PANEL_WIDTH = 28; +const MIN_COLS = 60; +const MIN_ROWS = 10; +const STATUS_BAR_HEIGHT = 2; + +export class TuiRenderer implements WarRoomRenderer { + private events: WarRoomEvent[] = []; + private session: WarRoomSession | null = null; + private activeAgent: WarRoomAgent | null = null; + private lastTool = ""; + private fallback: PlainRenderer | null = null; + private resizeHandler: (() => void) | null = null; + private cols = 0; + private rows = 0; + + init(session: WarRoomSession): void { + this.cols = process.stdout.columns ?? 80; + this.rows = process.stdout.rows ?? 24; + + if (this.cols < MIN_COLS || this.rows < MIN_ROWS) { + this.fallback = new PlainRenderer(); + this.fallback.init(session); + return; + } + + this.session = session; + this.events = []; + + // Enter alternate screen, hide cursor + process.stdout.write("\x1b[?1049h\x1b[?25l"); + + this.resizeHandler = () => { + this.cols = process.stdout.columns ?? 80; + this.rows = process.stdout.rows ?? 24; + if (this.session) this.render(); + }; + process.stdout.on("resize", this.resizeHandler); + + this.addEvent({ type: "system", message: "War room started", timestamp: Date.now() }); + this.render(); + } + + destroy(): void { + if (this.fallback) { + this.fallback.destroy(); + return; + } + + if (this.resizeHandler) { + process.stdout.off("resize", this.resizeHandler); + this.resizeHandler = null; + } + + // Leave alternate screen, show cursor + process.stdout.write("\x1b[?1049l\x1b[?25h"); + + // Print summary + if (this.session) { + const elapsed = formatDuration(Date.now() - this.session.startTime); + log.info(`War room finished — ${this.session.turn} turns, ${elapsed}`); + for (const agent of this.session.agents) { + log.dim(` ${agent.config.name}: kibble ${agent.kibbleRemaining}/${agent.config.kibble}, tools: ${agent.toolsUsed}`); + } + } + } + + onTurnStart(session: WarRoomSession, agent: WarRoomAgent): void { + if (this.fallback) { this.fallback.onTurnStart(session, agent); return; } + this.session = session; + this.activeAgent = agent; + this.lastTool = ""; + this.addEvent({ type: "agent_start", agent: agent.config.name, message: `Starting turn ${session.turn}`, timestamp: Date.now() }); + this.render(); + } + + onTurnEnd(session: WarRoomSession, agent: WarRoomAgent): void { + if (this.fallback) { this.fallback.onTurnEnd(session, agent); return; } + this.session = session; + this.render(); + } + + onToolUse(session: WarRoomSession, agent: WarRoomAgent, toolName: string): void { + if (this.fallback) { this.fallback.onToolUse(session, agent, toolName); return; } + this.session = session; + this.lastTool = toolName; + this.addEvent({ type: "tool_use", agent: agent.config.name, message: `[tool] ${toolName}`, timestamp: Date.now() }); + this.render(); + } + + onEvent(session: WarRoomSession, event: WarRoomEvent): void { + if (this.fallback) { this.fallback.onEvent(session, event); return; } + this.session = session; + this.addEvent(event); + this.render(); + } + + onTransfer(session: WarRoomSession, transfer: KibbleTransfer): void { + if (this.fallback) { this.fallback.onTransfer(session, transfer); return; } + this.session = session; + this.addEvent({ type: "transfer", message: `Kibble: ${transfer.from} → ${transfer.to} (${transfer.amount})`, timestamp: Date.now() }); + this.render(); + } + + onDone(session: WarRoomSession, agent: WarRoomAgent): void { + if (this.fallback) { this.fallback.onDone(session, agent); return; } + this.session = session; + this.addEvent({ type: "system", agent: agent.config.name, message: "Signaled done", timestamp: Date.now() }); + this.render(); + } + + onTimeout(session: WarRoomSession, agent: WarRoomAgent): void { + if (this.fallback) { this.fallback.onTimeout(session, agent); return; } + this.session = session; + this.addEvent({ type: "error", agent: agent.config.name, message: "Timed out", timestamp: Date.now() }); + this.render(); + } + + onAllKibbleExhausted(session: WarRoomSession): void { + if (this.fallback) { this.fallback.onAllKibbleExhausted(session); return; } + this.session = session; + this.addEvent({ type: "system", message: "All agents exhausted their kibble", timestamp: Date.now() }); + this.render(); + } + + onAbort(session: WarRoomSession): void { + if (this.fallback) { this.fallback.onAbort(session); return; } + this.session = session; + this.addEvent({ type: "system", message: "Aborted", timestamp: Date.now() }); + this.render(); + } + + onComplete(session: WarRoomSession): void { + if (this.fallback) { this.fallback.onComplete(session); return; } + this.session = session; + this.addEvent({ type: "system", message: "Complete", timestamp: Date.now() }); + this.render(); + } + + private addEvent(event: WarRoomEvent): void { + this.events.push(event); + } + + private render(): void { + if (!this.session) return; + + const { cols, rows } = this; + const rightWidth = cols - LEFT_PANEL_WIDTH - 1; // -1 for separator + const contentHeight = rows - STATUS_BAR_HEIGHT - 2; // -2 for top/bottom borders + + let output = ""; + + // Move cursor to top-left + output += "\x1b[H"; + + // Top border + output += pc.dim("┌─ Agents ") + pc.dim("─".repeat(LEFT_PANEL_WIDTH - 10)) + pc.dim("┬─ Events ") + pc.dim("─".repeat(Math.max(0, rightWidth - 10))) + pc.dim("┐") + "\n"; + + // Content rows + const agentLines = this.buildAgentPanel(contentHeight); + const eventLines = this.buildEventPanel(contentHeight, rightWidth); + + for (let i = 0; i < contentHeight; i++) { + const left = (agentLines[i] ?? "").padEnd(LEFT_PANEL_WIDTH); + const right = (eventLines[i] ?? "").padEnd(rightWidth); + output += pc.dim("│") + " " + truncate(left, LEFT_PANEL_WIDTH - 1) + pc.dim("│") + " " + truncate(right, rightWidth - 1) + pc.dim("│") + "\n"; + } + + // Status bar separator + output += pc.dim("├─ Status ") + pc.dim("─".repeat(LEFT_PANEL_WIDTH - 10)) + pc.dim("┴") + pc.dim("─".repeat(Math.max(0, rightWidth))) + pc.dim("┤") + "\n"; + + // Status bar content + const elapsed = formatDuration(Date.now() - this.session.startTime); + const agentName = this.activeAgent ? `${this.activeAgent.config.name} (${this.activeAgent.config.role})` : "—"; + const toolInfo = this.lastTool ? `[tool] ${this.lastTool}` : ""; + const statusText = `Turn ${this.session.turn}/${this.session.maxTurns} | ${agentName} | ${toolInfo} | elapsed: ${elapsed}`; + const statusPadded = truncate(statusText, cols - 4).padEnd(cols - 4); + output += pc.dim("│") + " " + pc.cyan(statusPadded) + " " + pc.dim("│") + "\n"; + + // Bottom border + output += pc.dim("└") + pc.dim("─".repeat(cols - 2)) + pc.dim("┘"); + + process.stdout.write(output); + } + + private buildAgentPanel(height: number): string[] { + if (!this.session) return []; + const lines: string[] = []; + + for (const agent of this.session.agents) { + if (lines.length >= height) break; + const isActive = this.activeAgent?.config.id === agent.config.id; + const prefix = isActive ? pc.green("> ") : " "; + const name = isActive ? pc.bold(agent.config.name) : agent.config.name; + lines.push(`${prefix}${name} (${agent.config.role})`); + + if (lines.length >= height) break; + const bar = renderKibbleBar(agent.kibbleRemaining, agent.config.kibble); + const color = agent.kibbleRemaining > 0 ? pc.green : pc.red; + lines.push(` kibble: ${agent.kibbleRemaining}/${agent.config.kibble} ${color(bar)}`); + + if (lines.length >= height) break; + lines.push(` tools: ${agent.toolsUsed}`); + + if (lines.length >= height) break; + lines.push(""); + } + + return lines; + } + + private buildEventPanel(height: number, _width: number): string[] { + // Show the last N events that fit + const visible = this.events.slice(-height); + return visible.map((e) => { + const prefix = e.agent ? pc.yellow(`[${e.agent}]`) : pc.blue("[system]"); + return `${prefix} ${e.message}`; + }); + } +} + +export function createRenderer(ui: boolean): WarRoomRenderer { + if (ui && process.stdout.isTTY) { + return new TuiRenderer(); + } + return new PlainRenderer(); +} diff --git a/src/core/war-room.ts b/src/core/war-room.ts new file mode 100644 index 0000000..aae76b4 --- /dev/null +++ b/src/core/war-room.ts @@ -0,0 +1,314 @@ +import { join } from "path"; +import { mkdirSync, appendFileSync, existsSync } from "fs"; +import type { + StormConfig, + AgentConfig, + WarRoomAgent, + WarRoomEvent, + WarRoomSession, + WarRoomRenderer, + KibbleTransfer, +} from "./types.js"; +import { + CONFIG_DIR, + SESSIONS_DIR, + EVENTS_FILE, + STOP_MARKER, + KIBBLE_TOOLS, + TRANSFER_KIBBLE_MARKER, + MAX_WAR_ROOM_TURNS, +} from "./constants.js"; +import { log, formatDuration } from "./output.js"; +import { PlainRenderer } from "./war-room-ui.js"; + +export function createWarRoomSession( + task: string, + agentConfigs: AgentConfig[], + issueNumber?: number +): WarRoomSession { + const agents: WarRoomAgent[] = agentConfigs.map((config) => ({ + config, + kibbleRemaining: config.kibble, + toolsUsed: 0, + })); + + return { + id: `war-room-${Date.now()}`, + task, + agents, + events: [], + turn: 0, + maxTurns: MAX_WAR_ROOM_TURNS, + startTime: Date.now(), + issueNumber, + }; +} + +export function buildAgentPrompt( + agent: WarRoomAgent, + task: string, + events: WarRoomEvent[] +): string { + const lines: string[] = []; + + lines.push(`# Role: ${agent.config.name} (${agent.config.role})`); + lines.push(""); + lines.push(agent.config.personality); + lines.push(""); + lines.push("# Task"); + lines.push(task); + lines.push(""); + lines.push("# Kibble Budget"); + lines.push(`You have ${agent.kibbleRemaining} kibble remaining out of ${agent.config.kibble} total.`); + lines.push("Each tool use that modifies or reads files costs 1 kibble."); + lines.push("When you run out of kibble, your turn ends."); + lines.push(""); + lines.push("# Collaboration"); + lines.push("You can transfer kibble to another agent by outputting:"); + lines.push(`${TRANSFER_KIBBLE_MARKER}:{amount}:{agent_name}%%`); + lines.push(""); + lines.push(`When you have completed your part of the work, output ${STOP_MARKER} on its own line.`); + + if (events.length > 0) { + lines.push(""); + lines.push("# Recent Events"); + lines.push(formatEventsForPrompt(events)); + } + + return lines.join("\n"); +} + +export function parseTransferKibble(output: string): KibbleTransfer | null { + const regex = new RegExp( + `${escapeRegex(TRANSFER_KIBBLE_MARKER)}:(\\d+):([^%]+)%%` + ); + const match = output.match(regex); + if (!match) return null; + + return { + from: "", // caller fills this in + to: match[2].trim(), + amount: parseInt(match[1], 10), + }; +} + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function formatEventsForPrompt(events: WarRoomEvent[], maxEvents = 20): string { + const recent = events.slice(-maxEvents); + return recent + .map((e) => { + const prefix = e.agent ? `[${e.agent}]` : "[system]"; + return `${prefix} ${e.message}`; + }) + .join("\n"); +} + +export function appendEvent(sessionDir: string, event: WarRoomEvent): void { + const eventsPath = join(sessionDir, EVENTS_FILE); + appendFileSync(eventsPath, JSON.stringify(event) + "\n"); +} + +export async function spawnWarRoomAgent( + prompt: string, + config: StormConfig, + agent: WarRoomAgent, + cwd: string, + renderer?: WarRoomRenderer, + session?: WarRoomSession +): Promise<{ output: string; done: boolean; timedOut: boolean }> { + const args = [ + ...config.agent.args, + "--verbose", + "--output-format", + "stream-json", + "--model", + agent.config.model ?? config.agent.model, + ]; + + 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(); + }, 300_000); + + let output = ""; + const reader = proc.stdout.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + 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 || ""; + } else if (msg.type === "assistant" && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === "tool_use") { + if (KIBBLE_TOOLS.has(block.name)) { + agent.kibbleRemaining = Math.max(0, agent.kibbleRemaining - 1); + } + agent.toolsUsed++; + if (renderer && session) { + renderer.onToolUse(session, agent, block.name); + } + } + } + } + } catch { + // Not JSON, skip + } + } + } + } finally { + reader.releaseLock(); + clearTimeout(timer); + } + + await proc.exited; + + const done = output.includes(STOP_MARKER); + return { output, done, timedOut }; +} + +export async function runWarRoom( + session: WarRoomSession, + config: StormConfig, + cwd: string, + signal?: AbortSignal, + renderer?: WarRoomRenderer +): Promise { + const ren = renderer ?? new PlainRenderer(); + const sessionDir = join(cwd, CONFIG_DIR, SESSIONS_DIR, session.id); + + if (!existsSync(sessionDir)) { + mkdirSync(sessionDir, { recursive: true }); + } + + ren.init(session); + + const systemEvent: WarRoomEvent = { + type: "system", + message: `War room started: ${session.task.slice(0, 100)}`, + timestamp: Date.now(), + }; + session.events.push(systemEvent); + appendEvent(sessionDir, systemEvent); + ren.onEvent(session, systemEvent); + + try { + for (let turn = 1; turn <= session.maxTurns; turn++) { + if (signal?.aborted) { + ren.onAbort(session); + break; + } + + // Find next agent with kibble (round-robin) + const agentIndex = (turn - 1) % session.agents.length; + const agent = session.agents[agentIndex]; + + if (agent.kibbleRemaining <= 0) { + // Check if ALL agents are out of kibble + const anyAlive = session.agents.some((a) => a.kibbleRemaining > 0); + if (!anyAlive) { + ren.onAllKibbleExhausted(session); + break; + } + // Skip this agent, but still count the turn + continue; + } + + session.turn = turn; + ren.onTurnStart(session, agent); + + const startEvent: WarRoomEvent = { + type: "agent_start", + agent: agent.config.name, + message: `Starting turn ${turn}`, + timestamp: Date.now(), + }; + session.events.push(startEvent); + appendEvent(sessionDir, startEvent); + + // Build prompt and spawn + const prompt = buildAgentPrompt(agent, session.task, session.events); + const result = await spawnWarRoomAgent(prompt, config, agent, cwd, ren, session); + + // Check for kibble transfer + const transfer = parseTransferKibble(result.output); + if (transfer) { + transfer.from = agent.config.name; + const target = session.agents.find( + (a) => a.config.name.toLowerCase() === transfer.to.toLowerCase() + ); + if (target) { + const actual = Math.min(transfer.amount, agent.kibbleRemaining); + agent.kibbleRemaining -= actual; + target.kibbleRemaining += actual; + transfer.amount = actual; + ren.onTransfer(session, transfer); + + const transferEvent: WarRoomEvent = { + type: "transfer", + agent: agent.config.name, + message: `Transferred ${actual} kibble to ${target.config.name}`, + timestamp: Date.now(), + }; + session.events.push(transferEvent); + appendEvent(sessionDir, transferEvent); + } + } + + // Record end event + const endEvent: WarRoomEvent = { + type: "agent_end", + agent: agent.config.name, + message: result.done + ? "Signaled done" + : `Finished turn (kibble: ${agent.kibbleRemaining})`, + timestamp: Date.now(), + }; + session.events.push(endEvent); + appendEvent(sessionDir, endEvent); + + ren.onTurnEnd(session, agent); + + if (result.timedOut) { + ren.onTimeout(session, agent); + } + + if (result.done) { + ren.onDone(session, agent); + break; + } + } + + ren.onComplete(session); + } finally { + ren.destroy(); + } + + return session; +} diff --git a/src/primitives/war-room-agent.ts b/src/primitives/war-room-agent.ts new file mode 100644 index 0000000..908f1d7 --- /dev/null +++ b/src/primitives/war-room-agent.ts @@ -0,0 +1,76 @@ +import { join } from "path"; +import { existsSync } from "fs"; +import { readdir } from "fs/promises"; +import matter from "gray-matter"; +import { CONFIG_DIR, AGENTS_DIR, AGENT_FILE, DEFAULT_KIBBLE } from "../core/constants.js"; +import type { AgentConfig } from "../core/types.js"; + +export const DEFAULT_AGENTS: AgentConfig[] = [ + { + id: "storm", + name: "Storm", + role: "Architect", + personality: + "You are the lead architect. Analyze the task, break it into subtasks, and coordinate the approach. Focus on high-level design decisions, file structure, and interfaces. Delegate implementation details to other agents.", + kibble: DEFAULT_KIBBLE, + }, + { + id: "johnny", + name: "Johnny", + role: "Engineer", + personality: + "You are the implementation engineer. Write clean, working code based on the architect's plan. Focus on correctness, readability, and following project conventions. Implement features and fix bugs.", + kibble: DEFAULT_KIBBLE, + }, + { + id: "alan", + name: "Alan", + role: "QA", + personality: + "You are the QA engineer. Review the code written so far, run tests, and identify bugs or issues. Write tests where needed. Focus on edge cases, error handling, and ensuring the implementation matches requirements.", + kibble: DEFAULT_KIBBLE, + }, +]; + +export async function loadWarRoomAgents( + cwd: string, + agentIds?: string[] +): Promise { + const agentsDir = join(cwd, CONFIG_DIR, AGENTS_DIR); + + let agents: AgentConfig[]; + + if (existsSync(agentsDir)) { + const entries = await readdir(agentsDir, { withFileTypes: true }); + const dirs = entries.filter((e) => e.isDirectory()); + + const custom: AgentConfig[] = []; + for (const dir of dirs) { + const agentFile = join(agentsDir, dir.name, AGENT_FILE); + if (!existsSync(agentFile)) continue; + + const content = await Bun.file(agentFile).text(); + const { data, content: body } = matter(content); + + custom.push({ + id: dir.name, + name: (data.name as string) || dir.name, + role: (data.role as string) || "Agent", + personality: body.trim(), + kibble: (data.kibble as number) ?? DEFAULT_KIBBLE, + model: data.model as string | undefined, + }); + } + + agents = custom.length > 0 ? custom : DEFAULT_AGENTS; + } else { + agents = DEFAULT_AGENTS; + } + + if (agentIds && agentIds.length > 0) { + const ids = new Set(agentIds.map((id) => id.toLowerCase())); + agents = agents.filter((a) => ids.has(a.id.toLowerCase())); + } + + return agents; +}