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
+288
View File
@@ -44,6 +44,294 @@ describe("CiFiles", () => {
});
});
describe("projectSlug", () => {
it("defaults to empty string", () => {
const ciFiles = new CiFiles(dir);
expect(ciFiles.getProjectSlug()).toBe("");
});
it("uses provided project slug", () => {
const ciFiles = new CiFiles(dir, "task-api");
expect(ciFiles.getProjectSlug()).toBe("task-api");
});
it("setProjectSlug updates slug", () => {
const ciFiles = new CiFiles(dir);
ciFiles.setProjectSlug("auth-svc");
expect(ciFiles.getProjectSlug()).toBe("auth-svc");
});
});
describe("multi-project support", () => {
it("isMultiProject returns false when not initialized", () => {
const ciFiles = new CiFiles(dir);
expect(ciFiles.isMultiProject()).toBe(false);
});
it("isMultiProject returns false for single-project config", () => {
const ciFiles = new CiFiles(dir);
ciFiles.ensureCIDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({
projects: [{ slug: "default", name: "Default" }],
active_project: "default",
}));
expect(ciFiles.isMultiProject()).toBe(true);
});
it("isMultiProject returns false for config without projects array", () => {
const ciFiles = new CiFiles(dir);
ciFiles.ensureCIDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({}));
expect(ciFiles.isMultiProject()).toBe(false);
});
it("addProject adds a project to config", () => {
const ciFiles = new CiFiles(dir);
ciFiles.ensureCIDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({
projects: [],
active_project: "",
}));
ciFiles.addProject("task-api", "Task API", true);
const config = JSON.parse(fs.readFileSync(path.join(dir, ".ci", "config.json"), "utf-8"));
expect(config.projects).toHaveLength(1);
expect(config.projects[0].slug).toBe("task-api");
expect(config.active_project).toBe("task-api");
});
it("addProject does not duplicate existing project", () => {
const ciFiles = new CiFiles(dir);
ciFiles.ensureCIDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({
projects: [{ slug: "task-api", name: "Task API" }],
active_project: "task-api",
}));
ciFiles.addProject("task-api", "Task API V2");
const config = JSON.parse(fs.readFileSync(path.join(dir, ".ci", "config.json"), "utf-8"));
expect(config.projects).toHaveLength(1);
});
it("addProject creates project subdirectory", () => {
const ciFiles = new CiFiles(dir);
ciFiles.ensureCIDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({
projects: [],
active_project: "",
}));
ciFiles.addProject("task-api", "Task API", true);
expect(fs.existsSync(path.join(dir, ".ci", "task-api"))).toBe(true);
});
it("getActiveProject returns from config", () => {
const ciFiles = new CiFiles(dir);
ciFiles.ensureCIDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({
projects: [{ slug: "task-api", name: "Task API", default: true }],
active_project: "task-api",
}));
expect(ciFiles.getActiveProject()).toBe("task-api");
});
it("setActiveProject updates config", () => {
const ciFiles = new CiFiles(dir);
ciFiles.ensureCIDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({
projects: [
{ slug: "task-api", name: "Task API" },
{ slug: "auth-svc", name: "Auth Service" },
],
active_project: "task-api",
}));
ciFiles.setActiveProject("auth-svc");
const config = JSON.parse(fs.readFileSync(path.join(dir, ".ci", "config.json"), "utf-8"));
expect(config.active_project).toBe("auth-svc");
});
it("listProjects returns projects from config", () => {
const ciFiles = new CiFiles(dir);
ciFiles.ensureCIDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({
projects: [
{ slug: "task-api", name: "Task API", default: true },
{ slug: "auth-svc", name: "Auth Service" },
],
active_project: "task-api",
}));
const projects = ciFiles.listProjects();
expect(projects).toHaveLength(2);
expect(projects[0].slug).toBe("task-api");
expect(projects[1].slug).toBe("auth-svc");
});
});
describe("needsMigration", () => {
it("returns false when not initialized", () => {
const ciFiles = new CiFiles(dir);
expect(ciFiles.needsMigration()).toBe(false);
});
it("returns false when already multi-project", () => {
const ciFiles = new CiFiles(dir);
ciFiles.ensureCIDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({
projects: [{ slug: "default", name: "Default" }],
}));
fs.writeFileSync(path.join(dir, ".ci", "PROJECT.md"), "# Test");
expect(ciFiles.needsMigration()).toBe(false);
});
it("returns true when flat files exist without subdirs or multi-project config", () => {
const ciFiles = new CiFiles(dir);
ciFiles.ensureCIDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({}));
fs.writeFileSync(path.join(dir, ".ci", "PROJECT.md"), "# Test");
expect(ciFiles.needsMigration()).toBe(true);
});
it("returns false when flat files exist but subdirs also exist", () => {
const ciFiles = new CiFiles(dir);
ciFiles.ensureCIDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({}));
fs.writeFileSync(path.join(dir, ".ci", "PROJECT.md"), "# Test");
fs.mkdirSync(path.join(dir, ".ci", "task-api"));
fs.writeFileSync(path.join(dir, ".ci", "task-api", "PROJECT.md"), "# Task API");
expect(ciFiles.needsMigration()).toBe(false);
});
});
describe("migrateFlatToProject", () => {
it("moves flat files to project subdirectory", () => {
const ciFiles = new CiFiles(dir);
ciFiles.ensureCIDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({}));
fs.writeFileSync(path.join(dir, ".ci", "PROJECT.md"), "# Test Project");
fs.writeFileSync(path.join(dir, ".ci", "ARCHITECTURE.md"), "# Architecture");
fs.writeFileSync(path.join(dir, ".ci", "ROADMAP.md"), "# Roadmap");
fs.writeFileSync(path.join(dir, ".ci", "REQUIREMENTS.md"), "# Requirements");
ciFiles.migrateFlatToProject("my-app");
expect(fs.existsSync(path.join(dir, ".ci", "my-app", "PROJECT.md"))).toBe(true);
expect(fs.existsSync(path.join(dir, ".ci", "my-app", "ARCHITECTURE.md"))).toBe(true);
expect(fs.existsSync(path.join(dir, ".ci", "my-app", "ROADMAP.md"))).toBe(true);
expect(fs.existsSync(path.join(dir, ".ci", "my-app", "REQUIREMENTS.md"))).toBe(true);
const config = JSON.parse(fs.readFileSync(path.join(dir, ".ci", "config.json"), "utf-8"));
expect(config.projects).toHaveLength(1);
expect(config.active_project).toBe("my-app");
});
it("does not migrate when not needed", () => {
const ciFiles = new CiFiles(dir);
ciFiles.ensureCIDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({
projects: [{ slug: "existing", name: "Existing" }],
}));
ciFiles.migrateFlatToProject("new-proj");
const config = JSON.parse(fs.readFileSync(path.join(dir, ".ci", "config.json"), "utf-8"));
expect(config.projects).toHaveLength(1);
expect(config.projects[0].slug).toBe("existing");
});
});
describe("isNfrMilestone", () => {
it("returns true when no roadmap exists", () => {
const ciFiles = new CiFiles(dir);
expect(ciFiles.isNfrMilestone()).toBe(true);
});
it("returns true when phases are all NFR types", () => {
const ciFiles = new CiFiles(dir, "nfr-proj");
ciFiles.ensureProjectDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({
projects: [{ slug: "nfr-proj", name: "NFR Project", default: true }],
active_project: "nfr-proj",
}));
const roadmap: RoadmapMd = {
overview: "NFR-only",
phases: [
{ number: 1, name: "test-coverage", description: "Add tests", status: "in_progress", dependsOn: [], requirements: [], successCriteria: [] },
{ number: 2, name: "refactor-api", description: "Refactor", status: "not_started", dependsOn: [], requirements: [], successCriteria: [] },
],
};
ciFiles.writeRoadmapMd(roadmap);
expect(ciFiles.isNfrMilestone()).toBe(true);
});
it("returns false when phases include feature work", () => {
const ciFiles = new CiFiles(dir, "feat-proj");
ciFiles.ensureProjectDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({
projects: [{ slug: "feat-proj", name: "Feature Project", default: true }],
active_project: "feat-proj",
}));
const roadmap: RoadmapMd = {
overview: "mixed",
phases: [
{ number: 1, name: "authentication", description: "Auth feature", status: "in_progress", dependsOn: [], requirements: [], successCriteria: [] },
{ number: 2, name: "test-coverage", description: "Add tests", status: "not_started", dependsOn: [], requirements: [], successCriteria: [] },
],
};
ciFiles.writeRoadmapMd(roadmap);
expect(ciFiles.isNfrMilestone()).toBe(false);
});
});
describe("multi-project file paths", () => {
it("writes PROJECT.md to project subdirectory when slug is set", () => {
const ciFiles = new CiFiles(dir, "my-app");
ciFiles.ensureCIDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({
projects: [{ slug: "my-app", name: "My App", default: true }],
active_project: "my-app",
}));
const project: ProjectMd = {
name: "My App",
coreValue: "Build something cool",
requirements: { validated: [], active: [], outOfScope: [] },
constraints: [],
context: "Test context",
keyDecisions: [],
};
ciFiles.writeProjectMd(project, "initial");
expect(fs.existsSync(path.join(dir, ".ci", "my-app", "PROJECT.md"))).toBe(true);
});
it("writes PROJECT.md to .ci root when no slug is set", () => {
const ciFiles = new CiFiles(dir);
ciFiles.ensureCIDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({}));
const project: ProjectMd = {
name: "Default App",
coreValue: "Build something",
requirements: { validated: [], active: [], outOfScope: [] },
constraints: [],
context: "Test context",
keyDecisions: [],
};
ciFiles.writeProjectMd(project, "initial");
expect(fs.existsSync(path.join(dir, ".ci", "PROJECT.md"))).toBe(true);
});
});
describe("PROJECT.md", () => {
const project: ProjectMd = {
name: "Task API",
+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 {
+51
View File
@@ -13,6 +13,18 @@ describe("CommitBuilder", () => {
expect(block).toContain("status: execute");
});
it("builds ci block with project", () => {
const ci: CiMetadata = { phase: 1, milestone: "v1.0", status: "execute", project: "task-api" };
const block = CommitBuilder.buildCiBlock(ci);
expect(block).toContain("project: task-api");
});
it("builds ci block without project when not set", () => {
const ci: CiMetadata = { phase: 1, milestone: "v1.0", status: "execute" };
const block = CommitBuilder.buildCiBlock(ci);
expect(block).not.toContain("project:");
});
it("builds ci block with decisions", () => {
const ci: CiMetadata = {
phase: 1,
@@ -172,6 +184,16 @@ describe("CommitBuilder", () => {
expect(parsed.compound!.problem).toBe("Token replay attacks");
expect(parsed.lessons).toHaveLength(1);
});
it("round-trips project field", () => {
const ci: CiMetadata = { phase: 1, milestone: "v1.0", status: "execute", project: "task-api" };
const block = CommitBuilder.buildCiBlock(ci);
const fullMessage = `feat(task-api/P01): test\n\n---ci---\n${block}\n---/ci---`;
const extracted = extractCiBlock(fullMessage)!;
const parsed = parseCiBlock(extracted)!;
expect(parsed.project).toBe("task-api");
});
});
describe("buildInitCommit", () => {
@@ -193,6 +215,19 @@ describe("CommitBuilder", () => {
expect(msg).toContain("Build a REST API for task management");
expect(msg).toContain("AUTH-01");
});
it("builds an init commit message with project", () => {
const msg = CommitBuilder.buildInitCommit({
projectName: "task-api",
phaseCount: 4,
milestone: "v1.0",
project: "task-api",
specification: "Build a REST API",
requirements: ["AUTH-01"],
});
expect(msg).toContain("project: task-api");
});
});
describe("buildTaskCommit", () => {
@@ -223,6 +258,22 @@ describe("CommitBuilder", () => {
expect(msg).toContain("D-003");
expect(msg).toContain("AUTH-01");
});
it("builds a task commit with project prefix", () => {
const msg = CommitBuilder.buildTaskCommit({
type: "feat",
phase: 1,
milestone: "v1.0",
project: "task-api",
plan: "01-01",
task: "01-01-02",
subject: "registration endpoint",
status: "execute",
});
expect(msg).toContain("feat(task-api/P01-01-02):");
expect(msg).toContain("project: task-api");
});
});
describe("buildPhaseCompletionCommit", () => {
+6
View File
@@ -25,6 +25,7 @@ export interface InitCommitInput {
projectName: string;
phaseCount: number;
milestone: string;
project?: string;
specification: string;
requirements?: string[];
constraints?: string[];
@@ -36,6 +37,7 @@ export interface TaskCommitInput {
type: CommitType;
phase: number;
milestone: string;
project?: string;
plan: string;
task: string;
subject: string;
@@ -95,6 +97,7 @@ export class CommitBuilder {
lines.push(`phase: ${ci.phase}`);
lines.push(`milestone: ${ci.milestone}`);
if (ci.project) lines.push(`project: ${ci.project}`);
if (ci.plan) lines.push(`plan: ${ci.plan}`);
if (ci.task) lines.push(`task: ${ci.task}`);
@@ -162,6 +165,7 @@ export class CommitBuilder {
const ci: CiMetadata = {
phase: 0,
milestone: input.milestone,
project: input.project,
status: "specify",
decisions: input.decisions,
};
@@ -193,6 +197,7 @@ export class CommitBuilder {
const ci: CiMetadata = {
phase: input.phase,
milestone: input.milestone,
project: input.project,
plan: input.plan,
task: input.task,
status: input.status,
@@ -204,6 +209,7 @@ export class CommitBuilder {
phase: input.phase,
plan: input.plan,
task: input.task,
project: input.project,
isInit: false,
isMilestone: false,
};
+105
View File
@@ -4,6 +4,9 @@ import {
CommitEscalation,
CommitRequirements,
CommitCompoundMeta,
parseCommitScope,
formatCommitScope,
CommitScope,
} from "../types/commit-meta.js";
import {
extractCiBlock,
@@ -112,6 +115,19 @@ escalations:
All tests pass. Awaiting deploy approval.`;
const SAMPLE_PROJECT_COMMIT = `feat(task-api/P01-01-02): create registration endpoint
---ci---
phase: 1
milestone: v1.0
project: task-api
plan: 01-01
task: 01-01-02
status: execute
---/ci---
Registration endpoint for task-api project.`;
describe("extractCiBlock", () => {
it("extracts ---ci--- block from commit message", () => {
const block = extractCiBlock(SAMPLE_INIT_COMMIT);
@@ -192,6 +208,14 @@ describe("parseCiBlock", () => {
expect(meta.escalations![0].resolution).toBe("pending");
});
it("parses project field", () => {
const block = extractCiBlock(SAMPLE_PROJECT_COMMIT)!;
const meta = parseCiBlock(block)!;
expect(meta.project).toBe("task-api");
expect(meta.phase).toBe(1);
expect(meta.plan).toBe("01-01");
});
it("returns null for empty block", () => {
const meta = parseCiBlock("");
expect(meta).toBeNull();
@@ -249,4 +273,85 @@ describe("parseCommitMessage", () => {
const parsed = parseCommitMessage("pqr678", SAMPLE_TASK_COMMIT);
expect(parsed.body).toContain("POST /auth/register validates email and password");
});
it("parses commit with project-prefixed scope", () => {
const parsed = parseCommitMessage("stu901", SAMPLE_PROJECT_COMMIT);
expect(parsed.type).toBe("feat");
expect(parsed.scope).toBe("task-api/P01-01-02");
expect(parsed.ci!.project).toBe("task-api");
});
});
describe("parseCommitScope", () => {
it("parses init scope", () => {
const scope = parseCommitScope("init");
expect(scope.isInit).toBe(true);
expect(scope.phase).toBe(0);
});
it("parses milestone scope", () => {
const scope = parseCommitScope("milestone");
expect(scope.isMilestone).toBe(true);
expect(scope.phase).toBe(0);
});
it("parses simple phase scope", () => {
const scope = parseCommitScope("P01");
expect(scope.phase).toBe(1);
expect(scope.isInit).toBe(false);
expect(scope.isMilestone).toBe(false);
});
it("parses task scope with plan and task", () => {
const scope = parseCommitScope("P01-01-02");
expect(scope.phase).toBe(1);
expect(scope.plan).toBe("01-01");
expect(scope.task).toBe("01-01-02");
});
it("parses project-prefixed scope", () => {
const scope = parseCommitScope("task-api/P01-01-02");
expect(scope.project).toBe("task-api");
expect(scope.phase).toBe(1);
expect(scope.plan).toBe("01-01");
expect(scope.task).toBe("01-01-02");
});
it("does not treat P-prefixed scope as project-prefixed", () => {
const scope = parseCommitScope("P01-auth");
expect(scope.project).toBeUndefined();
expect(scope.phase).toBe(1);
});
});
describe("formatCommitScope", () => {
it("formats init scope", () => {
const scope: CommitScope = { phase: 0, isInit: true, isMilestone: false };
expect(formatCommitScope(scope)).toBe("init");
});
it("formats milestone scope", () => {
const scope: CommitScope = { phase: 0, isInit: false, isMilestone: true };
expect(formatCommitScope(scope)).toBe("milestone");
});
it("formats simple phase scope", () => {
const scope: CommitScope = { phase: 1, isInit: false, isMilestone: false };
expect(formatCommitScope(scope)).toBe("P01");
});
it("formats task scope", () => {
const scope: CommitScope = { phase: 1, plan: "01-01", task: "01-01-02", isInit: false, isMilestone: false };
expect(formatCommitScope(scope)).toBe("P01-01-02");
});
it("formats project-prefixed scope", () => {
const scope: CommitScope = { phase: 1, project: "task-api", plan: "01-01", task: "01-01-02", isInit: false, isMilestone: false };
expect(formatCommitScope(scope)).toBe("task-api/P01-01-02");
});
it("formats project-prefixed phase scope without plan/task", () => {
const scope: CommitScope = { phase: 2, project: "auth-svc", isInit: false, isMilestone: false };
expect(formatCommitScope(scope)).toBe("auth-svc/P02");
});
});
+3
View File
@@ -40,6 +40,9 @@ export function parseCiBlock(yaml: string): CiMetadata | null {
const statusMatch = yaml.match(/^status:\s*(.+)$/m);
if (statusMatch) result.status = statusMatch[1].trim() as CiMetadata["status"];
const projectMatch = yaml.match(/^project:\s*(.+)$/m);
if (projectMatch) result.project = projectMatch[1].trim();
result.decisions = parseDecisionsFromYaml(yaml);
result.escalations = parseEscalationsFromYaml(yaml);
result.requirements = parseRequirementsFromYaml(yaml);
+51
View File
@@ -45,6 +45,37 @@ describe("CI Config", () => {
expect(config.autonomy.max_revision_iterations).toBe(3);
expect(config.autonomy.escalation_hooks).toEqual(["deploy", "delete_data", "merge_to_main"]);
});
it("initializes with project slug", () => {
const config = initCI(tempDir, undefined, "task-api", "Task API");
expect(config.projects).toHaveLength(1);
expect(config.projects[0].slug).toBe("task-api");
expect(config.projects[0].name).toBe("Task API");
expect(config.projects[0].default).toBe(true);
expect(config.active_project).toBe("task-api");
});
it("does not re-add existing project slug", () => {
initCI(tempDir, undefined, "task-api", "Task API");
const config = initCI(tempDir, undefined, "task-api", "Task API V2");
expect(config.projects).toHaveLength(1);
});
it("defaults projects and active_project when no slug provided", () => {
const config = initCI(tempDir);
expect(config.projects).toEqual([]);
expect(config.active_project).toBe("");
});
it("preserves existing projects when adding new one", () => {
const config1 = initCI(tempDir, undefined, "task-api", "Task API");
const config2 = initCI(tempDir, {
...config1,
projects: [...config1.projects, { slug: "auth-svc", name: "Auth Service" }],
}, "auth-svc", "Auth Service");
expect(config2.projects).toHaveLength(2);
expect(config2.active_project).toBe("auth-svc");
});
});
describe("loadConfig", () => {
@@ -68,6 +99,13 @@ describe("CI Config", () => {
expect(config.git.auto_commit).toBe(true);
expect(config.git.branching_strategy).toBe("phase");
});
it("loads projects array from config", () => {
initCI(tempDir, undefined, "task-api", "Task API");
const config = loadConfig(tempDir);
expect(config.projects).toHaveLength(1);
expect(config.active_project).toBe("task-api");
});
});
describe("saveConfig", () => {
@@ -81,6 +119,19 @@ describe("CI Config", () => {
const loaded = loadConfig(tempDir);
expect(loaded.autonomy.level).toBe("guided");
});
it("saves and reloads config with projects", () => {
ensureCIDir(tempDir);
const config = {
...DEFAULT_CI_CONFIG,
projects: [{ slug: "my-app", name: "My App", default: true }],
active_project: "my-app",
};
saveConfig(tempDir, config);
const loaded = loadConfig(tempDir);
expect(loaded.projects).toHaveLength(1);
expect(loaded.active_project).toBe("my-app");
});
});
describe("isCIInitialized", () => {
+14 -1
View File
@@ -62,11 +62,24 @@ export function isCIInitialized(projectPath: string): boolean {
return fs.existsSync(ciDir) && fs.existsSync(configPath);
}
export function initCI(projectPath: string, config?: Partial<CIConfig>): CIConfig {
export function initCI(projectPath: string, config?: Partial<CIConfig>, projectSlug?: string, projectName?: string): CIConfig {
ensureCIDir(projectPath);
let projects = config?.projects || DEFAULT_CI_CONFIG.projects;
let activeProject = config?.active_project || DEFAULT_CI_CONFIG.active_project;
if (projectSlug) {
if (!projects.some((p) => p.slug === projectSlug)) {
projects = [...projects, { slug: projectSlug, name: projectName || projectSlug, default: projects.length === 0 }];
}
activeProject = projectSlug;
}
const fullConfig: CIConfig = {
...DEFAULT_CI_CONFIG,
...config,
projects,
active_project: activeProject,
autonomy: { ...DEFAULT_CI_CONFIG.autonomy, ...config?.autonomy },
parallelization: {
...DEFAULT_CI_CONFIG.parallelization,
+24
View File
@@ -53,6 +53,22 @@ describe("GitBranch", () => {
expect(result.name).toBe("phase/03-real-time-notifications");
});
it("creates project-prefixed phase branch when projectSlug is set", () => {
const gitBranch = new GitBranch(repoDir, "task-api");
const result = gitBranch.createPhaseBranch(1, "authentication");
expect(result.created).toBe(true);
expect(result.name).toBe("task-api/phase/01-authentication");
});
it("updates project prefix after setProjectSlug", () => {
const gitBranch = new GitBranch(repoDir);
gitBranch.setProjectSlug("auth-svc");
const result = gitBranch.createPhaseBranch(2, "token-rotation");
expect(result.name).toBe("auth-svc/phase/02-token-rotation");
});
});
describe("createMilestoneBranch", () => {
@@ -71,6 +87,14 @@ describe("GitBranch", () => {
const result = gitBranch.createMilestoneBranch("v1.0", "mvp");
expect(result.alreadyExisted).toBe(true);
});
it("creates project-prefixed milestone branch when projectSlug is set", () => {
const gitBranch = new GitBranch(repoDir, "task-api");
const result = gitBranch.createMilestoneBranch("v1.0", "mvp");
expect(result.created).toBe(true);
expect(result.name).toBe("task-api/milestone/v1.0-mvp");
});
});
describe("listPhases", () => {
+17 -4
View File
@@ -30,10 +30,21 @@ export interface MilestoneBranchInfo {
export class GitBranch {
private projectPath: string;
private gitContext: GitContext;
private projectSlug?: string;
constructor(projectPath: string) {
constructor(projectPath: string, projectSlug?: string) {
this.projectPath = projectPath;
this.gitContext = new GitContext(projectPath);
this.projectSlug = projectSlug;
this.gitContext = new GitContext(projectPath, projectSlug);
}
setProjectSlug(slug: string | undefined): void {
this.projectSlug = slug;
this.gitContext.setProjectSlug(slug);
}
private prefix(name: string): string {
return this.projectSlug ? `${this.projectSlug}/${name}` : name;
}
private git(args: string): string {
@@ -58,7 +69,8 @@ export class GitBranch {
createPhaseBranch(phaseNumber: number, phaseName: string): BranchCreateResult {
const padded = String(phaseNumber).padStart(2, "0");
const slug = this.slugify(phaseName);
const branchName = `phase/${padded}-${slug}`;
const baseName = `phase/${padded}-${slug}`;
const branchName = this.prefix(baseName);
const existing = this.gitContext.getBranches().find((b) => b.name === branchName);
if (existing) {
@@ -75,7 +87,8 @@ export class GitBranch {
createMilestoneBranch(version: string, milestoneName: string): BranchCreateResult {
const slug = this.slugify(milestoneName);
const branchName = `milestone/${version}-${slug}`;
const baseName = `milestone/${version}-${slug}`;
const branchName = this.prefix(baseName);
const existing = this.gitContext.getBranches().find((b) => b.name === branchName);
if (existing) {
+91
View File
@@ -56,6 +56,24 @@ describe("GitContext", () => {
});
});
describe("projectSlug", () => {
it("defaults to undefined", () => {
const ctx = new GitContext(repoDir);
expect(ctx.getProjectSlug()).toBeUndefined();
});
it("accepts project slug in constructor", () => {
const ctx = new GitContext(repoDir, "task-api");
expect(ctx.getProjectSlug()).toBe("task-api");
});
it("setProjectSlug updates slug", () => {
const ctx = new GitContext(repoDir);
ctx.setProjectSlug("auth-svc");
expect(ctx.getProjectSlug()).toBe("auth-svc");
});
});
describe("getRecentCommits", () => {
it("returns parsed commits with ci blocks", () => {
commit(repoDir, `docs(init): initialize project
@@ -187,5 +205,78 @@ lessons:
expect(milestoneBranches.length).toBeGreaterThanOrEqual(1);
expect(phaseBranches[0].phaseNumber).toBe(1);
});
it("strips project prefix when projectSlug is set", () => {
commit(repoDir, "initial");
execSync("git checkout -b task-api/phase/01-auth", { cwd: repoDir, stdio: "pipe" });
commit(repoDir, "feat: auth work");
const ctx = new GitContext(repoDir, "task-api");
const branches = ctx.getBranches();
const phaseBranches = branches.filter((b) => b.type === "phase");
expect(phaseBranches.length).toBeGreaterThanOrEqual(1);
expect(phaseBranches[0].phaseNumber).toBe(1);
expect(phaseBranches[0].name).toBe("task-api/phase/01-auth");
});
});
describe("detectProjectFromCommit", () => {
it("detects project from ci block project field", () => {
commit(repoDir, `feat(P01): task work
---ci---
phase: 1
milestone: v1.0
project: task-api
status: execute
---/ci---`);
const ctx = new GitContext(repoDir);
expect(ctx.detectProjectFromCommit()).toBe("task-api");
});
it("detects project from branch prefix", () => {
commit(repoDir, "initial");
execSync("git checkout -b auth-svc/phase/01-auth", { cwd: repoDir, stdio: "pipe" });
commit(repoDir, "feat: auth work");
const ctx = new GitContext(repoDir);
expect(ctx.detectProjectFromCommit()).toBe("auth-svc");
});
it("returns null when no project detected", () => {
commit(repoDir, "feat: some work");
const ctx = new GitContext(repoDir);
expect(ctx.detectProjectFromCommit()).toBeNull();
});
});
describe("isNfrMilestone", () => {
it("returns true when no feat commits exist", () => {
commit(repoDir, `chore(P01): cleanup
---ci---
phase: 1
milestone: v0.1.1
status: execute
---/ci---`);
const ctx = new GitContext(repoDir);
expect(ctx.isNfrMilestone()).toBe(true);
});
it("returns false when feat commits exist", () => {
commit(repoDir, `feat(P01): add feature
---ci---
phase: 1
milestone: v1.0
status: execute
---/ci---`);
const ctx = new GitContext(repoDir);
expect(ctx.isNfrMilestone()).toBe(false);
});
});
});
+43 -3
View File
@@ -27,9 +27,19 @@ export interface BranchInfo {
export class GitContext {
private projectPath: string;
private projectSlug?: string;
constructor(projectPath: string) {
constructor(projectPath: string, projectSlug?: string) {
this.projectPath = projectPath;
this.projectSlug = projectSlug;
}
setProjectSlug(slug: string | undefined): void {
this.projectSlug = slug;
}
getProjectSlug(): string | undefined {
return this.projectSlug;
}
private git(args: string): string {
@@ -98,14 +108,21 @@ export class GitContext {
merged: mergedBranches.has(cleanName),
};
const phaseMatch = cleanName.match(/^phase\/(\d+)-(.+)/);
let branchName = cleanName;
const projectPrefix = this.projectSlug ? `${this.projectSlug}/` : "";
if (projectPrefix && cleanName.startsWith(projectPrefix)) {
branchName = cleanName.slice(projectPrefix.length);
}
const phaseMatch = branchName.match(/^phase\/(\d+)-(.+)/);
if (phaseMatch) {
info.type = "phase";
info.phaseNumber = parseInt(phaseMatch[1], 10);
return info;
}
const milestoneMatch = cleanName.match(/^milestone\/(.+)/);
const milestoneMatch = branchName.match(/^milestone\/(.+)/);
if (milestoneMatch) {
info.type = "milestone";
info.milestone = milestoneMatch[1];
@@ -311,4 +328,27 @@ export class GitContext {
return commits;
}
detectProjectFromCommit(): string | null {
const commit = this.getLatestCiCommit();
if (commit?.ci?.project) return commit.ci.project;
const branches = this.getBranches();
for (const branch of branches) {
const projectMatch = branch.name.match(/^([a-z0-9-]+)\/(?:phase|milestone)\//);
if (projectMatch) return projectMatch[1];
}
return null;
}
isNfrMilestone(): boolean {
const commits = this.getRecentCommits(100);
for (const commit of commits) {
if (commit.type === "feat" && commit.ci) {
return false;
}
}
return true;
}
}
+21 -6
View File
@@ -20,6 +20,7 @@ export interface CommitScope {
phase: number;
plan?: string;
task?: string;
project?: string;
isInit: boolean;
isMilestone: boolean;
}
@@ -53,6 +54,7 @@ export interface CommitCompoundMeta {
export interface CiMetadata {
phase: number;
milestone: string;
project?: string;
plan?: string;
task?: string;
status: PipelineStage;
@@ -88,10 +90,19 @@ export function parseCommitScope(scope: string): CommitScope {
return { phase: 0, isInit: false, isMilestone: true };
}
const phaseMatch = scope.match(/^P(\d+)/);
let project: string | undefined;
let cleanScope = scope;
const projectMatch = scope.match(/^([a-z0-9-]+)\/(.+)$/);
if (projectMatch && !scope.startsWith("P")) {
project = projectMatch[1];
cleanScope = projectMatch[2];
}
const phaseMatch = cleanScope.match(/^P(\d+)/);
const phase = phaseMatch ? parseInt(phaseMatch[1], 10) : 0;
const parts = scope.split("-");
const parts = cleanScope.split("-");
let plan: string | undefined;
let task: string | undefined;
@@ -102,7 +113,7 @@ export function parseCommitScope(scope: string): CommitScope {
task = `${plan}-${parts[2]}`;
}
return { phase, plan, task, isInit: false, isMilestone: false };
return { phase, plan, task, project, isInit: false, isMilestone: false };
}
export function formatCommitScope(scope: CommitScope): string {
@@ -110,7 +121,11 @@ export function formatCommitScope(scope: CommitScope): string {
if (scope.isMilestone) return "milestone";
const phaseStr = `P${String(scope.phase).padStart(2, "0")}`;
if (scope.task) return `${phaseStr}-${scope.task.split("-").slice(1).join("-")}`;
if (scope.plan) return `${phaseStr}-${scope.plan.split("-").slice(1).join("-")}`;
return phaseStr;
let suffix: string;
if (scope.task) suffix = `${phaseStr}-${scope.task.split("-").slice(1).join("-")}`;
else if (scope.plan) suffix = `${phaseStr}-${scope.plan.split("-").slice(1).join("-")}`;
else suffix = phaseStr;
if (scope.project) return `${scope.project}/${suffix}`;
return suffix;
}
+51 -1
View File
@@ -1,4 +1,4 @@
import { CIConfig, DEFAULT_CI_CONFIG, AutonomyLevel, ModelProfile } from "../types/config.js";
import { CIConfig, DEFAULT_CI_CONFIG, AutonomyLevel, ModelProfile, ProjectEntry } from "../types/config.js";
describe("CIConfig", () => {
it("DEFAULT_CI_CONFIG has all required fields", () => {
@@ -17,6 +17,11 @@ describe("CIConfig", () => {
expect(DEFAULT_CI_CONFIG.git.auto_push).toBe(false);
});
it("DEFAULT_CI_CONFIG has multi-project fields", () => {
expect(DEFAULT_CI_CONFIG.projects).toEqual([]);
expect(DEFAULT_CI_CONFIG.active_project).toBe("");
});
it("AutonomyLevel accepts all valid levels", () => {
const levels: AutonomyLevel[] = ["full", "supervised", "guided"];
for (const level of levels) {
@@ -46,4 +51,49 @@ describe("CIConfig", () => {
"merge_to_main",
]);
});
describe("ProjectEntry", () => {
it("accepts valid project entries", () => {
const entry: ProjectEntry = { slug: "task-api", name: "Task API", default: true };
expect(entry.slug).toBe("task-api");
expect(entry.name).toBe("Task API");
expect(entry.default).toBe(true);
});
it("default field is optional", () => {
const entry: ProjectEntry = { slug: "task-api", name: "Task API" };
expect(entry.default).toBeUndefined();
});
});
describe("CIConfig with projects", () => {
it("supports multiple projects", () => {
const config: CIConfig = {
...DEFAULT_CI_CONFIG,
projects: [
{ slug: "task-api", name: "Task API", default: true },
{ slug: "auth-svc", name: "Auth Service" },
],
active_project: "task-api",
};
expect(config.projects).toHaveLength(2);
expect(config.active_project).toBe("task-api");
expect(config.projects[0].default).toBe(true);
});
it("supports single project", () => {
const config: CIConfig = {
...DEFAULT_CI_CONFIG,
projects: [{ slug: "my-app", name: "My App", default: true }],
active_project: "my-app",
};
expect(config.projects).toHaveLength(1);
expect(config.active_project).toBe("my-app");
});
it("defaults to empty projects array and empty active_project", () => {
expect(DEFAULT_CI_CONFIG.projects).toEqual([]);
expect(DEFAULT_CI_CONFIG.active_project).toBe("");
});
});
});
+10
View File
@@ -61,7 +61,15 @@ export interface GitConfig {
auto_push: boolean;
}
export interface ProjectEntry {
slug: string;
name: string;
default?: boolean;
}
export interface CIConfig {
projects: ProjectEntry[];
active_project: string;
autonomy: AutonomyConfig;
model_profile: ModelProfile;
parallelization: ParallelizationConfig;
@@ -71,6 +79,8 @@ export interface CIConfig {
}
export const DEFAULT_CI_CONFIG: CIConfig = {
projects: [],
active_project: "",
autonomy: {
level: "full",
escalation_hooks: ["deploy", "delete_data", "merge_to_main"],
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "0.2.0";
export const VERSION = "0.3.0";