#!/usr/bin/env node /** * Hive CLI — standalone entry point for use outside Pi. * * Pure commands (status, next, doctor) need zero dependencies. * LLM commands delegate to `pi` in print mode. * * Usage: * hive status * hive next * hive setup * hive doctor * hive spec [description] * hive map-codebase * hive to-features-md * hive run * hive q * hive worktree-split --count 3 [--fork [warp|tmux]] [--sandbox] [--auto-approve] [--features "1,2;3,4"] * hive worktree-merge [worktree] */ import * as fs from "node:fs"; import * as path from "node:path"; import * as os from "node:os"; import * as readline from "node:readline"; import { execFileSync } from "node:child_process"; import { parseFeaturesMd, getStats, progressBar, validateFeaturesMd } from "../extensions/hive/lib/features-parser.js"; import { runAgent, runChain, type ChainStep, type RunOptions, detectProvider, applyHiveConfig, loadHiveConfig, saveHiveConfig } from "../extensions/hive/lib/agents.js"; import { SPEC_SCOUT_TASK, specPlannerTask, SPEC_REVIEWER_TASK, MAP_CODEBASE_TASK, TO_FEATURES_MD_TASK, RUN_PROMPT, qPrompt, worktreeSplitPrompt, generatePromptMd, generateWarpYaml, generateTmuxCommands, type WorktreePromptData, type WarpWorktree, } from "../extensions/hive/lib/prompts.js"; import { createWorktree, getProjectName, listWorktrees, getCurrentBranch, testMerge, executeMerge, removeWorktree, deleteBranch, getDiffStats, hasUncommittedChanges, commitAll, } from "../extensions/hive/lib/git-worktree.js"; // ─── Terminal helpers ────────────────────────────────────────────────── const CWD = process.cwd(); const FEATURES_PATH = path.join(CWD, "features.md"); const c = { reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m", red: "\x1b[31m", green: "\x1b[32m", yellow: "\x1b[33m", cyan: "\x1b[36m", }; function die(msg: string, code = 1): never { console.error(`${c.red}Error:${c.reset} ${msg}`); process.exit(code); } function info(msg: string) { console.log(`${c.cyan}▸${c.reset} ${msg}`); } function success(msg: string) { console.log(`${c.green}✓${c.reset} ${msg}`); } function warn(msg: string) { console.log(`${c.yellow}⚠${c.reset} ${msg}`); } function check(ok: boolean, label: string, detail?: string) { const icon = ok ? `${c.green}✓${c.reset}` : `${c.red}✗${c.reset}`; const extra = detail ? ` ${c.dim}${detail}${c.reset}` : ""; console.log(` ${icon} ${label}${extra}`); } function ask(prompt: string): Promise { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); return new Promise((resolve) => rl.question(prompt, (a) => { rl.close(); resolve(a); })); } function requireFeaturesMd(): string { if (!fs.existsSync(FEATURES_PATH)) { die("features.md not found. Run `hive spec` first."); } return fs.readFileSync(FEATURES_PATH, "utf-8"); } function getProjectNameFromCwd(): string { try { const pkg = JSON.parse(fs.readFileSync(path.join(CWD, "package.json"), "utf-8")); if (pkg.name) return pkg.name; } catch { /* ignore */ } return path.basename(CWD); } function piAvailable(): boolean { try { execFileSync("pi", ["--version"], { stdio: "ignore" }); return true; } catch { return false; } } function gitAvailable(): boolean { try { execFileSync("git", ["--version"], { stdio: "ignore" }); return true; } catch { return false; } } function nodeVersion(): string { return process.version; // e.g. "v22.13.1" } function nodeVersionOk(): boolean { const major = parseInt(process.version.slice(1).split(".")[0], 10); return major >= 18; } // ─── API Key / Provider ──────────────────────────────────────────────── const SETUP_GUIDE = ` ${c.yellow}No API key found.${c.reset} Hive needs one to use AI commands. Pick a provider and set the key: ${c.cyan}# Anthropic (recommended)${c.reset} export ANTHROPIC_API_KEY=sk-ant-... ${c.cyan}# OpenAI${c.reset} export OPENAI_API_KEY=sk-... ${c.cyan}# Google Gemini${c.reset} export GOOGLE_API_KEY=AI... ${c.cyan}# Local model (free, no key needed)${c.reset} ${c.cyan}# Requires Ollama: https://ollama.com${c.reset} export OLLAMA_HOST=http://localhost:11434 Add it to your shell profile (~/.zshrc or ~/.bashrc) to persist. Or run ${c.bold}hive setup${c.reset} to configure interactively. `; /** * Ensure an API key is available. Shows setup guide and exits if not. */ function requireProvider(): RunOptions { applyHiveConfig(); const provider = detectProvider(); if (!provider) { console.error(SETUP_GUIDE); process.exit(1); } return provider.model ? { model: provider.model } : {}; } /** * Ensure Pi is installed. Show install command if not. */ function requirePi(): void { if (!piAvailable()) { die("Pi is required for this command but was not found in PATH.\nInstall: npm install -g @mariozechner/pi-coding-agent"); } } /** Extract features.md content from LLM output */ function extractFeaturesMd(output: string): string | null { const fenceMatch = output.match(/```(?:markdown)?\s*\n(# Features[\s\S]*?)```/); if (fenceMatch) return fenceMatch[1].trim() + "\n"; const rawMatch = output.match(/(# Features[\s\S]*## Feature \d+:[\s\S]*)/); if (rawMatch) { let content = rawMatch[1]; const lastFeature = content.lastIndexOf("## Feature "); if (lastFeature >= 0) { const afterLast = content.indexOf("\n\n\n", lastFeature); if (afterLast >= 0) content = content.slice(0, afterLast); } return content.trim() + "\n"; } return null; } // ─── hive setup ──────────────────────────────────────────────────────── async function cmdSetup() { console.log(`\n${c.bold}Hive Setup${c.reset}\n`); const existing = detectProvider(); if (existing) { console.log(` Current provider: ${c.green}${existing.name}${c.reset} (${existing.envVar})\n`); const cont = await ask("Reconfigure? [y/N] "); if (cont.toLowerCase() !== "y") return; console.log(); } console.log(" 1. Anthropic (Claude) — recommended"); console.log(" 2. OpenAI (GPT)"); console.log(" 3. Google (Gemini)"); console.log(" 4. Ollama (local, free)"); console.log(); const choice = await ask("Pick a provider [1-4]: "); const config = loadHiveConfig(); switch (choice.trim()) { case "1": { const key = await ask("\nAnthropic API key (sk-ant-...): "); if (!key.trim()) { warn("No key entered. Aborted."); return; } config.ANTHROPIC_API_KEY = key.trim(); saveHiveConfig(config); success("Anthropic API key saved to ~/.hive/config.json"); break; } case "2": { const key = await ask("\nOpenAI API key (sk-...): "); if (!key.trim()) { warn("No key entered. Aborted."); return; } config.OPENAI_API_KEY = key.trim(); saveHiveConfig(config); success("OpenAI API key saved to ~/.hive/config.json"); break; } case "3": { const key = await ask("\nGoogle API key (AI...): "); if (!key.trim()) { warn("No key entered. Aborted."); return; } config.GOOGLE_API_KEY = key.trim(); saveHiveConfig(config); success("Google API key saved to ~/.hive/config.json"); break; } case "4": { let host = await ask("\nOllama host [http://localhost:11434]: "); host = host.trim() || "http://localhost:11434"; config.OLLAMA_HOST = host; saveHiveConfig(config); success(`Ollama configured (${host}) → saved to ~/.hive/config.json`); console.log(`\n Make sure Ollama is running: ${c.dim}ollama serve${c.reset}`); console.log(` And pull a model: ${c.dim}ollama pull llama3.1${c.reset}`); break; } default: warn("Invalid choice. Aborted."); return; } console.log(`\nTry it: ${c.bold}hive doctor${c.reset}\n`); } // ─── hive doctor ─────────────────────────────────────────────────────── async function cmdDoctor() { console.log(`\n${c.bold}Hive Doctor${c.reset}\n`); // Node.js check(nodeVersionOk(), `Node.js ${nodeVersion()}`, nodeVersionOk() ? "≥ 18 ✓" : "need ≥ 18"); // Git const git = gitAvailable(); check(git, "git", git ? "" : "not found — install git"); // Pi const pi = piAvailable(); check(pi, "pi (coding agent)", pi ? "" : "npm install -g @mariozechner/pi-coding-agent"); // API key applyHiveConfig(); const provider = detectProvider(); if (provider) { let detail = `${provider.name}`; if (provider.model) detail += ` → ${provider.model}`; // For Ollama, check if the server is reachable if (provider.envVar === "OLLAMA_HOST") { let ollamaOk = false; try { const resp = await fetch(`${process.env.OLLAMA_HOST}/api/version`); ollamaOk = resp.ok; } catch { /* not reachable */ } check(ollamaOk, `AI provider: ${detail}`, ollamaOk ? "" : "Ollama not reachable — run: ollama serve"); } else { check(true, `AI provider: ${detail}`); } } else { check(false, "AI provider", "no API key — run: hive setup"); } // .hive/ and features.md (shown together with contextual hints) const hasHive = fs.existsSync(path.join(CWD, ".hive")); const hasHiveMap = hasHive && fs.existsSync(path.join(CWD, ".hive", "codebase-map.json")); const hasFeatures = fs.existsSync(FEATURES_PATH); if (hasHive) { check(true, ".hive/", hasHiveMap ? "codebase analysis found" : "directory exists (no map)"); } else { check(false, ".hive/", "not found"); } if (hasFeatures) { const content = fs.readFileSync(FEATURES_PATH, "utf-8"); const features = parseFeaturesMd(content); const stats = getStats(features); check(true, `features.md`, `${features.length} features (${stats.done.length} done, ${stats.pending.length} ready)`); } else { check(false, "features.md", "not found"); if (hasHiveMap) { // Analysis exists but no features — just needs conversion console.log(`\n Analysis exists! Convert it:`); console.log(` ${c.cyan}hive to-features-md${c.reset}`); } else if (hasHive) { // .hive/ exists but no map — need to map first console.log(`\n Start here:`); console.log(` ${c.cyan}hive spec "describe your project"${c.reset}`); } else { // Nothing exists console.log(`\n Quick start (1 command):`); console.log(` ${c.cyan}hive spec "describe your project"${c.reset}`); console.log(`\n Step by step (more control):`); console.log(` ${c.cyan}hive map-codebase${c.reset} ${c.dim}# analyzes codebase → .hive/${c.reset}`); console.log(` ${c.cyan}hive to-features-md${c.reset} ${c.dim}# converts analysis → features.md${c.reset}`); } console.log(); } // Config file const configPath = path.join(os.homedir(), ".hive", "config.json"); const hasConfig = fs.existsSync(configPath); check(hasConfig, "~/.hive/config.json", hasConfig ? "" : "optional — run: hive setup"); console.log(); } // ─── Pure Commands ───────────────────────────────────────────────────── function cmdStatus() { const content = requireFeaturesMd(); const features = parseFeaturesMd(content); const stats = getStats(features); const name = getProjectNameFromCwd(); console.log(`\nHive Status: ${c.bold}${name}${c.reset}`); console.log("=".repeat(30)); console.log(`\nProgress: ${progressBar(stats.done.length, stats.total)}`); console.log(` Done: ${stats.done.length}`); console.log(` In Progress: ${stats.inProgress.length}`); console.log(` Ready: ${stats.pending.length}`); console.log(` Blocked: ${stats.blocked.length}`); if (stats.pending.length > 0) { console.log(`\n${c.green}Ready (next up):${c.reset}`); for (const f of stats.pending) console.log(` Feature ${f.number}: ${f.title}`); } if (stats.inProgress.length > 0) { console.log(`\n${c.yellow}In Progress:${c.reset}`); for (const f of stats.inProgress) console.log(` Feature ${f.number}: ${f.title}`); } if (stats.blocked.length > 0) { console.log(`\n${c.dim}Blocked:${c.reset}`); for (const f of stats.blocked) console.log(` Feature ${f.number}: ${f.title} (needs: ${f.dependencies})`); } if (stats.done.length === stats.total && stats.total > 0) { console.log(`\n${c.green}All features complete! 🎉${c.reset}`); } console.log(); } function cmdNext() { const content = requireFeaturesMd(); const features = parseFeaturesMd(content); const stats = getStats(features); if (stats.pending.length === 0) { if (stats.inProgress.length > 0) info(`No ready features. ${stats.inProgress.length} in progress.`); else if (stats.done.length === stats.total) success("All features complete! 🎉"); else warn(`${stats.blocked.length} features blocked by dependencies.`); return; } const next = stats.pending[0]; console.log(`\n${c.bold}Next: Feature ${next.number}: ${next.title}${c.reset}`); console.log(`\n${next.description}`); console.log(`\nDependencies: ${next.dependencies}`); console.log(); } // ─── LLM Commands (delegate to Pi) ──────────────────────────────────── async function cmdSpec(description?: string) { const opts = requireProvider(); requirePi(); // Detect scenario const hasFeatures = fs.existsSync(FEATURES_PATH); const hasAnalysis = fs.existsSync(path.join(CWD, ".hive", "codebase-map.json")); const hasSrc = ["src", "lib", "app", "pkg", "cmd", "internal"].some((d) => fs.existsSync(path.join(CWD, d)), ); if (hasFeatures) { const content = fs.readFileSync(FEATURES_PATH, "utf-8"); const features = parseFeaturesMd(content); console.log(`\nfeatures.md already exists with ${features.length} features.`); console.log("To recreate, remove it first: mv features.md features.md.backup\n"); return; } let steps: ChainStep[]; if (hasAnalysis) { info("Found .hive/ analysis. Running planner → reviewer..."); steps = [ { agent: "planner", task: specPlannerTask(description).replace("{previous}", "(read from .hive/ files)") }, { agent: "reviewer", task: SPEC_REVIEWER_TASK }, ]; } else if (hasSrc || description) { info("Running scout → planner → reviewer chain..."); steps = [ { agent: "scout", task: SPEC_SCOUT_TASK }, { agent: "planner", task: specPlannerTask(description) }, { agent: "reviewer", task: SPEC_REVIEWER_TASK }, ]; } else if (description) { info("New project — generating from description..."); steps = [ { agent: "planner", task: specPlannerTask(description).replace("{previous}", description) }, { agent: "reviewer", task: SPEC_REVIEWER_TASK }, ]; } else { die("No source code found and no description provided.\nUsage: hive spec \"Describe your project here\""); } const result = await runChain(steps, CWD, opts, (step, total, agent, status) => { info(`[${step}/${total}] ${agent}: ${status}`); }); if (result.exitCode !== 0) { die(`Chain failed at ${result.agent}: ${result.output || result.stderr}`); } const featuresContent = extractFeaturesMd(result.output); if (featuresContent) { fs.writeFileSync(FEATURES_PATH, featuresContent); const count = parseFeaturesMd(featuresContent).length; success(`features.md created with ${count} features.`); console.log(`\nNext steps:\n hive status\n hive run\n hive worktree-split --count 3\n`); } else { warn("Could not extract features.md from agent output."); console.log("\nRaw output (first 2000 chars):\n"); console.log(result.output.slice(0, 2000)); } } async function cmdMapCodebase() { const opts = requireProvider(); requirePi(); const hiveDir = path.join(CWD, ".hive"); if (!fs.existsSync(hiveDir)) fs.mkdirSync(hiveDir, { recursive: true }); info("Mapping codebase with scout agent..."); const result = await runAgent("scout", MAP_CODEBASE_TASK, CWD, opts); if (result.exitCode !== 0) die(`Scout failed: ${result.stderr || result.output}`); const mapOk = fs.existsSync(path.join(hiveDir, "codebase-map.json")); const sumOk = fs.existsSync(path.join(hiveDir, "summary.md")); if (mapOk && sumOk) { success("Codebase mapped!"); console.log(" .hive/codebase-map.json ✓"); console.log(" .hive/summary.md ✓"); console.log(`\nNext: hive to-features-md\n`); } else { warn(`Scout completed but files missing: map=${mapOk}, summary=${sumOk}`); console.log("The scout agent may not have written files. Try: hive spec"); } } async function cmdToFeaturesMd() { const opts = requireProvider(); requirePi(); const mapPath = path.join(CWD, ".hive", "codebase-map.json"); const sumPath = path.join(CWD, ".hive", "summary.md"); if (!fs.existsSync(mapPath) || !fs.existsSync(sumPath)) { die(".hive/ analysis not found. Run `hive map-codebase` first."); } if (fs.existsSync(FEATURES_PATH)) { fs.copyFileSync(FEATURES_PATH, FEATURES_PATH + ".backup"); info("Backed up existing features.md → features.md.backup"); } info("Generating features from analysis..."); const result = await runAgent("planner", TO_FEATURES_MD_TASK, CWD, opts); if (result.exitCode !== 0) die(`Planner failed: ${result.stderr || result.output}`); if (fs.existsSync(FEATURES_PATH)) { const count = parseFeaturesMd(fs.readFileSync(FEATURES_PATH, "utf-8")).length; success(`features.md created with ${count} features.`); } else { warn("Planner completed but features.md was not written. Try: hive spec"); } } async function cmdRun() { const opts = requireProvider(); requirePi(); const content = requireFeaturesMd(); const features = parseFeaturesMd(content); const stats = getStats(features); if (stats.done.length === stats.total && stats.total > 0) { success("All features already done! 🎉"); return; } if (stats.pending.length === 0 && stats.inProgress.length === 0) { die(`No features ready. ${stats.blocked.length} blocked by dependencies.`); } info(`Starting sequential run: ${stats.pending.length} ready, ${stats.done.length}/${stats.total} done`); const result = await runAgent("builder", RUN_PROMPT, CWD, opts); if (result.exitCode !== 0) { warn(`Builder finished with errors: ${result.stderr.slice(0, 500)}`); } // Show updated status if (fs.existsSync(FEATURES_PATH)) { const updated = parseFeaturesMd(fs.readFileSync(FEATURES_PATH, "utf-8")); const updatedStats = getStats(updated); console.log(`\nFinal: ${progressBar(updatedStats.done.length, updatedStats.total)}`); } } async function cmdQ(question: string) { const opts = requireProvider(); requirePi(); info("Querying project (read-only)...\n"); const result = await runAgent("scout", qPrompt(question), CWD, opts); if (result.exitCode !== 0) { die(`Query failed: ${result.stderr || result.output}`); } console.log(result.output); } // ─── Worktree Commands ───────────────────────────────────────────────── type ForkMode = false | "warp" | "tmux" | "iterm"; interface SplitFlags { count: number; fork: ForkMode; sandbox: boolean; autoApprove: boolean; features?: string; } function parseSplitFlags(args: string[]): SplitFlags { const flags: SplitFlags = { count: 3, fork: false, sandbox: false, autoApprove: false }; for (let i = 0; i < args.length; i++) { const a = args[i]; if (a === "--count" && args[i + 1]) { flags.count = Math.max(1, Math.min(10, parseInt(args[++i], 10) || 3)); } else if (a === "--warp") { // Legacy alias flags.fork = "warp"; } else if (a === "--fork") { const next = args[i + 1]; if (next === "tmux" || next === "iterm" || next === "warp") { flags.fork = next as ForkMode; i++; } else { flags.fork = "warp"; // default } } else if (a === "--sandbox") { flags.sandbox = true; } else if (a === "--auto-approve") { flags.autoApprove = true; } else if (a === "--features" && args[i + 1]) { flags.features = args[++i]; } } return flags; } function tmuxAvailable(): boolean { try { execFileSync("tmux", ["-V"], { stdio: "ignore" }); return true; } catch { return false; } } function dockerSandboxAvailable(): boolean { try { execFileSync("docker", ["sandbox", "version"], { stdio: "ignore" }); return true; } catch { return false; } } async function cmdWorktreeSplit(args: string[]) { const opts = requireProvider(); requirePi(); const flags = parseSplitFlags(args); // Validate features.md const content = requireFeaturesMd(); const validation = validateFeaturesMd(content); if (!validation.valid) { die(`features.md format issues:\n${validation.issues.map((i) => ` - ${i}`).join("\n")}`); } const features = parseFeaturesMd(content); const pendingFeatures = features.filter((f) => f.status !== "done"); if (pendingFeatures.length === 0) { success("All features already done. Nothing to split."); return; } // ── Sandbox pre-flight ────────────────────────────────────── let useSandbox = flags.sandbox; if (useSandbox && !dockerSandboxAvailable()) { warn("Docker Sandbox not available. Install Docker Desktop 4.58+ or the sandbox plugin."); warn("Falling back to standard mode (no isolation)."); useSandbox = false; } // ── tmux pre-flight ──────────────────────────────────────── let forkMode = flags.fork; if (forkMode === "tmux" && !tmuxAvailable()) { warn("tmux not found. Install it with:"); console.log(` macOS: ${c.cyan}brew install tmux${c.reset}`); console.log(` Ubuntu: ${c.cyan}sudo apt install tmux${c.reset}`); console.log(` Fedora: ${c.cyan}sudo dnf install tmux${c.reset}`); warn("Falling back to standard mode (manual commands)."); forkMode = false; } if (forkMode === "iterm") { warn("iTerm2 integration not yet implemented. Falling back to manual commands."); forkMode = false; } info(`Analyzing project for ${flags.count}-way territory split (${pendingFeatures.length} pending features)...`); // Use the LLM to analyze territory, but ask it to output structured JSON const territoryPrompt = `${worktreeSplitPrompt(flags.count, content, flags.features)} IMPORTANT: Since you cannot call tools directly, output your territory decisions as a JSON code block with this exact schema: \`\`\`json { "worktrees": [ { "id": "wt1", "features": [1, 2], "ownedFiles": ["src/path/**"], "ownedDirs": ["src/path/"], "readOnlyFiles": ["src/shared.ts"], "forbiddenDirs": ["src/other/"], "sharedFiles": [{"path": "package.json", "strategy": "append_only", "instructions": "Only ADD entries"}] } ], "mergeOrder": ["wt1", "wt2"], "sharedFiles": [{"path": "package.json", "strategy": "append_only", "instructions": "Only ADD entries"}], "techStack": "TypeScript + React", "testingInfo": "Jest", "conventions": "Follow existing patterns" } \`\`\``; const result = await runAgent("scout", territoryPrompt, CWD, opts); if (result.exitCode !== 0) { die(`Territory analysis failed: ${result.stderr || result.output}`); } // Extract JSON from output const jsonMatch = result.output.match(/```json\s*\n([\s\S]*?)```/); if (!jsonMatch) { die("Could not extract territory map from agent output.\n\n" + result.output.slice(0, 2000)); } let territory: any; try { territory = JSON.parse(jsonMatch[1]); } catch (e: any) { die(`Invalid territory JSON: ${e.message}\n\n${jsonMatch[1].slice(0, 1000)}`); } // Show territory assignment const projectName = getProjectName(CWD); console.log(`\n${c.bold}Territory Assignment:${c.reset}\n`); for (const wt of territory.worktrees) { console.log(` ${c.cyan}${wt.id}${c.reset} (Features ${wt.features.join(", ")}):`); console.log(` Owns: ${(wt.ownedDirs || []).join(", ") || "(auto)"}`); if (wt.sharedFiles?.length > 0) { console.log(` Shared: ${wt.sharedFiles.map((s: any) => `${s.path} (${s.strategy})`).join(", ")}`); } } console.log(`\n Merge order: ${territory.mergeOrder.join(" → ")}`); if (useSandbox) console.log(` Isolation: ${c.green}Docker Sandbox${c.reset} (each agent in its own microVM)`); console.log(); // --sandbox implies auto-approve if (!flags.autoApprove && !useSandbox) { const answer = await ask("Proceed? [Y/n] "); if (answer.toLowerCase() === "n") { info("Aborted."); return; } } // Create worktrees const created: Array<{ id: string; path: string; branch: string; featureNums: number[] }> = []; for (const wt of territory.worktrees) { const wtName = `${projectName}-${wt.id}`; try { const res = await createWorktree(CWD, wtName); created.push({ id: wt.id, path: res.path, branch: res.branch, featureNums: wt.features }); success(`Created ${wtName}`); } catch (err: any) { warn(`Failed to create ${wtName}: ${err.message}`); } } if (created.length === 0) die("No worktrees created."); // Generate PROMPT.md in each for (const cwt of created) { const wtSpec = territory.worktrees.find((w: any) => w.id === cwt.id); const ftrs = cwt.featureNums .map((n: number) => features.find((f) => f.number === n)) .filter(Boolean) as typeof features; const promptData: WorktreePromptData = { worktreeId: cwt.id, branchName: cwt.branch, features: ftrs.map((f) => ({ number: f.number, title: f.title, description: f.description, dependencies: f.dependencies })), ownedFiles: wtSpec.ownedFiles || [], ownedDirs: wtSpec.ownedDirs || [], readOnlyFiles: wtSpec.readOnlyFiles || [], forbiddenDirs: wtSpec.forbiddenDirs || [], sharedFiles: wtSpec.sharedFiles || [], provides: wtSpec.provides || [], consumes: wtSpec.consumes || [], techStack: territory.techStack || "Unknown", testingInfo: territory.testingInfo || "Not specified", conventions: territory.conventions || "Follow existing patterns", }; fs.writeFileSync(path.join(cwt.path, "PROMPT.md"), generatePromptMd(promptData)); } // Write territory_map.json const territoryMap: any = { createdAt: new Date().toISOString(), projectName, worktrees: created.map((cwt) => { const wtSpec = territory.worktrees.find((w: any) => w.id === cwt.id); const entry: any = { id: cwt.id, path: cwt.path, branch: cwt.branch, features: cwt.featureNums, ownedFiles: wtSpec.ownedFiles || [], ownedDirs: wtSpec.ownedDirs || [], readOnlyFiles: wtSpec.readOnlyFiles || [], sharedFiles: Object.fromEntries((wtSpec.sharedFiles || []).map((s: any) => [s.path, s.strategy])), provides: wtSpec.provides || [], consumes: wtSpec.consumes || [], }; if (useSandbox) { entry.sandbox = { enabled: true, name: `${projectName}-${cwt.id}` }; } return entry; }), mergeOrder: territory.mergeOrder, sharedFiles: (territory.sharedFiles || []).map((s: any) => ({ path: s.path, strategy: s.strategy })), }; fs.writeFileSync(path.join(CWD, "territory_map.json"), JSON.stringify(territoryMap, null, 2)); // Update features.md status let updatedContent = content; for (const cwt of created) { for (const fNum of cwt.featureNums) { updatedContent = updatedContent.replace( new RegExp(`(## Feature ${fNum}:[\\s\\S]*?- Status: )pending`, "m"), `$1in_progress`, ); } } fs.writeFileSync(FEATURES_PATH, updatedContent); // ── Build agent commands ────────────────────────────────────── const agentPrompt = "'Read PROMPT.md and implement all assigned features. Report progress after each feature.'"; function agentCmdForWorktree(cwt: { id: string; path: string }) { if (useSandbox) { return `docker sandbox run --name ${projectName}-${cwt.id} -w "${cwt.path}" claude`; } const base = `pi ${agentPrompt}`; return (flags.autoApprove || useSandbox) ? base.replace("pi ", "pi --yolo ") : base; } // ── Launch modes ────────────────────────────────────────────── if (forkMode === "warp") { // ── Warp Terminal ────────────────────────────────────────── const warpWts: WarpWorktree[] = created.map((cwt) => ({ path: cwt.path, command: agentCmdForWorktree(cwt), })); const yaml = generateWarpYaml(warpWts); const warpDir = path.join(os.homedir(), ".warp", "launch_configurations"); fs.mkdirSync(warpDir, { recursive: true }); const warpFile = path.join(warpDir, "hive-agents.yaml"); fs.writeFileSync(warpFile, yaml); try { execFileSync("open", ["warp://launch/Hive%20Agents"], { stdio: "ignore" }); } catch { /* Warp not available */ } const modeLabel = useSandbox ? "Sandbox Agents" : "Agents"; success(`Warp launched with ${created.length} ${modeLabel}!`); console.log(` Config: ${warpFile}\n`); } else if (forkMode === "tmux") { // ── tmux ─────────────────────────────────────────────────── const tmuxWts: WarpWorktree[] = created.map((cwt) => ({ path: cwt.path, command: useSandbox ? agentCmdForWorktree(cwt) : `cd ${cwt.path} && ${agentCmdForWorktree(cwt)}`, })); const tmuxCmds = generateTmuxCommands(tmuxWts); // Execute all but the last command (attach) in background const { execSync } = await import("node:child_process"); for (const cmd of tmuxCmds.slice(0, -1)) { try { execSync(cmd, { stdio: "ignore" }); } catch { /* ignore */ } } const modeLabel = useSandbox ? "Sandbox Agents" : "Agents"; success(`tmux session 'hive' launched with ${created.length} ${modeLabel}!`); console.log(); console.log(` ${c.dim}Re-attach: tmux attach -t hive${c.reset}`); console.log(` ${c.dim}List panes: tmux list-panes -t hive${c.reset}`); console.log(` ${c.dim}Kill all: tmux kill-session -t hive${c.reset}`); console.log(); console.log(` ${c.dim}Shortcuts:${c.reset}`); console.log(` ${c.dim} Ctrl+B → arrows navigate panes${c.reset}`); console.log(` ${c.dim} Ctrl+B → z zoom/unzoom pane${c.reset}`); console.log(` ${c.dim} Ctrl+B → d detach (agents keep running)${c.reset}`); console.log(); // Attach (this takes over the terminal) const lastCmd = tmuxCmds[tmuxCmds.length - 1]; try { execSync(lastCmd, { stdio: "inherit" }); } catch { /* detached or killed */ } } else { // ── Manual mode (no --fork) ──────────────────────────────── const modeLabel = useSandbox ? " (Docker Sandbox mode)" : ""; console.log(`\n${c.bold}Start agents${modeLabel} (one terminal per worktree):${c.reset}\n`); for (const cwt of created) { const cmd = agentCmdForWorktree(cwt); if (useSandbox) { console.log(` ${cmd}`); } else { console.log(` cd ${cwt.path} && ${cmd}`); } } if (useSandbox) { console.log(`\n${c.dim}Monitor sandboxes: docker sandbox ls${c.reset}`); } console.log(); } console.log(`${c.bold}After completion:${c.reset}\n`); if (useSandbox) { console.log(` docker sandbox rm ${created.map((cwt) => `${projectName}-${cwt.id}`).join(" ")}`); } for (const cwt of created) { console.log(` hive worktree-merge ${projectName}-${cwt.id}`); } console.log(); } async function cmdWorktreeMerge(worktreeArg?: string) { const worktrees = await listWorktrees(CWD); const others = worktrees.filter((wt) => wt.path !== CWD && !wt.isBare); if (others.length === 0) { info("No worktrees found to merge."); return; } // Resolve which worktree to merge let target = others[0]; if (worktreeArg) { const byName = others.find((wt) => path.basename(wt.path) === worktreeArg); const byBranch = others.find((wt) => wt.branch === worktreeArg); const byIndex = /^\d+$/.test(worktreeArg) ? others[parseInt(worktreeArg, 10) - 1] : undefined; target = byName || byBranch || byIndex || target; if (!byName && !byBranch && !byIndex) { warn(`No worktree matching "${worktreeArg}". Available:`); for (let i = 0; i < others.length; i++) { console.log(` ${i + 1}. ${path.basename(others[i].path)} (${others[i].branch})`); } return; } } else if (others.length > 1) { console.log("\nAvailable worktrees:"); for (let i = 0; i < others.length; i++) { console.log(` ${i + 1}. ${path.basename(others[i].path)} (${others[i].branch})`); } const answer = await ask("\nWhich to merge? [1] "); const idx = parseInt(answer || "1", 10) - 1; target = others[idx] || others[0]; } info(`Merging ${path.basename(target.path)} (${target.branch})...`); // Check uncommitted changes try { if (await hasUncommittedChanges(target.path)) { warn("Worktree has uncommitted changes. Committing..."); await commitAll(target.path, `hive: auto-commit before merge`); success("Changes committed."); } } catch { /* might not be a git dir */ } // Test merge const mainBranch = await getCurrentBranch(CWD); const mergeResult = await testMerge(CWD, target.branch); if (!mergeResult.clean) { console.error(`\n${c.red}Conflicts detected! Merge aborted.${c.reset}\n`); console.error("Conflicting files:"); for (const f of mergeResult.conflicts) console.error(` ${f}`); console.error(`\nResolve manually:\n cd ${target.path}\n git merge ${mainBranch}\n # fix conflicts\n git add . && git commit\n hive worktree-merge ${path.basename(target.path)}\n`); process.exit(1); } // Execute merge const commitMsg = `merge: ${target.branch}`; await executeMerge(CWD, target.branch, commitMsg); success(`Merged ${target.branch} → ${mainBranch}`); // Sandbox cleanup — remove container if territory_map says sandbox was used try { const mapPath = path.join(CWD, "territory_map.json"); if (fs.existsSync(mapPath)) { const map = JSON.parse(fs.readFileSync(mapPath, "utf-8")); const wtEntry = (map.worktrees || []).find((w: any) => w.path === target.path || w.branch === target.branch); if (wtEntry?.sandbox?.enabled && wtEntry.sandbox.name) { try { execFileSync("docker", ["sandbox", "rm", wtEntry.sandbox.name], { stdio: "ignore" }); success(`Sandbox '${wtEntry.sandbox.name}' removed.`); } catch { /* already removed or not running */ } } } } catch { /* ignore territory_map read errors */ } // Cleanup worktree + branch try { await removeWorktree(CWD, target.path); await deleteBranch(CWD, target.branch); success("Worktree and branch cleaned up."); } catch (err: any) { warn(`Cleanup issue: ${err.message}`); } // Show remaining const remaining = (await listWorktrees(CWD)).filter((wt) => wt.path !== CWD && !wt.isBare); if (remaining.length > 0) { console.log(`\nRemaining worktrees:`); for (const wt of remaining) console.log(` ${path.basename(wt.path)} (${wt.branch})`); console.log(`\nNext: hive worktree-merge ${path.basename(remaining[0].path)}\n`); } else { success("All worktrees merged! 🎉\n"); } } // ─── Main ────────────────────────────────────────────────────────────── const USAGE = `${c.bold}Hive${c.reset} — Lightweight Feature Tracker for AI Coding Agents ${c.bold}Usage:${c.reset} hive [options] ${c.bold}Setup:${c.reset} setup Configure API key interactively doctor Check all requirements ${c.bold}Pure commands (no API key needed):${c.reset} status Show feature progress next Show next feature to implement ${c.bold}LLM commands (requires Pi + API key):${c.reset} spec [description] Create features.md (scout → planner → reviewer) map-codebase Analyze codebase → .hive/ to-features-md Convert .hive/ → features.md run Implement features sequentially q Ask about the project (read-only) ${c.bold}Worktree commands:${c.reset} worktree-split [options] Split into parallel worktrees --count N Number of worktrees (default: 3) --fork [warp|tmux] Auto-launch agents (default: warp) --sandbox Docker Sandbox isolation per agent --auto-approve Skip confirmations --features "1,2;3,4" Explicit grouping worktree-merge [name] Merge a worktree back ${c.bold}Examples:${c.reset} hive setup hive doctor hive status hive spec "Build a REST API with auth and CRUD for users" hive run hive worktree-split --count 3 --fork tmux hive worktree-split --count 2 --sandbox --fork tmux hive worktree-merge myproject-wt1 `; async function main() { const args = process.argv.slice(2); const command = args[0]; if (!command || command === "--help" || command === "-h") { console.log(USAGE); process.exit(0); } switch (command) { case "setup": await cmdSetup(); break; case "doctor": await cmdDoctor(); break; case "status": cmdStatus(); break; case "next": cmdNext(); break; case "spec": await cmdSpec(args.slice(1).join(" ") || undefined); break; case "map-codebase": await cmdMapCodebase(); break; case "to-features-md": await cmdToFeaturesMd(); break; case "run": await cmdRun(); break; case "q": if (args.length < 2) die("Usage: hive q "); await cmdQ(args.slice(1).join(" ")); break; case "worktree-split": await cmdWorktreeSplit(args.slice(1)); break; case "worktree-merge": await cmdWorktreeMerge(args[1]); break; default: die(`Unknown command: ${command}\n\nRun \`hive --help\` for usage.`); } } main().catch((err) => { die(err.message || String(err)); });