import { BaseAgent, AgentContext, AgentResult } from "./base.js"; import { CIAgentFiles, RequirementsMd, RoadmapMd, ArchitectureMd } from "../core/ciagent-files.js"; import { GitContext } from "../core/git-context.js"; import { CommitBuilder } from "../core/commit-builder.js"; import { writeFile, readFile, ensureDir } from "../utils/file.js"; import { execSync } from "node:child_process"; import * as path from "node:path"; export interface PlannerResult { success: boolean; planCount: number; waves: { wave: number; plans: string[] }[]; decisions: number; error?: string; } interface PlanEntry { name: string; wave: number; requirements: string[]; dependsOn: string[]; tasks: string[]; mustHaves: string[]; } export class PlannerAgent extends BaseAgent { readonly name = "planner"; readonly description = "Creates phase plans with tasks. Never sets autonomous:false — decomposes into verifiable subtasks."; readonly workflow = "plan"; async execute(context: AgentContext): Promise { const start = Date.now(); this.log("Creating phase plan..."); if (context.backend) { const taskPrompt = await this.buildBackendTaskPrompt(context); const result = await this.executeViaBackend(context, taskPrompt); return { ...result, duration_ms: Date.now() - start }; } return this.executeMechanical(context, start); } private async buildBackendTaskPrompt(context: AgentContext): Promise { const ciFiles = new CIAgentFiles(context.project_path); const parts: string[] = [ `Create a phase plan for stage ${context.stage}, phase ${context.phase}.`, "", "## Project Context", ]; const roadmap = ciFiles.readRoadmapMd(); if (roadmap) { const currentPhase = roadmap.phases.find((p) => p.number === context.phase); if (currentPhase) { parts.push("", "### Phase Goal", currentPhase.description); parts.push("", "### Phase Requirements", currentPhase.requirements.join(", ") || "None specified"); parts.push("", "### Phase Dependencies", currentPhase.dependsOn.length > 0 ? currentPhase.dependsOn.map((d) => `Phase ${d}`).join(", ") : "None"); parts.push("", "### Success Criteria", ...currentPhase.successCriteria.map((sc) => `- ${sc}`)); } } const requirements = ciFiles.readRequirementsMd(); if (requirements) { const phaseReqs = requirements.traceability.filter((t) => t.phase === context.phase); if (phaseReqs.length > 0) { parts.push("", "### Requirements for Phase", ...phaseReqs.map((t) => `- ${t.requirement} (${t.status})`)); } } const architecture = ciFiles.readArchitectureMd(); if (architecture) { parts.push("", "### Architecture Boundaries", ...architecture.components.map((c) => `- ${c.name}: ${c.boundaries}`)); parts.push("", "### Build Order", ...architecture.buildOrder.map((bo) => `${bo}`)); } parts.push("", "## Specification", context.specification || "No specification provided"); return parts.join("\n"); } private executeMechanical(context: AgentContext, start: number): AgentResult { const ciFiles = new CIAgentFiles(context.project_path); ciFiles.ensureCIDir(); const requirements = ciFiles.readRequirementsMd(); const roadmap = ciFiles.readRoadmapMd(); const architecture = ciFiles.readArchitectureMd(); if (!requirements && !roadmap) { return { success: false, output: "Planning requires either .ciagent/REQUIREMENTS.md or .ciagent/ROADMAP.md. Initialize the project first.", artifacts_created: [], decisions: 0, escalations: 0, duration_ms: Date.now() - start, error: "No requirements or roadmap found for mechanical planning", }; } let gitLogSummary = ""; try { gitLogSummary = execSync("git log --max-count=20 --oneline", { cwd: context.project_path, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], }).trim(); } catch { gitLogSummary = "(no git history available)"; } const phaseGoal = this.extractPhaseGoal(roadmap, context.phase); const phaseRequirements = this.extractPhaseRequirements(requirements, context.phase); const componentBoundaries = architecture ? architecture.components.map((c) => c.name) : []; const plans = this.buildPlans(phaseRequirements, componentBoundaries, context.phase); const planFileContent = this.formatPlanFile(context.phase, phaseGoal, plans); const planFilePath = path.join(context.project_path, ".ciagent", "PLAN.md"); ensureDir(path.dirname(planFilePath)); writeFile(planFilePath, planFileContent); const decisionCount = plans.length > 0 ? 1 : 0; if (this.shouldCommit(context)) { try { const commitMessage = CommitBuilder.buildTaskCommit({ type: "docs", phase: context.phase, milestone: "v1.0", plan: "01", task: "01-01", subject: `create ${plans.length} phase plans`, status: "plan", decisions: decisionCount > 0 ? [{ id: "D-001", decision: `Decomposed phase ${context.phase} into ${plans.length} vertical-slice plans`, rationale: "Requirements grouped by dependency analysis — independent requirements in wave 1, dependent in wave 2+", confidence: 0.75, alternatives: ["single monolithic plan", "per-requirement plans"], }] : undefined, }); execSync(`git add -A && git commit -m "${commitMessage.replace(/"/g, '\\"')}" --allow-empty`, { cwd: context.project_path, stdio: "pipe", }); } catch { this.warn("Plan commit failed"); } } const waves = this.groupPlansByWave(plans); const plannerResult: PlannerResult = { success: true, planCount: plans.length, waves, decisions: decisionCount, }; return { success: true, output: `Created ${plans.length} plan(s) across ${waves.length} wave(s) for phase ${context.phase}`, artifacts_created: [".ciagent/PLAN.md"], decisions: decisionCount, escalations: 0, duration_ms: Date.now() - start, }; } private extractPhaseGoal(roadmap: RoadmapMd | null, phase: number): string { if (!roadmap) return "No roadmap available"; const phaseEntry = roadmap.phases.find((p) => p.number === phase); if (phaseEntry) return `${phaseEntry.name}: ${phaseEntry.description}`; return `Phase ${phase} (no roadmap entry)`; } private extractPhaseRequirements(requirements: RequirementsMd | null, phase: number): Array<{ id: string; description: string; phase: number; status: string }> { if (!requirements) return []; return requirements.traceability .filter((t) => t.phase === phase) .map((t) => { let description = t.requirement; for (const cat of [...requirements.v1, ...requirements.v2]) { const item = cat.items.find((i) => i.id === t.requirement); if (item) { description = `${t.requirement}: ${item.description}`; break; } } return { id: t.requirement, description, phase: t.phase, status: t.status }; }); } private buildPlans( phaseRequirements: Array<{ id: string; description: string; phase: number; status: string }>, componentBoundaries: string[], phase: number ): PlanEntry[] { if (phaseRequirements.length === 0) { return [{ name: `Phase ${phase} Core Implementation`, wave: 1, requirements: [], dependsOn: [], tasks: [`Implement phase ${phase} deliverables as specified in ROADMAP.md`], mustHaves: [`Phase ${phase} deliverables exist and pass verification`], }]; } const independentReqs = phaseRequirements.filter((r) => r.status !== "blocked"); const blockedReqs = phaseRequirements.filter((r) => r.status === "blocked"); const plans: PlanEntry[] = []; if (independentReqs.length > 0) { const taskChunks = this.chunkByComponent(independentReqs, componentBoundaries); for (const chunk of taskChunks) { plans.push({ name: this.inferPlanName(chunk, phase), wave: 1, requirements: chunk.map((r) => r.id), dependsOn: [], tasks: chunk.map((r) => { const desc = r.description.split(": ").slice(1).join(": ") || r.description; return desc !== r.id ? `Implement ${r.id}: ${desc}` : `Implement ${r.id}`; }), mustHaves: chunk.map((r) => `${r.id} implemented and testable`), }); } } if (blockedReqs.length > 0) { const taskChunks = this.chunkByComponent(blockedReqs, componentBoundaries); for (const chunk of taskChunks) { plans.push({ name: this.inferPlanName(chunk, phase), wave: plans.length > 0 ? Math.max(...plans.map((p) => p.wave)) + 1 : 2, requirements: chunk.map((r) => r.id), dependsOn: plans.slice(0, plans.length > 0 ? 1 : 0).map((p) => p.name), tasks: chunk.map((r) => { const desc = r.description.split(": ").slice(1).join(": ") || r.description; return desc !== r.id ? `Implement ${r.id}: ${desc}` : `Implement ${r.id}`; }), mustHaves: chunk.map((r) => `${r.id} implemented and testable`), }); } } if (plans.length === 0) { plans.push({ name: `Phase ${phase} Default`, wave: 1, requirements: [], dependsOn: [], tasks: [`Implement phase ${phase} deliverables`], mustHaves: [`Phase ${phase} deliverables pass verification`], }); } return plans; } private chunkByComponent( reqs: Array<{ id: string; description: string; phase: number; status: string }>, _componentBoundaries: string[] ): Array> { if (reqs.length <= 3) return [reqs]; const chunks: Array> = []; const chunkSize = Math.ceil(reqs.length / Math.ceil(reqs.length / 3)); for (let i = 0; i < reqs.length; i += chunkSize) { chunks.push(reqs.slice(i, i + chunkSize)); } return chunks; } private inferPlanName(chunk: Array<{ id: string; description: string; phase: number; status: string }>, phase: number): string { if (chunk.length === 1) return `Phase ${phase}: ${chunk[0].id}`; return `Phase ${phase}: ${chunk[0].id}–${chunk[chunk.length - 1].id}`; } private groupPlansByWave(plans: PlanEntry[]): { wave: number; plans: string[] }[] { const waveMap = new Map(); for (const plan of plans) { const existing = waveMap.get(plan.wave) || []; existing.push(plan.name); waveMap.set(plan.wave, existing); } return Array.from(waveMap.entries()) .sort((a, b) => a[0] - b[0]) .map(([wave, names]) => ({ wave, plans: names })); } private formatPlanFile(phase: number, phaseGoal: string, plans: PlanEntry[]): string { const lines: string[] = [ `# Phase ${phase} Plan`, "", "## Phase Goal", phaseGoal, "", "## Plans", "", ]; for (let i = 0; i < plans.length; i++) { const plan = plans[i]; const planNum = i + 1; lines.push(`### Plan ${planNum}: ${plan.name}`); lines.push(`- Wave: ${plan.wave}`); if (plan.requirements.length > 0) { lines.push(`- Requirements: [${plan.requirements.join(", ")}]`); } if (plan.dependsOn.length > 0) { lines.push(`- Depends on: ${plan.dependsOn.join(", ")}`); } lines.push("- Tasks:"); for (const task of plan.tasks) { lines.push(` 1. ${task}`); } lines.push("- Must-haves:"); for (const mh of plan.mustHaves) { lines.push(` - [x] ${mh}`); } lines.push(""); } return lines.join("\n"); } private shouldCommit(context: AgentContext): boolean { try { execSync("git rev-parse --is-inside-work-tree", { cwd: context.project_path, stdio: "pipe", }); return true; } catch { return false; } } }