feat(P03): multi-project support, NFR milestone versioning, phase context reset, install scripts (v0.3.0)

This commit is contained in:
CI
2026-05-29 15:13:45 +00:00
parent e4bb3a9970
commit ddf04792c7
57 changed files with 1748 additions and 59 deletions
+244 -18
View File
@@ -64,33 +64,204 @@ export interface ArchitectureMd {
buildOrder: string[];
}
export interface ProjectEntry {
slug: string;
name: string;
default?: boolean;
}
export class CiFiles {
private projectPath: string;
private projectSlug: string;
constructor(projectPath: 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<string, unknown> | null {
const content = readFile(path.join(this.ciDir, "config.json"));
if (!content) return null;
try {
return JSON.parse(content) as Record<string, unknown>;
} catch {
return null;
}
}
private writeConfigJson(config: Record<string, unknown>): 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.ciDir, "PROJECT.md"));
const content = readFile(path.join(this.projectDir, "PROJECT.md"));
if (!content) return null;
return this.parseProjectMd(content);
}
writeProjectMd(project: ProjectMd, reason: string): void {
this.ensureCIDir();
this.ensureProjectDir();
const lines: string[] = [
`# ${project.name}`,
"",
@@ -130,17 +301,17 @@ export class CiFiles {
"",
];
writeFile(path.join(this.ciDir, "PROJECT.md"), lines.join("\n"));
writeFile(path.join(this.projectDir, "PROJECT.md"), lines.join("\n"));
}
readRoadmapMd(): RoadmapMd | null {
const content = readFile(path.join(this.ciDir, "ROADMAP.md"));
const content = readFile(path.join(this.projectDir, "ROADMAP.md"));
if (!content) return null;
return this.parseRoadmapMd(content);
}
writeRoadmapMd(roadmap: RoadmapMd): void {
this.ensureCIDir();
this.ensureProjectDir();
const lines: string[] = [
"# Roadmap",
"",
@@ -160,7 +331,7 @@ export class CiFiles {
for (const phase of roadmap.phases) {
lines.push(`### Phase ${phase.number}: ${phase.name}`);
lines.push(`**Goal**: ${phase.description}`);
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**:");
@@ -171,17 +342,17 @@ export class CiFiles {
lines.push("");
}
writeFile(path.join(this.ciDir, "ROADMAP.md"), lines.join("\n"));
writeFile(path.join(this.projectDir, "ROADMAP.md"), lines.join("\n"));
}
readRequirementsMd(): RequirementsMd | null {
const content = readFile(path.join(this.ciDir, "REQUIREMENTS.md"));
const content = readFile(path.join(this.projectDir, "REQUIREMENTS.md"));
if (!content) return null;
return this.parseRequirementsMd(content);
}
writeRequirementsMd(requirements: RequirementsMd): void {
this.ensureCIDir();
this.ensureProjectDir();
const lines: string[] = [
"# Requirements",
"",
@@ -226,17 +397,17 @@ export class CiFiles {
lines.push(`| ${t.requirement} | Phase ${t.phase} | ${t.status} |`);
}
writeFile(path.join(this.ciDir, "REQUIREMENTS.md"), lines.join("\n"));
writeFile(path.join(this.projectDir, "REQUIREMENTS.md"), lines.join("\n"));
}
readArchitectureMd(): ArchitectureMd | null {
const content = readFile(path.join(this.ciDir, "ARCHITECTURE.md"));
const content = readFile(path.join(this.projectDir, "ARCHITECTURE.md"));
if (!content) return null;
return this.parseArchitectureMd(content);
}
writeArchitectureMd(architecture: ArchitectureMd): void {
this.ensureCIDir();
this.ensureProjectDir();
const lines: string[] = [
"# Architecture",
"",
@@ -267,7 +438,7 @@ export class CiFiles {
lines.push(`1. ${step}`);
}
writeFile(path.join(this.ciDir, "ARCHITECTURE.md"), lines.join("\n"));
writeFile(path.join(this.projectDir, "ARCHITECTURE.md"), lines.join("\n"));
}
updateRequirementStatus(reqId: string, status: "pending" | "in_progress" | "complete" | "blocked"): void {
@@ -296,6 +467,21 @@ export class CiFiles {
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",
@@ -312,10 +498,50 @@ export class CiFiles {
}
private parseRoadmapMd(content: string): RoadmapMd {
return {
overview: this.extractSection(content, "## Overview") || "",
phases: [],
};
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 {