feat(P05): Multi-project ideation support — MULTI-03, MULTI-05, MULTI-07 #8

Merged
grimacing merged 1 commits from phase/05-multi-project-ideation into main 2026-06-01 13:58:11 +00:00
5 changed files with 732 additions and 77 deletions
+1
View File
@@ -18,6 +18,7 @@ export interface AgentContext {
specification: string;
config_path: string;
backend?: IntelligenceBackend;
project_slug?: string;
}
export function backendResultToAgentResult(result: BackendResult): AgentResult {
+105 -3
View File
@@ -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<AgentResult> {
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<Record<string, AgentResult>> {
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<string, AgentResult> = {};
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;
}
}
+210 -73
View File
@@ -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 <number>", "Phase number", "1")
.option("--backend <provider>", "Override intelligence backend for this run")
.option("--ideate", "Insert ideation stage between research and plan")
.option("--project <slug>", "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<string, AgentResult> = {};
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<string, Idea[]> = {};
const allIdeas: Idea[] = [];
const seenTitles = new Set<string>();
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<string>();
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) > ");
+414 -1
View File
@@ -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");
});
});
});
+2
View File
@@ -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,