feat(P03): core agent flesh — VerifierAgent, ResearcherAgent, TesterAgent intrinsic logic

This commit is contained in:
Jon Chery
2026-05-29 18:08:38 +00:00
parent 99df4fe4e2
commit bbabd2dc0a
10 changed files with 1179 additions and 42 deletions
+317 -9
View File
@@ -1,4 +1,27 @@
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";
@@ -8,21 +31,306 @@ export class PlannerAgent extends BaseAgent {
async execute(context: AgentContext): Promise<AgentResult> {
const start = Date.now();
this.log("Creating phase plan...");
if (context.backend) {
const result = await this.executeViaBackend(
context,
`Create a phase plan for stage ${context.stage}, phase ${context.phase}. Specification: ${context.specification}`
);
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: false,
output: "Planning requires an intelligence backend. Configure one with: ci init --backend",
artifacts_created: [],
decisions: 0,
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,
error: "No intelligence backend available",
};
}
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) => `Implement ${r.id}: ${r.description.split(": ").slice(1).join(": ") || r.description}`),
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) => `Implement ${r.id}: ${r.description.split(": ").slice(1).join(": ") || r.description}`),
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;
}
}
}