/** * Plan Orchestrate — Decompose plan documents into agent chain commands. * * Parses tagged sections from markdown plan documents and builds commands * for sequential agent chain execution based on ECC recommendations. */ import { randomUUID } from "node:crypto"; import * as fs from "node:fs"; import type { PlanRecord } from "../state/types.ts"; /** * Tag → Agent chain mapping from ECC recommendations. */ export const TAG_TO_CHAIN: Record = { design: ["planner", "architect"], impl: ["tdd-guide", "lang-reviewer"], security: ["security-reviewer", "lang-reviewer"], build: ["build-error-resolver"], test: ["test-engineer", "verifier"], review: ["reviewer"], } as const; /** * Options for plan orchestration. */ export interface OrchestrateOptions { /** Path to the plan markdown document. */ planPath: string; } /** * A single orchestrated step parsed from a plan section. */ export interface OrchestratedStep { /** Unique step identifier. */ stepId: string; /** Tag from the parsed section. */ tag: string; /** Agent chain for this step. */ chain: string[]; /** Prompt/goal text extracted from the section. */ prompt: string; /** Raw heading text if present. */ heading?: string; } /** * Parse tagged sections from a plan markdown document. * * Expected format: * ```markdown * # Design Phase * * Design the authentication system... * * # Implementation * * Implement the JWT auth... * ``` * * @param planPath - Path to the plan markdown document. * @returns Array of OrchestratedStep parsed from the document. */ export function parsePlanDocument(planPath: string): OrchestratedStep[] { if (!fs.existsSync(planPath)) { throw new Error(`Plan document not found: ${planPath}`); } const content = fs.readFileSync(planPath, "utf-8"); return parsePlanDocumentContent(content); } /** * Parse tagged sections from plan content string. * This is the core parsing logic used by both parsePlanDocument and parsePlanDocumentSimple. */ function parsePlanDocumentContent(content: string): OrchestratedStep[] { const steps: OrchestratedStep[] = []; // Find all tag matches with their positions const tagRegex = //g; const tagMatches: Array<{ tag: string; start: number; end: number }> = []; let match: RegExpExecArray | null; while ((match = tagRegex.exec(content)) !== null) { tagMatches.push({ tag: match[1], start: match.index, end: match.index + match[0].length, }); } if (tagMatches.length === 0) { return []; } // For each tag, extract the content before it (to get heading) // and the content after it until either another tag or section for (let i = 0; i < tagMatches.length; i++) { const current = tagMatches[i]; const nextTagStart = i < tagMatches.length - 1 ? tagMatches[i + 1].start : content.length; // Find the heading by looking back from the tag position. // Use global regex to find ALL headings in the window, then take the LAST // (nearest to the tag) — simple `.match()` only finds the FIRST heading. const textBeforeTag = content.slice(Math.max(0, current.start - 500), current.start); const headingRegex = /(^|\n)(#{1,6})\s+(.+?)(\n|$)/g; let lastHeadingMatch: RegExpExecArray | null = null; let hm: RegExpExecArray | null; while ((hm = headingRegex.exec(textBeforeTag)) !== null) { lastHeadingMatch = hm; } const heading = lastHeadingMatch ? lastHeadingMatch[3].trim() : undefined; // Get content after the tag until next tag or heading // Start from end of tag comment, skip any whitespace/newline const afterTagContent = content.slice(current.end); // Find the section content: capture until next heading (##) or next tag const sectionEndMatch = afterTagContent.search(/(^|\n)(#{1,6}\s|\n