import * as fs from "node:fs"; import * as path from "node:path"; import { BaseAgent, AgentContext, AgentResult } from "./base.js"; interface PlanCheckResult { type: "missing_section" | "task_id_gap" | "missing_must_haves" | "wave_order_invalid" | "uncovered_requirement"; severity: "P0" | "P1" | "P2"; description: string; taskId?: string; } const REQUIRED_SECTIONS = ["# Phase", "## Phase Goal", "## Plans"]; export class PlanCheckerAgent extends BaseAgent { readonly name = "plan-checker"; readonly description = "Verifies plan quality. On ISSUES FOUND, triggers automatic plan revision (up to 3 iterations)."; readonly workflow = "plan"; async execute(context: AgentContext): Promise { const start = Date.now(); this.log("Checking plan quality..."); if (context.backend) { const result = await this.executeViaBackend( context, `Verify plan quality for phase ${context.phase}. Specification: ${context.specification}` ); return { ...result, duration_ms: Date.now() - start }; } const planPath = path.join(context.project_path, ".ciagent", "PLAN.md"); let planContent = ""; if (fs.existsSync(planPath)) { planContent = fs.readFileSync(planPath, "utf-8"); } const results = this.mechanicalPlanCheck(context.project_path, planContent); const p0Count = results.filter((r) => r.severity === "P0").length; const output = this.formatResults(results); return { success: p0Count === 0, output, artifacts_created: [], decisions: 0, escalations: p0Count, duration_ms: Date.now() - start, error: p0Count > 0 ? `${p0Count} P0 issue(s) found` : undefined, }; } mechanicalPlanCheck(projectPath: string, planContent: string): PlanCheckResult[] { const results: PlanCheckResult[] = []; this.checkStructure(planContent, results); this.checkTaskIds(planContent, results); this.checkMustHavesPresent(planContent, results); this.checkWaveOrdering(planContent, results); this.checkRequirementCoverage(projectPath, planContent, results); return results; } checkStructure(planContent: string, results: PlanCheckResult[]): void { for (const section of REQUIRED_SECTIONS) { if (!planContent.includes(section)) { results.push({ type: "missing_section", severity: "P0", description: `Plan is missing required section: ${section}`, }); } } } checkTaskIds(planContent: string, results: PlanCheckResult[]): void { const taskIdRegex = /###?\s+Task\s+[\d.]+[:\s]+T?([\d.]+)/gi; const ids: number[] = []; let match; while ((match = taskIdRegex.exec(planContent)) !== null) { const idParts = match[1].split("."); const taskId = parseInt(idParts[idParts.length - 1], 10); if (!isNaN(taskId)) ids.push(taskId); } if (ids.length === 0) return; for (let i = 1; i <= Math.max(...ids); i++) { if (!ids.includes(i)) { results.push({ type: "task_id_gap", severity: "P1", description: `Task ID gap: missing Task ${i}`, taskId: `T${i}`, }); } } } checkMustHavesPresent(planContent: string, results: PlanCheckResult[]): void { const taskRegex = /###?\s+Task[^]*?(?=###?\s+Task|$)/g; const taskBlocks = planContent.match(taskRegex) || []; for (const block of taskBlocks) { const headerMatch = block.match(/###?\s+Task\s+([\d.]+)/); if (!headerMatch) continue; const taskId = headerMatch[1]; const hasMustHaves = /must.haves|acceptance.criteria|must.?have/i.test(block); if (!hasMustHaves) { results.push({ type: "missing_must_haves", severity: "P1", description: `Task ${taskId} is missing must-haves/acceptance criteria`, taskId, }); } } } checkWaveOrdering(planContent: string, results: PlanCheckResult[]): void { const waveRegex = /##?\s+Wave\s+(\d+)/gi; const waves: number[] = []; let match; while ((match = waveRegex.exec(planContent)) !== null) { waves.push(parseInt(match[1], 10)); } for (let i = 1; i < waves.length; i++) { if (waves[i] < waves[i - 1]) { results.push({ type: "wave_order_invalid", severity: "P0", description: `Wave ordering invalid: Wave ${waves[i]} appears after Wave ${waves[i - 1]}`, }); } } } checkRequirementCoverage(projectPath: string, planContent: string, results: PlanCheckResult[]): void { const reqPath = path.join(projectPath, ".ciagent", "REQUIREMENTS.md"); if (!fs.existsSync(reqPath)) return; const reqContent = fs.readFileSync(reqPath, "utf-8"); const reqIdRegex = /REQ-(\d+)/g; const requirements = new Set(); let reqMatch; while ((reqMatch = reqIdRegex.exec(reqContent)) !== null) { requirements.add(`REQ-${reqMatch[1]}`); } const planReqIdRegex = /REQ-(\d+)/g; const coveredReqs = new Set(); let planMatch; while ((planMatch = planReqIdRegex.exec(planContent)) !== null) { coveredReqs.add(`REQ-${planMatch[1]}`); } for (const req of requirements) { if (!coveredReqs.has(req)) { results.push({ type: "uncovered_requirement", severity: "P2", description: `Requirement ${req} not covered in plan`, }); } } } private formatResults(results: PlanCheckResult[]): string { if (results.length === 0) return "Plan check passed — no issues found."; const lines: string[] = ["Plan Check Results:", ""]; for (const r of results) { lines.push(`[${r.type}|${r.severity}] ${r.description}${r.taskId ? ` (task: ${r.taskId})` : ""}`); } return lines.join("\n"); } }