/** * errand-planner.ts -- Lightweight task generation for quest-less "errands". * * Takes a brief natural-language description from the user and runs the * errand-planner agent with a simplified prompt to generate 1-N tasks that * are not associated with any quest. * * Uses a dedicated errand-planner-agent definition (tuned for standalone * errands rather than quest decomposition), but reuses the YAML parsing and * validation pipeline from quest-planner.ts. * * Created tasks go directly into the task store (no quest association). */ import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; import type { WomboConfig } from "../config"; import { resolveAgentBin } from "../config"; import type { ProposedTask, PlanResult } from "./quest-planner"; import { extractPlanYaml, parsePlanYaml, validatePlan, } from "./quest-planner"; import { createBlankTask, saveTaskToStore, loadTasks } from "./tasks"; import type { Task } from "./tasks"; import { buildScoutIndex, formatScoutTree } from "./subagents/scout"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- /** Structured errand specification from the multi-step wizard. */ export interface ErrandSpec { /** What needs to be done (required). */ description: string; /** What areas/files should this focus on (optional). */ scope?: string; /** Key objectives or acceptance criteria (optional). */ objectives?: string; } // --------------------------------------------------------------------------- // Prompt Generation // --------------------------------------------------------------------------- /** * Generate the prompt for errand-style task generation. * Lighter than a full quest prompt -- no quest context, no branches, no HITL. */ async function generateErrandPrompt( spec: ErrandSpec, projectRoot: string, config: WomboConfig ): Promise { const sections: string[] = []; sections.push(`# Errand: Quick Task Generation`); sections.push(``); sections.push( `You are being asked to turn a brief request into one or more atomic tasks ` + `that autonomous coding agents can execute. These are standalone "errands" ` + `-- quick jobs not associated with any larger quest.` ); sections.push(``); sections.push(`## Request`); sections.push(``); sections.push(spec.description.trim()); sections.push(``); if (spec.scope) { sections.push(`## Scope`); sections.push(``); sections.push( `Focus the work on these areas. Do NOT modify files or systems outside ` + `this scope unless strictly necessary:` ); sections.push(``); sections.push(spec.scope.trim()); sections.push(``); } if (spec.objectives) { sections.push(`## Objectives / Acceptance Criteria`); sections.push(``); sections.push( `The tasks you produce should collectively satisfy these objectives:` ); sections.push(``); sections.push(spec.objectives.trim()); sections.push(``); } // Codebase outline sections.push(`## Codebase Outline`); sections.push(``); sections.push( "Below is the project structure. Use your tools to explore specific files " + "in depth if needed." ); sections.push(``); sections.push("```"); try { const scoutIndex = await buildScoutIndex(projectRoot); sections.push( formatScoutTree(scoutIndex, { maxDepth: 4, showSymbolCounts: true, maxLines: 150, }) ); } catch { // Minimal fallback sections.push("(codebase outline unavailable)"); } sections.push("```"); // Existing tasks (for ID dedup) const tasksData = loadTasks(projectRoot, config); if (tasksData.tasks.length > 0) { sections.push(``); sections.push(`## Existing Task IDs`); sections.push(``); sections.push( "These task IDs already exist -- do NOT reuse them. Pick unique IDs." ); sections.push(``); for (const t of tasksData.tasks) { sections.push(`- \`${t.id}\``); } } // Build info sections.push(``); sections.push(`## Build System`); sections.push(``); sections.push(`Build command: \`${config.build.command}\``); if (config.build.timeout) { sections.push(`Build timeout: ${config.build.timeout}ms`); } sections.push(``); sections.push(`## Instructions`); sections.push(``); sections.push( "Explore the codebase briefly to understand the request, then produce a " + "task breakdown. For simple requests this will be 1 task; for more complex " + "ones, 2-5 tasks with dependencies.\n\n" + "If the request mentions a specific agent type or specialization, set the " + "`agent` field accordingly. Otherwise, omit it (the generalist agent will " + "be used).\n\n" + "Output a single YAML fenced code block as your final output, using the " + "schema specified in your agent definition." ); return sections.join("\n"); } // --------------------------------------------------------------------------- // Errand Planner Execution // --------------------------------------------------------------------------- /** * Run the errand planner: launch the quest-planner agent with an errand- * specific prompt, parse its YAML output, validate, and return the result. */ export async function runErrandPlanner( spec: ErrandSpec, projectRoot: string, config: WomboConfig, opts?: { model?: string; onProgress?: (message: string) => void; } ): Promise { const onProgress = opts?.onProgress ?? (() => {}); // Generate the prompt onProgress("Generating errand prompt..."); const prompt = await generateErrandPrompt(spec, projectRoot, config); // Launch the errand planner agent onProgress("Launching errand planner..."); const agentBin = resolveAgentBin(config); const agentName = "errand-planner-agent"; // Verify the agent binary exists before spawning if (!existsSync(agentBin)) { return { success: false, tasks: [], knowledge: null, issues: [], rawOutput: "", error: `Agent binary not found at: ${agentBin}. Check config.agent.bin or OPENCODE_BIN env var.`, }; } const args = [ "run", "--format", "json", "--agent", agentName, "--dir", projectRoot, "--title", `woco: errand`, ]; if (opts?.model) { args.push("--model", opts.model); } args.push(prompt); const child = spawn(agentBin, args, { stdio: ["pipe", "pipe", "pipe"], detached: false, env: { ...process.env, OPENCODE_DIR: projectRoot, }, }); child.stdin?.end(); // Collect stdout const chunks: Buffer[] = []; let stderrText = ""; child.stdout?.on("data", (chunk: Buffer) => { chunks.push(chunk); }); child.stderr?.on("data", (chunk: Buffer) => { stderrText += chunk.toString(); }); // Wait for process to exit with a 5-minute timeout const ERRAND_TIMEOUT_MS = 5 * 60 * 1000; const exitCode = await new Promise((resolve) => { let settled = false; const timeout = setTimeout(() => { if (!settled) { settled = true; try { child.kill("SIGTERM"); } catch { // Best effort } resolve(-1); } }, ERRAND_TIMEOUT_MS); child.on("close", (code) => { if (!settled) { settled = true; clearTimeout(timeout); resolve(code ?? 1); } }); child.on("error", (err) => { if (!settled) { settled = true; clearTimeout(timeout); stderrText += `\nSpawn error: ${err.message}`; resolve(1); } }); }); const rawOutput = Buffer.concat(chunks).toString("utf-8"); onProgress("Parsing errand planner output..."); if (exitCode === -1) { return { success: false, tasks: [], knowledge: null, issues: [], rawOutput, error: `Errand planner timed out after ${ERRAND_TIMEOUT_MS / 1000}s. The agent process was killed.`, }; } if (exitCode !== 0 && !rawOutput.trim()) { return { success: false, tasks: [], knowledge: null, issues: [], rawOutput, error: `Errand planner exited with code ${exitCode}. stderr: ${stderrText.slice(0, 500)}`, }; } // Extract text events from JSON output stream const textContent = extractTextFromJsonEvents(rawOutput); // Extract YAML const yamlStr = extractPlanYaml(textContent); if (!yamlStr) { return { success: false, tasks: [], knowledge: null, issues: [], rawOutput: textContent, error: "Errand planner did not produce a YAML task plan in a fenced code block.", }; } // Parse the YAML let plan: { tasks: ProposedTask[]; knowledge?: string }; try { plan = parsePlanYaml(yamlStr); } catch (err: unknown) { const reason = err instanceof Error ? err.message : String(err); return { success: false, tasks: [], knowledge: null, issues: [], rawOutput: textContent, error: `Failed to parse errand YAML: ${reason}`, }; } // Validate onProgress(`Validating errand plan (${plan.tasks.length} tasks)...`); const issues = validatePlan(plan); const hasErrors = issues.some((i) => i.level === "error"); return { success: !hasErrors, tasks: plan.tasks, knowledge: plan.knowledge ?? null, issues, rawOutput: textContent, error: hasErrors ? "Errand plan has validation errors -- review and fix before approving." : undefined, }; } // --------------------------------------------------------------------------- // Apply Errand Plan (create tasks without a quest) // --------------------------------------------------------------------------- /** * Apply an approved errand plan: create tasks directly in the task store * without associating them with any quest. */ export function applyErrandPlan( plan: PlanResult, projectRoot: string, config: WomboConfig ): Task[] { const tasks: Task[] = []; for (const proposed of plan.tasks) { const task = createBlankTask(proposed.id, proposed.title, proposed.description, { priority: proposed.priority, difficulty: proposed.difficulty, effort: proposed.effort, }); // Copy over planner fields task.depends_on = proposed.depends_on; task.constraints = proposed.constraints; task.forbidden = proposed.forbidden; task.references = proposed.references; task.notes = proposed.notes; if (proposed.agent) { task.agent = proposed.agent; } saveTaskToStore(projectRoot, config, task); tasks.push(task); } return tasks; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** * Extract text content from the JSON event stream. * (Duplicated from quest-planner.ts to avoid exporting an internal helper.) */ function extractTextFromJsonEvents(rawOutput: string): string { const lines = rawOutput.split("\n"); const textParts: string[] = []; for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; try { const event = JSON.parse(trimmed); if (event.type === "text" && event.part?.text) { textParts.push(event.part.text); } } catch { textParts.push(trimmed); } } return textParts.join(""); }