From c747d3e8be6901b30006185209aee184aeef56f3 Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Mon, 1 Jun 2026 13:56:43 +0000 Subject: [PATCH] =?UTF-8?q?feat(P05):=20Multi-project=20ideation=20support?= =?UTF-8?q?=20=E2=80=94=20MULTI-03,=20MULTI-05,=20MULTI-07?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ---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 --- src/agents/base.ts | 1 + src/agents/orchestrator.ts | 108 ++++++++- src/cli/commands.ts | 283 ++++++++++++++++------ src/core/multi-project.test.ts | 415 ++++++++++++++++++++++++++++++++- src/types/config.ts | 2 + 5 files changed, 732 insertions(+), 77 deletions(-) diff --git a/src/agents/base.ts b/src/agents/base.ts index e24a8cf..1fde86b 100644 --- a/src/agents/base.ts +++ b/src/agents/base.ts @@ -18,6 +18,7 @@ export interface AgentContext { specification: string; config_path: string; backend?: IntelligenceBackend; + project_slug?: string; } export function backendResultToAgentResult(result: BackendResult): AgentResult { diff --git a/src/agents/orchestrator.ts b/src/agents/orchestrator.ts index 94df99e..fb33573 100644 --- a/src/agents/orchestrator.ts +++ b/src/agents/orchestrator.ts @@ -68,9 +68,10 @@ export class OrchestratorAgent extends BaseAgent { try { this.config = loadConfig(context.project_path); + const projectSlug = context.project_slug || ""; this.gitContext = new GitContext(context.project_path); this.gitBranch = new GitBranch(context.project_path); - this.ciFiles = new CIAgentFiles(context.project_path); + this.ciFiles = new CIAgentFiles(context.project_path, projectSlug || undefined); this.ciFiles.ensureCIDir(); const projectState = this.gitContext.reconstructState(); @@ -460,6 +461,7 @@ export class OrchestratorAgent extends BaseAgent { projectName: spec.objective.slice(0, 30), phaseCount: 0, milestone: this.currentMilestone, + project: context.project_slug || undefined, specification: spec.raw_content, requirements: spec.requirements, constraints: spec.constraints, @@ -575,7 +577,7 @@ export class OrchestratorAgent extends BaseAgent { case "ideate": { this.log("Running ideation stage..."); const { IdeationEngine } = await import("../core/ideation.js"); - const ideationEngine = new IdeationEngine(context.project_path); + const ideationEngine = new IdeationEngine(context.project_path, context.project_slug || undefined); const ideas = ideationEngine.runMechanical(); const ideationConfig = this.config.ideation; @@ -599,10 +601,15 @@ export class OrchestratorAgent extends BaseAgent { const { accepted: savedIdeas, results } = ideationEngine.acceptIdeas(trimmedIdeas); const savedCount = results.filter((r) => r.addedToRequirements || r.addedToRoadmap).length; - const ideationCommit = CommitBuilder.buildDecisionCommit({ + const ideationCommit = CommitBuilder.buildTaskCommit({ + type: "decision", phase: this.pipelineState!.current_phase, milestone: this.currentMilestone, + project: context.project_slug || undefined, + plan: "ideation", + task: "ideation-results", subject: `ideation results — ${trimmedIdeas.length} total, ${savedCount} accepted`, + status: "ideate", decisions: savedIdeas.map((idea) => ({ id: idea.id, decision: idea.title, @@ -849,4 +856,99 @@ export class OrchestratorAgent extends BaseAgent { return lines.join("\n"); } + + async runForProject(projectSlug: string, context: AgentContext): Promise { + this.log(`Running pipeline for project: ${projectSlug}`); + + this.ciFiles = new CIAgentFiles(context.project_path, projectSlug); + this.ciFiles.ensureCIDir(); + this.ciFiles.setProjectSlug(projectSlug); + + const projectContext: AgentContext = { + ...context, + project_path: context.project_path, + }; + + const result = await this.execute(projectContext); + + return { + ...result, + output: result.output ? `[${projectSlug}] ${result.output}` : result.output, + }; + } + + async runForAllProjects(context: AgentContext): Promise> { + const config = loadConfig(context.project_path); + const ciFiles = new CIAgentFiles(context.project_path); + const projects = ciFiles.listProjects(); + + const activeProjects: string[] = config.active_projects?.length > 0 + ? config.active_projects + : projects.map((p) => p.slug); + + if (activeProjects.length === 0) { + this.log("No active projects found; running for default project"); + const result = await this.execute(context); + return { default: result }; + } + + this.log(`Running pipeline for ${activeProjects.length} project(s): ${activeProjects.join(", ")}`); + + const results: Record = {}; + const maxConcurrent = config.parallelization?.max_concurrent_projects ?? 3; + const parallel = config.parallelization?.enabled && activeProjects.length > 1; + + if (parallel) { + const limitedConcurrency = Math.min(maxConcurrent, activeProjects.length); + const batches: string[][] = []; + for (let i = 0; i < activeProjects.length; i += limitedConcurrency) { + batches.push(activeProjects.slice(i, i + limitedConcurrency)); + } + + for (const batch of batches) { + const batchResults = await Promise.allSettled( + batch.map(async (slug): Promise<[string, AgentResult]> => { + const orchestrator = new OrchestratorAgent(config); + const result = await orchestrator.runForProject(slug, context); + return [slug, result]; + }) + ); + + for (const settled of batchResults) { + if (settled.status === "fulfilled") { + const [slug, result] = settled.value; + results[slug] = result; + } else { + this.warn(`Project pipeline failed: ${settled.reason instanceof Error ? settled.reason.message : String(settled.reason)}`); + } + } + } + } else { + for (const slug of activeProjects) { + this.log(`Processing project: ${slug}`); + const orchestrator = new OrchestratorAgent(config); + orchestrator.ciFiles = new CIAgentFiles(context.project_path, slug); + orchestrator.ciFiles.ensureCIDir(); + orchestrator.ciFiles.setProjectSlug(slug); + + try { + const result = await orchestrator.runForProject(slug, context); + results[slug] = result; + } catch (err) { + this.warn(`Failed for project ${slug}: ${err instanceof Error ? err.message : String(err)}`); + results[slug] = { + success: false, + output: `Pipeline failed for project ${slug}`, + artifacts_created: 0, + decisions: 0, + escalations: 0, + duration_ms: 0, + error: err instanceof Error ? err.message : String(err), + }; + } + } + } + + return results; + } } \ No newline at end of file diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 4fdba79..8ee1a23 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -10,7 +10,7 @@ import { getAuditSummary, readAudit } from "../core/audit.js"; import { VerificationPipeline } from "../verification/index.js"; import { ClarifyPhase } from "../core/clarify.js"; import { loadSpecification as loadSpec } from "../core/clarify.js"; -import { AgentContext } from "../agents/base.js"; +import { AgentContext, AgentResult } from "../agents/base.js"; import { ErrorRecovery } from "../core/error-recovery.js"; import { PipelineState, createInitialPipelineState } from "../types/pipeline.js"; import { resolveBackend } from "../backends/index.js"; @@ -79,6 +79,7 @@ export function createInitCommand(): Command { enabled: options.parallel !== false, max_concurrent_agents: 5, min_plans_for_parallel: 2, + max_concurrent_projects: 3, }, backend: { provider: options.backend || "auto", @@ -170,6 +171,7 @@ export function createRunCommand(): Command { .option("--phase ", "Phase number", "1") .option("--backend ", "Override intelligence backend for this run") .option("--ideate", "Insert ideation stage between research and plan") + .option("--project ", "Target project slug (comma-separated or 'all')") .action(async (phase, options) => { const projectPath = process.cwd(); @@ -178,13 +180,106 @@ export function createRunCommand(): Command { process.exit(1); } + const config = loadConfig(projectPath); + const ciFiles = new CIAgentFiles(projectPath); + const runForAllProjects = options.project === "all" || (Array.isArray(config.active_projects) && config.active_projects.length > 1 && !options.project); + + if (runForAllProjects) { + console.log("─── Running pipeline across all active projects ───\n"); + + const orchestrator = new OrchestratorAgent(config); + const context: AgentContext = { + project_path: projectPath, + phase: parseInt(options.phase) || 1, + stage: phase || "all", + specification: "", + config_path: path.join(projectPath, ".ciagent", "config.json"), + backend: undefined, + }; + + const spec = loadSpec(projectPath); + if (spec) { + context.specification = spec.raw_content; + } + + const { backend, error: backendError } = await resolveBackendForCommand(config, options.backend); + if (backend) { + context.backend = backend; + } else if (backendError) { + console.warn(` ⚠ No intelligence backend available: ${backendError}`); + console.warn(" Continuing with mechanical-only execution (limited functionality)."); + } + + const results = await orchestrator.runForAllProjects(context); + + console.log("\n─── Multi-Project Pipeline Results ───\n"); + let allSuccess = true; + for (const [slug, result] of Object.entries(results)) { + const icon = result.success ? "✓" : "✗"; + console.log(` ${icon} ${slug}: ${result.success ? "success" : result.error || "failed"}`); + if (!result.success) allSuccess = false; + } + + if (!allSuccess) { + process.exit(1); + } + return; + } + + let projectSlug: string | undefined; + if (options.project && options.project !== "all") { + const slugs = options.project.split(",").map((s: string) => s.trim()).filter(Boolean); + projectSlug = slugs[0]; + + if (slugs.length > 1) { + console.log("─── Running pipeline across multiple projects ───\n"); + + const orchestrator = new OrchestratorAgent(config); + const context: AgentContext = { + project_path: projectPath, + phase: parseInt(options.phase) || 1, + stage: phase || "all", + specification: "", + config_path: path.join(projectPath, ".ciagent", "config.json"), + backend: undefined, + }; + + const { backend, error: backendError } = await resolveBackendForCommand(config, options.backend); + if (backend) { + context.backend = backend; + } else if (backendError) { + console.warn(` ⚠ No intelligence backend available: ${backendError}`); + } + + const allResults: Record = {}; + for (const slug of slugs) { + console.log(`\nProcessing project: ${slug}`); + const projOrchestrator = new OrchestratorAgent(config); + const result = await projOrchestrator.runForProject(slug, context); + allResults[slug] = result; + } + + console.log("\n─── Multi-Project Pipeline Results ───\n"); + let allSuccess = true; + for (const [slug, result] of Object.entries(allResults)) { + const icon = result.success ? "✓" : "✗"; + console.log(` ${icon} ${slug}: ${result.success ? "success" : result.error || "failed"}`); + if (!result.success) allSuccess = false; + } + + if (!allSuccess) { + process.exit(1); + } + return; + } + } + if (options.ideate) { console.log("─── CIAgent Ideate (pipeline mode) ───\n"); - const ciFiles = new CIAgentFiles(projectPath); - const slug = ciFiles.getProjectSlug() || ciFiles.getActiveProject() || "default"; + const currentSlug = projectSlug || ciFiles.getProjectSlug() || ciFiles.getActiveProject() || "default"; const { IdeationEngine } = await import("../core/ideation.js"); - const engine = new IdeationEngine(projectPath, slug); + const engine = new IdeationEngine(projectPath, currentSlug); const ideas = engine.runMechanical(); @@ -221,7 +316,6 @@ export function createRunCommand(): Command { } } - const config = loadConfig(projectPath); const { backend, error: backendError } = await resolveBackendForCommand(config, options.backend); if (!backend && backendError) { @@ -237,6 +331,7 @@ export function createRunCommand(): Command { specification: "", config_path: path.join(projectPath, ".ciagent", "config.json"), backend, + project_slug: projectSlug || undefined, }; const spec = loadSpec(projectPath); @@ -244,7 +339,7 @@ export function createRunCommand(): Command { context.specification = spec.raw_content; } - console.log(`Running CIAgent pipeline...`); + console.log(`Running CIAgent pipeline${projectSlug ? ` for project: ${projectSlug}` : ""}...`); if (options.all) { console.log(" Mode: Full pipeline (all phases)"); } else { @@ -1033,72 +1128,88 @@ export function createIdeateCommand(): Command { } const ciFiles = new CIAgentFiles(projectPath); - let slug = options.project || ciFiles.getActiveProject() || "default"; - const allProjects = slug === "all"; - - if (options.project) { - ciFiles.setProjectSlug(options.project); - } - - const categories: IdeationCategory[] = options.category - ? options.category.split(",").map((c: string) => c.trim() as IdeationCategory) - : []; - - console.log("\n─── CIAgent Ideation ───"); - console.log(`Project: ${ciFiles.getProjectSlug() || "default"}`); - const config = loadConfig(projectPath); - console.log("\nMining git history for patterns..."); + const allProjects: string[] = options.project === "all" + ? ciFiles.listProjects().map((p) => p.slug) + : options.project + ? options.project.split(",").map((s: string) => s.trim()).filter(Boolean) + : [ciFiles.getProjectSlug() || ciFiles.getActiveProject() || "default"]; + + if (allProjects.length > 1) { + console.log(`\n─── CIAgent Ideation (multi-project: ${allProjects.join(", ")}) ───\n`); + } else { + console.log("\n─── CIAgent Ideation ───"); + console.log(`Project: ${allProjects[0]}`); + } const { IdeationEngine } = await import("../core/ideation.js"); - const engine = new IdeationEngine(projectPath, ciFiles.getProjectSlug() || undefined); - let allIdeas: Idea[] = []; + const allIdeasByProject: Record = {}; + const allIdeas: Idea[] = []; + const seenTitles = new Set(); - console.log("Running mechanical analysis (tier 1)..."); - allIdeas = engine.runMechanical(categories.length > 0 ? categories : undefined); + for (const slug of allProjects) { + const engine = new IdeationEngine(projectPath, slug); + ciFiles.setProjectSlug(slug); - if (options.affected) { - console.log("Running cascade impact analysis (--affected)..."); - const affectedIdeas = engine.runAffected(); - allIdeas = [...allIdeas, ...affectedIdeas]; + const categories: IdeationCategory[] = options.category + ? options.category.split(",").map((c: string) => c.trim() as IdeationCategory) + : []; + + console.log(`\nMining git history for patterns in project: ${slug}...`); + + let projectIdeas: Idea[] = engine.runMechanical(categories.length > 0 ? categories : undefined); + + if (options.affected) { + console.log(`Running cascade impact analysis (--affected) for ${slug}...`); + const affectedIdeas = engine.runAffected(); + projectIdeas = [...projectIdeas, ...affectedIdeas]; + } + + if (options.spec) { + console.log(`Running specification analysis (--spec) for ${slug}...`); + const specIdeas = engine.runMechanical(["spec"]); + const newSpecIdeas = specIdeas.filter( + (idea: Idea) => !projectIdeas.some((existing: Idea) => existing.title === idea.title) + ); + projectIdeas = [...projectIdeas, ...newSpecIdeas]; + } + + if (options.external) { + console.log(`Running external signal analysis (--external) for ${slug}...`); + const externalIdeas = engine.runExternal(); + projectIdeas = [...projectIdeas, ...externalIdeas]; + } + + if (options.crossProject && ciFiles.isMultiProject()) { + console.log(`Running cross-project pattern mining (--cross-project) for ${slug}...`); + const crossProjectIdeas = engine.runCrossProject(); + projectIdeas = [...projectIdeas, ...crossProjectIdeas]; + } + + const uniqueProjectIdeas = projectIdeas.filter((idea: Idea) => { + const dedupeKey = allProjects.length > 1 ? `${slug}:${idea.title}` : idea.title; + if (seenTitles.has(dedupeKey)) return false; + seenTitles.add(dedupeKey); + return true; + }); + + uniqueProjectIdeas.sort((a: Idea, b: Idea) => b.confidence - a.confidence); + allIdeasByProject[slug] = uniqueProjectIdeas; + allIdeas.push(...uniqueProjectIdeas); } - if (options.spec) { - console.log("Running specification analysis (--spec)..."); - const specIdeas = engine.runMechanical(["spec"]); - const newSpecIdeas = specIdeas.filter( - (idea: Idea) => !allIdeas.some((existing: Idea) => existing.title === idea.title) - ); - allIdeas = [...allIdeas, ...newSpecIdeas]; - } + allIdeas.sort((a, b) => b.confidence - a.confidence); - if (options.external) { - console.log("Running external signal analysis (--external)..."); - const externalIdeas = engine.runExternal(); - allIdeas = [...allIdeas, ...externalIdeas]; - } - - if (options.crossProject && ciFiles.isMultiProject()) { - console.log("Running cross-project pattern mining (--cross-project)..."); - const crossProjectIdeas = engine.runCrossProject(); - allIdeas = [...allIdeas, ...crossProjectIdeas]; - } - - const seen = new Set(); - allIdeas = allIdeas.filter((idea: Idea) => { - if (seen.has(idea.title)) return false; - seen.add(idea.title); - return true; - }); - - allIdeas.sort((a: Idea, b: Idea) => b.confidence - a.confidence); + const currentSlug = allProjects.length === 1 ? allProjects[0] : "all"; + const engine = new IdeationEngine(projectPath, allProjects.length === 1 ? allProjects[0] : undefined); if (options.output === "json") { const result = engine.formatIdeasJson(allIdeas); result.summary.accepted = 0; result.summary.skipped = allIdeas.length; + result.project = currentSlug; console.log(JSON.stringify(result, null, 2)); return; } @@ -1109,21 +1220,33 @@ export function createIdeateCommand(): Command { console.log("No improvement ideas identified for this project."); return; } - for (const idea of allIdeas) { - console.log(`### ${idea.title}`); - console.log(`- **Category**: ${idea.category}`); - console.log(`- **Source**: ${idea.source}`); - console.log(`- **Confidence**: ${idea.confidence.toFixed(2)}`); - console.log(`- **Tier**: ${idea.tier}`); - console.log(`- **Rationale**: ${idea.rationale}`); - if (idea.relatedReq) console.log(`- **Related Req**: ${idea.relatedReq}`); - console.log(`- **Actions**: ${idea.actions.join(", ")}`); - console.log(""); + + if (allProjects.length > 1) { + console.log("| Project | Idea | Category | Confidence | Tier |"); + console.log("|---------|-------|----------|------------|------|"); + for (const slug of allProjects) { + const projectIdeas = allIdeasByProject[slug] || []; + for (const idea of projectIdeas) { + console.log(`| ${slug} | ${idea.title} | ${idea.category} | ${idea.confidence.toFixed(2)} | ${idea.tier} |`); + } + } + } else { + for (const idea of allIdeas) { + console.log(`### ${idea.title}`); + console.log(`- **Category**: ${idea.category}`); + console.log(`- **Source**: ${idea.source}`); + console.log(`- **Confidence**: ${idea.confidence.toFixed(2)}`); + console.log(`- **Tier**: ${idea.tier}`); + console.log(`- **Rationale**: ${idea.rationale}`); + if (idea.relatedReq) console.log(`- **Related Req**: ${idea.relatedReq}`); + console.log(`- **Actions**: ${idea.actions.join(", ")}`); + console.log(""); + } } return; } - console.log(`\nFound ${allIdeas.length} improvement ${allIdeas.length === 1 ? "idea" : "ideas"}\n`); + console.log(`\nFound ${allIdeas.length} improvement ${allIdeas.length === 1 ? "idea" : "ideas"}${allProjects.length > 1 ? ` across ${allProjects.length} projects` : ""}\n`); if (allIdeas.length === 0) { console.log("No improvement ideas identified for this project."); @@ -1154,8 +1277,9 @@ export function createIdeateCommand(): Command { for (let i = 0; i < allIdeas.length; i++) { const idea = allIdeas[i]; + const projectLabel = allProjects.length > 1 ? ` [${idea.tier === "cross-project" ? "cross-project" : allProjects[0]}]` : ""; console.log(`\n═══ Recommendation ${i + 1} of ${allIdeas.length} ═══\n`); - console.log(` Category: ${idea.category.toUpperCase()} | Confidence: ${idea.confidence.toFixed(2)} | Tier: ${idea.tier}`); + console.log(` Category: ${idea.category.toUpperCase()} | Confidence: ${idea.confidence.toFixed(2)} | Tier: ${idea.tier}${projectLabel}`); console.log(` Title: ${idea.title}`); console.log(` Rationale: ${idea.rationale}`); if (idea.relatedReq) console.log(` Related Req: ${idea.relatedReq}`); @@ -1204,17 +1328,30 @@ export function createIdeateCommand(): Command { console.log(`Accepted: ${accepted.length} recommendation${accepted.length === 1 ? "" : "s"}`); console.log(`Skipped: ${skipped.length} recommendation${skipped.length === 1 ? "" : "s"}`); + if (allProjects.length > 1) { + console.log(`Projects: ${allProjects.join(", ")}`); + } + if (accepted.length > 0) { console.log("\nAccepted ideas:"); for (const idea of accepted) { console.log(` ${idea.id}: ${idea.title} (${idea.category.toUpperCase()})`); } - const { accepted: savedIdeas, results } = engine.acceptIdeas(accepted); - const savedCount = results.filter((r) => r.addedToRequirements || r.addedToRoadmap).length; + for (const slug of allProjects) { + const projectAccepted = accepted.filter((idea) => { + return allIdeasByProject[slug]?.some((pi) => pi.id === idea.id); + }); - if (savedCount > 0) { - console.log(`\n${savedCount} idea${savedCount === 1 ? "" : "s"} added to REQUIREMENTS.md and ROADMAP.md.`); + if (projectAccepted.length > 0) { + const projEngine = new IdeationEngine(projectPath, slug); + const { accepted: savedIdeas, results } = projEngine.acceptIdeas(projectAccepted); + const savedCount = results.filter((r) => r.addedToRequirements || r.addedToRoadmap).length; + + if (savedCount > 0) { + console.log(`\n${savedCount} idea${savedCount === 1 ? "" : "s"} for project "${slug}" added to REQUIREMENTS.md and ROADMAP.md.`); + } + } } const kickoffAnswer = await askQuestion("\nWould you like to kick off the run workflow for these ideas? (y/n) > "); diff --git a/src/core/multi-project.test.ts b/src/core/multi-project.test.ts index c8bf96b..99a2d3b 100644 --- a/src/core/multi-project.test.ts +++ b/src/core/multi-project.test.ts @@ -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 = {}; + + 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(); + + 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"); + }); + }); }); \ No newline at end of file diff --git a/src/types/config.ts b/src/types/config.ts index bbf2ded..02ffefa 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -46,6 +46,7 @@ export interface ParallelizationConfig { enabled: boolean; max_concurrent_agents: number; min_plans_for_parallel: number; + max_concurrent_projects: number; } export interface VerificationConfig { @@ -113,6 +114,7 @@ export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = { enabled: true, max_concurrent_agents: 5, min_plans_for_parallel: 2, + max_concurrent_projects: 3, }, verification: { automated_only: true,