import * as fs from "node:fs"; import * as path from "node:path"; import { writeFile, readFile, ensureDir, fileExists } from "../utils/file.js"; import { PipelineStage } from "../types/pipeline.js"; import { MilestoneType } from "../types/config.js"; const CI_DIR = ".ciagent"; export interface ProjectMd { name: string; coreValue: string; requirements: { validated: string[]; active: string[]; outOfScope: string[]; }; constraints: string[]; context: string; keyDecisions: Array<{ decision: string; rationale: string; outcome: string; }>; } export interface RoadmapMd { overview: string; phases: Array<{ number: number; name: string; description: string; status: "not_started" | "in_progress" | "complete" | "deferred"; dependsOn: number[]; requirements: string[]; successCriteria: string[]; }>; } export interface RequirementsMd { v1: Array<{ category: string; items: Array<{ id: string; description: string }>; }>; v2: Array<{ category: string; items: Array<{ id: string; description: string }>; }>; outOfScope: Array<{ feature: string; reason: string }>; traceability: Array<{ requirement: string; phase: number; status: "pending" | "in_progress" | "complete" | "blocked"; }>; } export interface ArchitectureMd { overview: string; components: Array<{ name: string; description: string; boundaries: string; dependsOn: string[]; }>; dataFlow: string; buildOrder: string[]; } export interface ProjectEntry { slug: string; name: string; default?: boolean; } export class CIAgentFiles { private projectPath: string; private projectSlug: string; constructor(projectPath: string, projectSlug?: string) { this.projectPath = projectPath; this.projectSlug = projectSlug || ""; } private get ciDir(): string { return path.join(this.projectPath, CI_DIR); } private get projectDir(): string { if (this.projectSlug) { return path.join(this.ciDir, this.projectSlug); } return this.ciDir; } setProjectSlug(slug: string): void { this.projectSlug = slug; } getProjectSlug(): string { return this.projectSlug; } ensureCIDir(): void { ensureDir(this.ciDir); } ensureProjectDir(): void { this.ensureCIDir(); if (this.projectSlug) { ensureDir(this.projectDir); } } isInitialized(): boolean { return fileExists(path.join(this.ciDir, "config.json")); } isMultiProject(): boolean { if (!this.isInitialized()) return false; const config = this.readConfigJson(); const projects = config?.projects; return Array.isArray(projects) && (projects as unknown[]).length > 0; } listProjects(): ProjectEntry[] { if (!this.isInitialized()) return []; const config = this.readConfigJson(); if (Array.isArray(config?.projects) && config.projects.length > 0) { return config.projects; } const subdirs = this.getProjectSubdirectories(); if (subdirs.length > 0) { return subdirs.map((slug) => { const projMd = this.readProjectMdForSlug(slug); return { slug, name: projMd?.name || slug, default: subdirs.length === 1, }; }); } return [{ slug: "default", name: "Default Project", default: true }]; } getActiveProject(): string { if (!this.isInitialized()) return ""; const config = this.readConfigJson(); if (config && typeof config.active_project === "string") return config.active_project; const projects = this.listProjects(); const defaultProject = projects.find((p) => p.default); if (defaultProject) return defaultProject.slug; return projects.length > 0 ? projects[0].slug : ""; } setActiveProject(slug: string): void { this.ensureCIDir(); const config = this.readConfigJson() || {}; config.active_project = slug; this.writeConfigJson(config); } addProject(slug: string, name: string, isDefault: boolean = false): void { this.ensureCIDir(); const config = this.readConfigJson() || {}; if (!Array.isArray(config.projects)) { config.projects = []; } if ((config.projects as unknown[]).some((p: unknown) => (p as ProjectEntry).slug === slug)) return; (config.projects as ProjectEntry[]).push({ slug, name, default: isDefault }); if (isDefault || (config.projects as unknown[]).length === 1) { config.active_project = slug; } this.writeConfigJson(config); ensureDir(path.join(this.ciDir, slug)); } needsMigration(): boolean { if (!this.isInitialized()) return false; if (this.isMultiProject()) return false; const hasFlatFiles = fileExists(path.join(this.ciDir, "PROJECT.md")); const hasSubdirs = this.getProjectSubdirectories().length > 0; return hasFlatFiles && !hasSubdirs; } migrateFlatToProject(slug: string): void { if (!this.needsMigration()) return; this.ensureCIDir(); const projectDir = path.join(this.ciDir, slug); ensureDir(projectDir); const filesToMove = ["PROJECT.md", "ARCHITECTURE.md", "ROADMAP.md", "REQUIREMENTS.md"]; for (const file of filesToMove) { const src = path.join(this.ciDir, file); const dest = path.join(projectDir, file); if (fileExists(src) && !fileExists(dest)) { const content = readFile(src); if (content) { writeFile(dest, content); } } } const config = this.readConfigJson() || {}; config.projects = [{ slug, name: slug, default: true }]; config.active_project = slug; this.writeConfigJson(config); } private getProjectSubdirectories(): string[] { if (!fs.existsSync(this.ciDir)) return []; try { return fs.readdirSync(this.ciDir, { withFileTypes: true }) .filter((d) => d.isDirectory()) .filter((d) => { const projectFile = path.join(this.ciDir, d.name, "PROJECT.md"); return fileExists(projectFile); }) .map((d) => d.name); } catch { return []; } } private readConfigJson(): Record | null { const content = readFile(path.join(this.ciDir, "config.json")); if (!content) return null; try { return JSON.parse(content) as Record; } catch { return null; } } private writeConfigJson(config: Record): void { writeFile(path.join(this.ciDir, "config.json"), JSON.stringify(config, null, 2)); } private readProjectMdForSlug(slug: string): ProjectMd | null { const content = readFile(path.join(this.ciDir, slug, "PROJECT.md")); if (!content) return null; return this.parseProjectMd(content); } readProjectMd(): ProjectMd | null { const content = readFile(path.join(this.projectDir, "PROJECT.md")); if (!content) return null; return this.parseProjectMd(content); } writeProjectMd(project: ProjectMd, reason: string): void { this.ensureProjectDir(); const lines: string[] = [ `# ${project.name}`, "", "## What This Is", "", project.coreValue, "", "## Requirements", "", "### Validated", "", ...project.requirements.validated.map((r) => `- ✓ ${r}`), "", "### Active", "", ...project.requirements.active.map((r) => `- [ ] ${r}`), "", "### Out of Scope", "", ...project.requirements.outOfScope.map((r) => `- ${r}`), "", "## Context", "", project.context, "", "## Constraints", "", ...project.constraints.map((c) => `- ${c}`), "", "## Key Decisions", "", "| Decision | Rationale | Outcome |", "|----------|-----------|---------|", ...project.keyDecisions.map( (d) => `| ${d.decision} | ${d.rationale} | ${d.outcome} |` ), "", ]; writeFile(path.join(this.projectDir, "PROJECT.md"), lines.join("\n")); } readRoadmapMd(): RoadmapMd | null { const content = readFile(path.join(this.projectDir, "ROADMAP.md")); if (!content) return null; return this.parseRoadmapMd(content); } writeRoadmapMd(roadmap: RoadmapMd): void { this.ensureProjectDir(); const lines: string[] = [ "# Roadmap", "", "## Overview", "", roadmap.overview, "", "## Phases", "", ...roadmap.phases.map( (p) => `- [${p.status === "complete" ? "x" : " "}] **Phase ${p.number}: ${p.name}** - ${p.description}` ), "", "## Phase Details", "", ]; for (const phase of roadmap.phases) { lines.push(`### Phase ${phase.number}: ${phase.name}`); lines.push(`**Goal**.: ${phase.description}`); lines.push(`**Depends on**: ${phase.dependsOn.length > 0 ? phase.dependsOn.map((d) => `Phase ${d}`).join(", ") : "Nothing"}`); lines.push(`**Requirements**: ${phase.requirements.length > 0 ? phase.requirements.join(", ") : "None"}`); lines.push("**Success Criteria**:"); for (const sc of phase.successCriteria) { lines.push(`1. ${sc}`); } lines.push(`**Status**: ${phase.status}`); lines.push(""); } writeFile(path.join(this.projectDir, "ROADMAP.md"), lines.join("\n")); } readRequirementsMd(): RequirementsMd | null { const content = readFile(path.join(this.projectDir, "REQUIREMENTS.md")); if (!content) return null; return this.parseRequirementsMd(content); } writeRequirementsMd(requirements: RequirementsMd): void { this.ensureProjectDir(); const lines: string[] = [ "# Requirements", "", "## v1 Requirements", "", ]; for (const cat of requirements.v1) { lines.push(`### ${cat.category}`); lines.push(""); for (const item of cat.items) { lines.push(`- [ ] **${item.id}**: ${item.description}`); } lines.push(""); } lines.push("## v2 Requirements"); lines.push(""); for (const cat of requirements.v2) { lines.push(`### ${cat.category}`); lines.push(""); for (const item of cat.items) { lines.push(`- **${item.id}**: ${item.description}`); } lines.push(""); } lines.push("## Out of Scope"); lines.push(""); lines.push("| Feature | Reason |"); lines.push("|---------|--------|"); for (const item of requirements.outOfScope) { lines.push(`| ${item.feature} | ${item.reason} |`); } lines.push(""); lines.push("## Traceability"); lines.push(""); lines.push("| Requirement | Phase | Status |"); lines.push("|-------------|-------|--------|"); for (const t of requirements.traceability) { lines.push(`| ${t.requirement} | Phase ${t.phase} | ${t.status} |`); } writeFile(path.join(this.projectDir, "REQUIREMENTS.md"), lines.join("\n")); } readArchitectureMd(): ArchitectureMd | null { const content = readFile(path.join(this.projectDir, "ARCHITECTURE.md")); if (!content) return null; return this.parseArchitectureMd(content); } writeArchitectureMd(architecture: ArchitectureMd): void { this.ensureProjectDir(); const lines: string[] = [ "# Architecture", "", "## Overview", "", architecture.overview, "", "## Components", "", ]; for (const comp of architecture.components) { lines.push(`### ${comp.name}`); lines.push(`- **Description**: ${comp.description}`); lines.push(`- **Boundaries**: ${comp.boundaries}`); lines.push(`- **Depends on**: ${comp.dependsOn.length > 0 ? comp.dependsOn.join(", ") : "None"}`); lines.push(""); } lines.push("## Data Flow"); lines.push(""); lines.push(architecture.dataFlow); lines.push(""); lines.push("## Build Order"); lines.push(""); for (const step of architecture.buildOrder) { lines.push(`1. ${step}`); } writeFile(path.join(this.projectDir, "ARCHITECTURE.md"), lines.join("\n")); } updateRequirementStatus(reqId: string, status: "pending" | "in_progress" | "complete" | "blocked"): void { const reqs = this.readRequirementsMd(); if (!reqs) return; for (const t of reqs.traceability) { if (t.requirement === reqId) { t.status = status; } } this.writeRequirementsMd(reqs); } updatePhaseStatus(phaseNumber: number, status: "not_started" | "in_progress" | "complete" | "deferred"): void { const roadmap = this.readRoadmapMd(); if (!roadmap) return; for (const phase of roadmap.phases) { if (phase.number === phaseNumber) { phase.status = status; } } this.writeRoadmapMd(roadmap); } getMilestoneType(): MilestoneType { const roadmap = this.readRoadmapMd(); if (!roadmap) return "nfr"; const nfrTypes: string[] = ["fix", "chore", "docs", "perf", "refactor", "test"]; const schemaBreakKeywords: string[] = ["refactor", "rewrite", "rearchitecture", "migrate", "restructure"]; let hasFeature = false; let hasSchemaBreak = false; for (const phase of roadmap.phases) { if (phase.status === "in_progress" || phase.status === "not_started" || phase.status === "complete") { const phaseName = phase.name.toLowerCase(); const isNfr = nfrTypes.some((t) => phaseName.includes(t)) || phaseName.includes("bug") || phaseName.includes("tune") || phaseName.includes("refresh"); if (!isNfr) hasFeature = true; if (schemaBreakKeywords.some((k) => phaseName.includes(k))) hasSchemaBreak = true; } } if (hasSchemaBreak) return "schema-breaking"; if (hasFeature) return "feature"; return "nfr"; } isNfrMilestone(): boolean { return this.getMilestoneType() === "nfr"; } private parseProjectMd(content: string): ProjectMd { return { name: this.extractSection(content, "# ") || "Unknown", coreValue: this.extractSection(content, "## What This Is") || "", requirements: { validated: this.extractListItems(content, "### Validated"), active: this.extractListItems(content, "### Active"), outOfScope: this.extractListItems(content, "### Out of Scope"), }, constraints: this.extractListItems(content, "## Constraints"), context: this.extractSection(content, "## Context") || "", keyDecisions: [], }; } private parseRoadmapMd(content: string): RoadmapMd { const overview = this.extractSection(content, "## Overview") || ""; const phases: RoadmapMd["phases"] = []; const phaseRegex = /### Phase (\d+): (.+)/g; let match; while ((match = phaseRegex.exec(content)) !== null) { const number = parseInt(match[1], 10); const name = match[2].trim(); const sectionStart = match.index + match[0].length; const nextPhase = content.indexOf("\n### Phase ", sectionStart); const nextH2 = content.indexOf("\n## ", sectionStart); const sectionEnd = Math.min( nextPhase >= 0 ? nextPhase : content.length, nextH2 >= 0 ? nextH2 : content.length ); const section = content.slice(sectionStart, sectionEnd); const goalMatch = section.match(/\*\*Goal\.?\*\*:\s*(.+)/); const statusMatch = section.match(/\*\*Status\*\*:\s*(.+)/); const reqMatch = section.match(/\*\*Requirements\*\*:\s*(.+)/); const depsMatch = section.match(/\*\*Depends on\*\*:\s*(.+)/); const statusVal = statusMatch ? statusMatch[1].trim() : "not_started"; const validStatuses = ["not_started", "in_progress", "complete", "deferred"] as const; phases.push({ number, name, description: goalMatch ? goalMatch[1].trim() : "", status: validStatuses.includes(statusVal as typeof validStatuses[number]) ? (statusVal as RoadmapMd["phases"][number]["status"]) : "not_started", dependsOn: depsMatch && depsMatch[1].trim() !== "Nothing" ? depsMatch[1].split(",").map((d: string) => parseInt(d.trim().replace(/Phase /g, ""), 10)).filter((n: number) => !isNaN(n)) : [], requirements: reqMatch && reqMatch[1].trim() !== "None" ? reqMatch[1].split(",").map((r: string) => r.trim()).filter(Boolean) : [], successCriteria: [], }); } return { overview, phases }; } private parseRequirementsMd(content: string): RequirementsMd { const v1: RequirementsMd["v1"] = []; const v2: RequirementsMd["v2"] = []; const v1Section = this.extractSection(content, "## v1 Requirements"); if (v1Section) { const categoryBlocks = v1Section.split(/\n### /).filter(Boolean); for (const block of categoryBlocks) { const lines = block.split("\n"); const category = lines[0].trim(); const items: Array<{ id: string; description: string }> = []; for (const line of lines.slice(1)) { const tableMatch = line.match(/^\|\s*([A-Z]+-\d+)\s*\|\s*(.+?)\s*\|/); if (tableMatch) { items.push({ id: tableMatch[1], description: tableMatch[2] }); continue; } const listMatch = line.match(/^\s*-?\s*\*?\s*\[?\s*\*?\s*([A-Z]+-\d+)[\]:\s*]*(.+)/); if (listMatch) { items.push({ id: listMatch[1], description: listMatch[2].trim() }); } } if (items.length > 0) { v1.push({ category, items }); } } } const v2Section = this.extractSection(content, "## v2 Requirements"); if (v2Section) { const categoryBlocks = v2Section.split(/\n### /).filter(Boolean); for (const block of categoryBlocks) { const lines = block.split("\n"); const category = lines[0].trim(); const items: Array<{ id: string; description: string }> = []; for (const line of lines.slice(1)) { const tableMatch = line.match(/^\|\s*([A-Z]+-\d+)\s*\|\s*(.+?)\s*\|/); if (tableMatch) { items.push({ id: tableMatch[1], description: tableMatch[2] }); continue; } const listMatch = line.match(/^\s*-?\s*\*?\s*\[?\s*\*?\s*([A-Z]+-\d+)[\]:\s*]*(.+)/); if (listMatch) { items.push({ id: listMatch[1], description: listMatch[2].trim() }); } } if (items.length > 0) { v2.push({ category, items }); } } } const outOfScope: RequirementsMd["outOfScope"] = []; const outSection = this.extractSection(content, "## Out of Scope"); if (outSection) { const tableRows = outSection.split("\n").filter((line) => /^\|/.test(line) && !line.includes("---") && !line.includes("Feature")); for (const row of tableRows) { const cols = row.split("|").map((c) => c.trim()).filter(Boolean); if (cols.length >= 2) { outOfScope.push({ feature: cols[0], reason: cols[1] }); } } if (outOfScope.length === 0) { const listItems = this.extractListItems(content, "## Out of Scope"); for (const item of listItems) { outOfScope.push({ feature: item, reason: "" }); } } } const traceability: RequirementsMd["traceability"] = []; const traceSection = this.extractSection(content, "## Traceability"); if (traceSection) { const activeHeader = traceSection.includes("Active Milestone") ? "## v0.5 Requirements (Active Milestone)" : content.includes("## v1 Requirements") ? "## v1 Requirements" : undefined; const tableRows = traceSection.split("\n").filter((line) => /^\|/.test(line) && !line.includes("---") && !line.includes("Requirement") && !line.includes("REQ-ID")); for (const row of tableRows) { const cols = row.split("|").map((c) => c.trim()).filter(Boolean); if (cols.length >= 3) { const req = cols[0]; const phaseStr = cols[1]; const phaseMatch = phaseStr.match(/(\d+)/); const phase = phaseMatch ? parseInt(phaseMatch[1], 10) : 0; const statusStr = cols[2].toLowerCase(); const status = ["pending", "in_progress", "complete", "blocked", "covered"].includes(statusStr) ? (statusStr === "covered" ? "complete" : statusStr as "pending" | "in_progress" | "complete" | "blocked") : "pending"; traceability.push({ requirement: req, phase, status }); } } } const allReqIds = new Set(); for (const cat of [...v1, ...v2]) { for (const item of cat.items) { allReqIds.add(item.id); } } for (const t of traceability) { allReqIds.add(t.requirement); } const coveredInTrace = new Set(traceability.filter((t) => t.status === "complete").map((t) => t.requirement)); for (const reqId of allReqIds) { if (!coveredInTrace.has(reqId)) { traceability.push({ requirement: reqId, phase: 0, status: "pending" }); } } return { v1, v2, outOfScope, traceability }; } private parseArchitectureMd(content: string): ArchitectureMd { const overview = this.extractSection(content, "## Overview") || ""; const components: ArchitectureMd["components"] = []; const section = content; const componentRegex = /###\s+(.+)/g; let compMatch; const h3Positions: Array<{ name: string; start: number }> = []; while ((compMatch = componentRegex.exec(section)) !== null) { h3Positions.push({ name: compMatch[1].trim(), start: compMatch.index + compMatch[0].length }); } for (let i = 0; i < h3Positions.length; i++) { const name = h3Positions[i].name; const start = h3Positions[i].start; const end = i + 1 < h3Positions.length ? h3Positions[i + 1].start - (content.substring(h3Positions[i + 1].start - 4, h3Positions[i + 1].start) === "### " ? 4 : 0) : content.length; const block = content.slice(start, end); const descMatch = block.match(/[-*]\s*\*?\*?(?:Description|description)\*?\*?\s*[::]\s*(.+)/); const boundaryMatch = block.match(/[-*]\s*\*?\*?(?:Boundaries|boundaries)\*?\*?\s*[::]\s*(.+)/); const depsMatch = block.match(/[-*]\s*\*?\*?(?:Depends on|depends on|Dependencies)\*?\*?\s*[::]\s*(.+)/); components.push({ name, description: descMatch ? descMatch[1].trim() : "", boundaries: boundaryMatch ? boundaryMatch[1].trim() : "", dependsOn: depsMatch ? depsMatch[1].split(",").map((d: string) => d.trim().replace(/\*\*/g, "")).filter(Boolean) : [], }); } const dataFlow = this.extractSection(content, "## Data Flow") || this.extractSection(content, "## Data flow") || ""; const buildOrder: string[] = []; const buildSection = this.extractSection(content, "## Build Order"); if (buildSection) { const listItems = buildSection .split("\n") .filter((line) => /^\d+\./.test(line.trim())) .map((line) => line.trim().replace(/^\d+\.\s*/, "")); buildOrder.push(...listItems); } return { overview, components, dataFlow, buildOrder }; } private extractSection(content: string, header: string): string | null { const headerIdx = content.indexOf(header); if (headerIdx < 0) return null; const startIdx = headerIdx + header.length; const nextHeaderIdx = content.indexOf("\n## ", startIdx); const endIdx = nextHeaderIdx >= 0 ? nextHeaderIdx : content.length; return content.slice(startIdx, endIdx).trim(); } private extractListItems(content: string, header: string): string[] { const section = this.extractSection(content, header); if (!section) return []; return section .split("\n") .filter((line) => line.trim().startsWith("-")) .map((line) => line.replace(/^-\s*(?:\[[ x]\]\s*)?(?:✓\s*)?/, "").trim()) .filter(Boolean); } }