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"; const CI_DIR = ".ci"; 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 CiFiles { 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); } isNfrMilestone(): boolean { const roadmap = this.readRoadmapMd(); if (!roadmap) return true; const nfrTypes: string[] = ["fix", "chore", "docs", "perf", "refactor", "test"]; for (const phase of roadmap.phases) { if (phase.status === "in_progress" || phase.status === "not_started") { const phaseName = phase.name.toLowerCase(); const hasFeature = !nfrTypes.some((t) => phaseName.includes(t)) && !phaseName.includes("bug") && !phaseName.includes("tune") && !phaseName.includes("refresh"); if (hasFeature) return false; } } return true; } 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 { return { v1: [], v2: [], outOfScope: [], traceability: [], }; } private parseArchitectureMd(content: string): ArchitectureMd { return { overview: this.extractSection(content, "## Overview") || "", components: [], dataFlow: this.extractSection(content, "## Data Flow") || "", 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); } }