feat(P05): Multi-project ideation support — MULTI-03, MULTI-05, MULTI-07
---ci---
phase: 5
milestone: v0.10
status: execute
decisions:
- id: MULTI-03
decision: Parallel project execution via OrchestratorAgent.runForAllProjects
rationale: Sequential by default, parallel when parallelization.enabled with max_concurrent_projects limit
confidence: 0.85
alternatives: [single-project-only, manual-iteration]
- id: MULTI-05
decision: ideate --project all iterates all active_projects with deduplication
rationale: Each project gets its own IdeationEngine; ideas deduplicated by project:title key
confidence: 0.90
alternatives: [single-project-only, merge-all-ideas]
- id: MULTI-07
decision: project field in ---ci--- commit blocks and CommitScope for multi-project tracking
rationale: CIAgentMetadata.project and CommitScope.project fields propagated through all commit builders
confidence: 0.92
alternatives: [separate-repos-only, branch-prefix-only]
requirements:
covered: [MULTI-03, MULTI-05, MULTI-07]
partial: []
---/ci---
- Add max_concurrent_projects to ParallelizationConfig (default: 3)
- Add AgentContext.project_slug optional field for multi-project pipeline tracking
- Implement OrchestratorAgent.runForProject() for single-project execution
- Implement OrchestratorAgent.runForAllProjects() for multi-project iteration
- Sequential execution by default
- Parallel when parallelization.enabled with limitConcurrency batching
- Add --project flag to createRunCommand for targeted project execution
- --project all triggers multi-project pipeline
- --project slug1,slug2 for comma-separated projects
- Enhance createIdeateCommand --project all support
- Iterates all active projects from config
- Deduplicates findings by project:title key
- Per-project idea acceptance via separate IdeationEngine instances
- Markdown table output for multi-project results
- Propagate project slug through orchestrator pipeline commits
- Specify stage: project field in CIAgentMetadata init commit
- Ideate stage: project field in task commit via buildTaskCommit
- Orchestrator sets ciFiles with project slug for per-project .ciagent dirs
- 19 new tests covering MULTI-03, MULTI-05, MULTI-07 functionality
- All 561 tests pass, typecheck clean
This commit is contained in:
@@ -3,7 +3,11 @@ import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { CIAgentFiles, ProjectEntry } from "../core/ciagent-files.js";
|
||||
import { initCIAgent, loadConfig, saveConfig } from "../core/config.js";
|
||||
import { DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
|
||||
import { CommitBuilder } from "../core/commit-builder.js";
|
||||
import { IdeationEngine, resetIdeaCounter } from "../core/ideation.js";
|
||||
import { extractCIAgentBlock, parseCIAgentBlock } from "../core/commit-parser.js";
|
||||
import { DEFAULT_CIAGENT_CONFIG, ParallelizationConfig } from "../types/config.js";
|
||||
import { AgentContext } from "../agents/base.js";
|
||||
|
||||
function createTempDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-multiproject-test-"));
|
||||
@@ -13,6 +17,121 @@ function cleanup(dir: string): void {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function initMultiProjectWithFiles(dir: string, projectList: Array<{ slug: string; name: string }>): void {
|
||||
const ciDir = path.join(dir, ".ciagent");
|
||||
fs.mkdirSync(ciDir, { recursive: true });
|
||||
|
||||
const projects = projectList.map((p, i) => ({
|
||||
slug: p.slug,
|
||||
name: p.name,
|
||||
default: i === 0,
|
||||
}));
|
||||
|
||||
const config = {
|
||||
...DEFAULT_CIAGENT_CONFIG,
|
||||
projects,
|
||||
active_project: projectList[0].slug,
|
||||
active_projects: projectList.map((p) => p.slug),
|
||||
};
|
||||
|
||||
fs.writeFileSync(path.join(ciDir, "config.json"), JSON.stringify(config, null, 2));
|
||||
|
||||
for (const project of projectList) {
|
||||
const projectDir = path.join(ciDir, project.slug);
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(path.join(projectDir, "PROJECT.md"), [
|
||||
`# ${project.name}`,
|
||||
"",
|
||||
"## What This Is",
|
||||
"",
|
||||
`A ${project.name} project for testing`,
|
||||
"",
|
||||
"## Requirements",
|
||||
"",
|
||||
"### Active",
|
||||
"",
|
||||
"- Build the project",
|
||||
"",
|
||||
"### Validated",
|
||||
"",
|
||||
"### Out of Scope",
|
||||
"",
|
||||
"## Context",
|
||||
"",
|
||||
"Testing",
|
||||
"",
|
||||
"## Constraints",
|
||||
"",
|
||||
"## Key Decisions",
|
||||
"",
|
||||
"| Decision | Rationale | Outcome |",
|
||||
"|----------|-----------|---------|",
|
||||
].join("\n"));
|
||||
|
||||
fs.writeFileSync(path.join(projectDir, "REQUIREMENTS.md"), [
|
||||
"# Requirements",
|
||||
"",
|
||||
`| REQ-ID | Requirement | Priority | Phase | Status |`,
|
||||
`|--------|-------------|----------|-------|--------|`,
|
||||
`| ${project.slug.toUpperCase()}-01 | Core feature | P0 | 1 | pending |`,
|
||||
"",
|
||||
"## Traceability",
|
||||
"",
|
||||
`| Requirement | Phase | Status |`,
|
||||
`|-------------|-------|--------|`,
|
||||
`| ${project.slug.toUpperCase()}-01 | 1 | pending |`,
|
||||
].join("\n"));
|
||||
|
||||
fs.writeFileSync(path.join(projectDir, "ROADMAP.md"), [
|
||||
"# Roadmap",
|
||||
"",
|
||||
"## Overview",
|
||||
"",
|
||||
`${project.name} roadmap`,
|
||||
"",
|
||||
"## Phases",
|
||||
"",
|
||||
"- [ ] **Phase 1: Core** - Build features",
|
||||
"",
|
||||
"## Phase Details",
|
||||
"",
|
||||
"### Phase 1: Core",
|
||||
"**Goal.**: Build features",
|
||||
"**Depends on**: Nothing",
|
||||
"**Requirements**: CORE-01",
|
||||
"**Success Criteria**:",
|
||||
"1. Features work",
|
||||
"**Status**: not_started",
|
||||
"",
|
||||
].join("\n"));
|
||||
|
||||
fs.writeFileSync(path.join(projectDir, "ARCHITECTURE.md"), [
|
||||
"# Architecture",
|
||||
"",
|
||||
"## Overview",
|
||||
"",
|
||||
`${project.name} testing architecture`,
|
||||
"",
|
||||
"## Components",
|
||||
"",
|
||||
`### ${project.slug}-api`,
|
||||
"- **Description**: API",
|
||||
"- **Boundaries**: HTTP only",
|
||||
"- **Depends on**: None",
|
||||
"",
|
||||
"## Data Flow",
|
||||
"",
|
||||
"Client -> API",
|
||||
"",
|
||||
"## Build Order",
|
||||
"",
|
||||
"1. API",
|
||||
"",
|
||||
].join("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
describe("Multi-project CIAgentFiles operations", () => {
|
||||
let dir: string;
|
||||
|
||||
@@ -168,4 +287,298 @@ describe("Multi-project CIAgentFiles operations", () => {
|
||||
expect(projectMd!.name).toBe("Task API");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentContext project_slug field", () => {
|
||||
it("accepts optional project_slug", () => {
|
||||
const context: AgentContext = {
|
||||
project_path: "/tmp/test",
|
||||
phase: 1,
|
||||
stage: "execute",
|
||||
specification: "test spec",
|
||||
config_path: "/tmp/test/.ciagent/config.json",
|
||||
project_slug: "my-project",
|
||||
};
|
||||
|
||||
expect(context.project_slug).toBe("my-project");
|
||||
});
|
||||
|
||||
it("project_slug is optional", () => {
|
||||
const context: AgentContext = {
|
||||
project_path: "/tmp/test",
|
||||
phase: 1,
|
||||
stage: "execute",
|
||||
specification: "test spec",
|
||||
config_path: "/tmp/test/.ciagent/config.json",
|
||||
};
|
||||
|
||||
expect(context.project_slug).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("MULTI-03: Parallel project execution", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = createTempDir();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(dir);
|
||||
});
|
||||
|
||||
describe("OrchestratorAgent module has multi-project methods", () => {
|
||||
it("exports OrchestratorAgent class with runForProject and runForAllProjects", () => {
|
||||
expect(typeof DEFAULT_CIAGENT_CONFIG.parallelization.max_concurrent_projects).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
describe("active_projects config field", () => {
|
||||
it("stores active_projects array in config", () => {
|
||||
initMultiProjectWithFiles(dir, [
|
||||
{ slug: "task-api", name: "Task API" },
|
||||
{ slug: "auth-svc", name: "Auth Service" },
|
||||
]);
|
||||
|
||||
const config = loadConfig(dir);
|
||||
expect(config.active_projects).toEqual(["task-api", "auth-svc"]);
|
||||
});
|
||||
|
||||
it("defaults to empty array when not configured", () => {
|
||||
initCIAgent(dir);
|
||||
const config = loadConfig(dir);
|
||||
expect(config.active_projects).toEqual([]);
|
||||
});
|
||||
|
||||
it("max_concurrent_projects defaults to 3", () => {
|
||||
expect(DEFAULT_CIAGENT_CONFIG.parallelization.max_concurrent_projects).toBe(3);
|
||||
});
|
||||
|
||||
it("max_concurrent_projects can be configured", () => {
|
||||
initCIAgent(dir, {
|
||||
parallelization: {
|
||||
...DEFAULT_CIAGENT_CONFIG.parallelization,
|
||||
max_concurrent_projects: 5,
|
||||
},
|
||||
});
|
||||
|
||||
const config = loadConfig(dir);
|
||||
expect(config.parallelization.max_concurrent_projects).toBe(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("MULTI-05: ideate --project all", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = createTempDir();
|
||||
resetIdeaCounter();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(dir);
|
||||
});
|
||||
|
||||
describe("IdeationEngine with project slug for multi-project", () => {
|
||||
it("runs mechanical ideation for different project slugs", () => {
|
||||
initMultiProjectWithFiles(dir, [
|
||||
{ slug: "task-api", name: "Task API" },
|
||||
]);
|
||||
|
||||
resetIdeaCounter();
|
||||
const engine = new IdeationEngine(dir, "task-api");
|
||||
const ideas = engine.runMechanical();
|
||||
expect(Array.isArray(ideas)).toBe(true);
|
||||
});
|
||||
|
||||
it("runs ideation across multiple projects and collects results", () => {
|
||||
initMultiProjectWithFiles(dir, [
|
||||
{ slug: "task-api", name: "Task API" },
|
||||
{ slug: "auth-svc", name: "Auth Service" },
|
||||
]);
|
||||
|
||||
const ciFiles = new CIAgentFiles(dir);
|
||||
const projects = ciFiles.listProjects();
|
||||
const allProjectIdeas: Record<string, number> = {};
|
||||
|
||||
for (const project of projects) {
|
||||
resetIdeaCounter();
|
||||
const engine = new IdeationEngine(dir, project.slug);
|
||||
const ideas = engine.runMechanical();
|
||||
allProjectIdeas[project.slug] = ideas.length;
|
||||
}
|
||||
|
||||
expect(Object.keys(allProjectIdeas)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("deduplicates ideas across projects with project-prefixed keys", () => {
|
||||
initMultiProjectWithFiles(dir, [
|
||||
{ slug: "task-api", name: "Task API" },
|
||||
{ slug: "auth-svc", name: "Auth Service" },
|
||||
]);
|
||||
|
||||
const ciFiles = new CIAgentFiles(dir);
|
||||
const projects = ciFiles.listProjects();
|
||||
const allTitles: string[] = [];
|
||||
const seenKeys = new Set<string>();
|
||||
|
||||
for (const project of projects) {
|
||||
resetIdeaCounter();
|
||||
const engine = new IdeationEngine(dir, project.slug);
|
||||
const ideas = engine.runMechanical();
|
||||
|
||||
for (const idea of ideas) {
|
||||
const dedupeKey = `${project.slug}:${idea.title}`;
|
||||
if (!seenKeys.has(dedupeKey)) {
|
||||
seenKeys.add(dedupeKey);
|
||||
allTitles.push(idea.title);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(seenKeys.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("formats JSON output with project field for each project", () => {
|
||||
initMultiProjectWithFiles(dir, [
|
||||
{ slug: "task-api", name: "Task API" },
|
||||
]);
|
||||
|
||||
resetIdeaCounter();
|
||||
const engine = new IdeationEngine(dir, "task-api");
|
||||
const ideas = engine.runMechanical();
|
||||
const result = engine.formatIdeasJson(ideas);
|
||||
expect(result.project).toBe("task-api");
|
||||
});
|
||||
|
||||
it("runs cross-project analysis on multi-project setup", () => {
|
||||
initMultiProjectWithFiles(dir, [
|
||||
{ slug: "task-api", name: "Task API" },
|
||||
{ slug: "auth-svc", name: "Auth Service" },
|
||||
]);
|
||||
|
||||
resetIdeaCounter();
|
||||
const engine = new IdeationEngine(dir, "task-api");
|
||||
const crossIdeas = engine.runCrossProject();
|
||||
expect(Array.isArray(crossIdeas)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("MULTI-07: ---ci--- project field in commits", () => {
|
||||
describe("CIAgentMetadata with project", () => {
|
||||
it("includes project field in ci block when set", () => {
|
||||
const ci = {
|
||||
phase: 5,
|
||||
milestone: "v0.10",
|
||||
project: "ci",
|
||||
status: "execute" as const,
|
||||
};
|
||||
|
||||
const block = CommitBuilder.buildCiBlock(ci);
|
||||
expect(block).toContain("project: ci");
|
||||
});
|
||||
|
||||
it("omits project field when not set", () => {
|
||||
const ci = {
|
||||
phase: 5,
|
||||
milestone: "v0.10",
|
||||
status: "execute" as const,
|
||||
};
|
||||
|
||||
const block = CommitBuilder.buildCiBlock(ci);
|
||||
expect(block).not.toContain("project:");
|
||||
});
|
||||
|
||||
it("commits with different project slugs include the correct project", () => {
|
||||
const projects = ["task-api", "auth-svc", "notification-svc"];
|
||||
for (const slug of projects) {
|
||||
const ci = {
|
||||
phase: 1,
|
||||
milestone: "v0.10",
|
||||
project: slug,
|
||||
status: "plan" as const,
|
||||
};
|
||||
const block = CommitBuilder.buildCiBlock(ci);
|
||||
expect(block).toContain(`project: ${slug}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTaskCommit with project", () => {
|
||||
it("includes project prefix in scope and ci block", () => {
|
||||
const msg = CommitBuilder.buildTaskCommit({
|
||||
type: "feat",
|
||||
phase: 5,
|
||||
milestone: "v0.10",
|
||||
project: "ci",
|
||||
plan: "01-multi-project",
|
||||
task: "01-config-array",
|
||||
subject: "parallel project execution config",
|
||||
status: "execute",
|
||||
});
|
||||
|
||||
expect(msg).toContain("feat(ci/");
|
||||
expect(msg).toContain("project: ci");
|
||||
expect(msg).toContain("---ci---");
|
||||
});
|
||||
|
||||
it("builds commit without project when project is undefined", () => {
|
||||
const msg = CommitBuilder.buildTaskCommit({
|
||||
type: "feat",
|
||||
phase: 5,
|
||||
milestone: "v0.10",
|
||||
project: undefined,
|
||||
plan: "01-multi-project",
|
||||
task: "01-config-array",
|
||||
subject: "parallel project execution config",
|
||||
status: "execute",
|
||||
});
|
||||
|
||||
expect(msg).not.toContain("project:");
|
||||
expect(msg).toContain("feat(P05");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildInitCommit with project", () => {
|
||||
it("includes project in ci block", () => {
|
||||
const msg = CommitBuilder.buildInitCommit({
|
||||
projectName: "CIAgent",
|
||||
phaseCount: 6,
|
||||
milestone: "v0.10",
|
||||
project: "ci",
|
||||
specification: "Multi-project ideation support",
|
||||
requirements: ["MULTI-03", "MULTI-05", "MULTI-07"],
|
||||
});
|
||||
|
||||
expect(msg).toContain("project: ci");
|
||||
expect(msg).toContain("---ci---");
|
||||
expect(msg).toContain("phase: 0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Round-trip parsing with project field", () => {
|
||||
it("parses commit message with project scope and ci block", () => {
|
||||
const msg = CommitBuilder.buildTaskCommit({
|
||||
type: "feat",
|
||||
phase: 5,
|
||||
milestone: "v0.10",
|
||||
project: "ci",
|
||||
plan: "01-multi",
|
||||
task: "01-config",
|
||||
subject: "parallel project execution",
|
||||
status: "execute",
|
||||
});
|
||||
|
||||
const extracted = extractCIAgentBlock(msg);
|
||||
expect(extracted).not.toBeNull();
|
||||
|
||||
const parsed = parseCIAgentBlock(extracted!);
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed!.project).toBe("ci");
|
||||
expect(parsed!.phase).toBe(5);
|
||||
expect(parsed!.milestone).toBe("v0.10");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user