6902c37ced
---ci--- phase: 3 milestone: v0.6.0 plan: 03 task: 03-03 status: execute ---/ci---
342 lines
12 KiB
TypeScript
342 lines
12 KiB
TypeScript
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<AgentResult> {
|
||
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<string> {
|
||
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<Array<{ id: string; description: string; phase: number; status: string }>> {
|
||
if (reqs.length <= 3) return [reqs];
|
||
const chunks: Array<Array<{ id: string; description: string; phase: number; status: string }>> = [];
|
||
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<number, string[]>();
|
||
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;
|
||
}
|
||
}
|
||
} |