import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import * as fs from "node:fs"; import * as path from "node:path"; import { parseFeaturesMd, getStats, progressBar } from "../lib/features-parser.js"; import { runChain, type ChainStep } from "../lib/agents.js"; import { SPEC_SCOUT_TASK, specPlannerTask, SPEC_REVIEWER_TASK, FEATURES_MD_FORMAT, } from "../lib/prompts.js"; type Scenario = "new_project" | "existing_no_features" | "analysis_exists" | "edit_features"; function detectScenario(cwd: string): Scenario { const hasFeatures = fs.existsSync(path.join(cwd, "features.md")); 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) return "edit_features"; if (hasAnalysis) return "analysis_exists"; if (hasSrc) return "existing_no_features"; return "new_project"; } export function registerSpecCommand(pi: ExtensionAPI) { pi.registerCommand("hive:spec", { description: "Create or edit features.md interactively (detects project context)", handler: async (args, ctx) => { const scenario = detectScenario(ctx.cwd); const autoApprove = pi.getFlag("--auto-approve") as boolean; // ── Scenario D: Edit existing features ────────────────────── if (scenario === "edit_features") { const content = fs.readFileSync(path.join(ctx.cwd, "features.md"), "utf-8"); const features = parseFeaturesMd(content); const stats = getStats(features); const summary = features .map((f) => ` Feature ${f.number}: ${f.title} [${f.status}]`) .join("\n"); const choice = await ctx.ui.select( `features.md found with ${features.length} features:\n${summary}\n\n${progressBar(stats.done.length, stats.total)}`, [ "Add new features", "Edit a feature", "Remove features", "Recreate from scratch (backup current)", "No changes — proceed to execution", ], ); if (choice === undefined) return; if (choice === "No changes — proceed to execution") { ctx.ui.notify("Next steps:\n /hive:worktree-split --count 3\n /hive:run", "info"); return; } if (choice === "Recreate from scratch (backup current)") { fs.copyFileSync( path.join(ctx.cwd, "features.md"), path.join(ctx.cwd, "features.md.backup"), ); ctx.ui.notify("Backed up to features.md.backup", "info"); // Fall through to generate new features } else { // Delegate interactive editing to the LLM pi.sendUserMessage( `I want to ${choice.toLowerCase()} in features.md. Read features.md first, then help me.\n\n${FEATURES_MD_FORMAT}`, ); return; } } // ── Scenario A: New project ───────────────────────────────── if (scenario === "new_project") { const description = args?.trim() || await ctx.ui.input("Describe your project:", "What do you want to build?"); if (!description) { ctx.ui.notify("No description provided. Aborting.", "warning"); return; } ctx.ui.notify("Generating features from project description...", "info"); ctx.ui.setStatus("hive", "Spec: planning..."); const steps: ChainStep[] = [ { agent: "planner", task: specPlannerTask(description).replace("{previous}", description) }, { agent: "reviewer", task: SPEC_REVIEWER_TASK }, ]; const result = await runChain(steps, ctx.cwd, undefined, (step, total, agent, status) => { ctx.ui.setStatus("hive", `Spec: ${agent} (${step}/${total}) ${status}`); }); ctx.ui.setStatus("hive", undefined); if (result.exitCode !== 0) { ctx.ui.notify(`Spec generation failed: ${result.output || result.stderr}`, "error"); return; } // Extract features.md content from output and write it const featuresContent = extractFeaturesMd(result.output); if (featuresContent) { if (!autoApprove) { const ok = await ctx.ui.confirm("Save features.md?", featuresContent.slice(0, 500) + "..."); if (!ok) return; } fs.writeFileSync(path.join(ctx.cwd, "features.md"), featuresContent); const count = parseFeaturesMd(featuresContent).length; ctx.ui.notify(completionMessage(count), "info"); } else { ctx.ui.notify("Could not extract features from agent output. Raw output:\n\n" + result.output.slice(0, 1000), "warning"); } return; } // ── Scenario C: Analysis exists ───────────────────────────── if (scenario === "analysis_exists") { ctx.ui.notify("Found .hive/ analysis. Generating features...", "info"); ctx.ui.setStatus("hive", "Spec: generating from analysis..."); const steps: ChainStep[] = [ { agent: "planner", task: `Read .hive/codebase-map.json and .hive/summary.md, then generate features.\n\n${specPlannerTask(args?.trim() || undefined).replace("{previous}", "(read from .hive/ files)")}` }, { agent: "reviewer", task: SPEC_REVIEWER_TASK }, ]; const result = await runChain(steps, ctx.cwd, undefined, (step, total, agent, status) => { ctx.ui.setStatus("hive", `Spec: ${agent} (${step}/${total}) ${status}`); }); ctx.ui.setStatus("hive", undefined); if (result.exitCode !== 0) { ctx.ui.notify(`Spec generation failed: ${result.output || result.stderr}`, "error"); return; } const featuresContent = extractFeaturesMd(result.output); if (featuresContent) { if (!autoApprove) { const ok = await ctx.ui.confirm("Save features.md?", featuresContent.slice(0, 500) + "..."); if (!ok) return; } fs.writeFileSync(path.join(ctx.cwd, "features.md"), featuresContent); const count = parseFeaturesMd(featuresContent).length; ctx.ui.notify(completionMessage(count), "info"); } else { ctx.ui.notify("Could not extract features. Raw output:\n\n" + result.output.slice(0, 1000), "warning"); } return; } // ── Scenario B: Existing project, no features ─────────────── ctx.ui.notify("Scanning codebase with scout → planner → reviewer chain...", "info"); ctx.ui.setStatus("hive", "Spec: scout analyzing..."); const steps: ChainStep[] = [ { agent: "scout", task: SPEC_SCOUT_TASK }, { agent: "planner", task: specPlannerTask(args?.trim() || undefined) }, { agent: "reviewer", task: SPEC_REVIEWER_TASK }, ]; const result = await runChain(steps, ctx.cwd, undefined, (step, total, agent, status) => { ctx.ui.setStatus("hive", `Spec: ${agent} (${step}/${total}) ${status}`); }); ctx.ui.setStatus("hive", undefined); if (result.exitCode !== 0) { ctx.ui.notify(`Spec generation failed: ${result.output || result.stderr}`, "error"); return; } const featuresContent = extractFeaturesMd(result.output); if (featuresContent) { if (!autoApprove) { const ok = await ctx.ui.confirm("Save features.md?", featuresContent.slice(0, 500) + "..."); if (!ok) return; } fs.writeFileSync(path.join(ctx.cwd, "features.md"), featuresContent); const count = parseFeaturesMd(featuresContent).length; ctx.ui.notify(completionMessage(count), "info"); } else { ctx.ui.notify("Could not extract features. Raw output:\n\n" + result.output.slice(0, 1000), "warning"); } }, }); } /** * Extract a features.md block from agent output. * Agents may wrap it in markdown code fences or output it directly. */ function extractFeaturesMd(output: string): string | null { // Try to find a fenced code block with features const fenceMatch = output.match(/```(?:markdown)?\s*\n(# Features[\s\S]*?)```/); if (fenceMatch) return fenceMatch[1].trim() + "\n"; // Try to find raw features.md content const rawMatch = output.match(/(# Features[\s\S]*## Feature \d+:[\s\S]*)/); if (rawMatch) { // Trim any trailing non-feature content let content = rawMatch[1]; const lastFeature = content.lastIndexOf("## Feature "); if (lastFeature >= 0) { const afterLastFeature = content.indexOf("\n\n\n", lastFeature); if (afterLastFeature >= 0) { content = content.slice(0, afterLastFeature); } } return content.trim() + "\n"; } return null; } function completionMessage(count: number): string { return `features.md created with ${count} features. Next steps: # Review features cat features.md # Parallel: N worktrees /hive:worktree-split --count 3 # Sequential: one agent at a time /hive:run # Parallel + Warp Terminal /hive:worktree-split --count 3 --warp`; }