Compare commits

...

8 Commits

Author SHA1 Message Date
grimacing e2b749d42e Merge pull request 'feat(P05): Multi-project ideation support — MULTI-03, MULTI-05, MULTI-07' (#8) from phase/05-multi-project-ideation into main
CI / build-and-test (push) Has been cancelled
Publish to npm / publish (push) Has been cancelled
2026-06-01 13:58:10 +00:00
Jon Chery c747d3e8be feat(P05): Multi-project ideation support — MULTI-03, MULTI-05, MULTI-07
CI / build-and-test (push) Has been cancelled
CI / build-and-test (pull_request) Has been cancelled
---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
2026-06-01 13:56:43 +00:00
grimacing d9927558d5 Merge pull request 'feat(P04): Cross-project pipeline integration — IDEATE-16, IDEATE-11, IDEATE-18' (#7) from phase/04-cross-project-pipeline into main
CI / build-and-test (push) Has been cancelled
Publish to npm / publish (push) Has been cancelled
2026-05-30 21:20:34 +00:00
Jon Chery 895d9f95a1 feat(P04): Add IDEATE stage to orchestrator pipeline — IDEATE-16
CI / build-and-test (push) Has been cancelled
CI / build-and-test (pull_request) Has been cancelled
- Add ideation-agent to STAGE_AGENT_MAP for ideate stage
- Implement ideate case in executeStage() with mechanical ideation,
  config-aware category filtering, idea deduplication, auto-accept,
  and ---ci--- commit with decision block
- Add test verifying ideate position between research and plan in
  STAGE_ORDER
- 542 tests passing
2026-05-30 21:17:21 +00:00
Jon Chery 30352a3603 feat(P03): External/cascade tests + --ideate flag on run (#6)
CI / build-and-test (push) Has been cancelled
Publish to npm / publish (push) Has been cancelled
2026-05-30 20:59:40 +00:00
Jon Chery d58fd0bdde feat(P03): external/cascade tests + --ideate flag on run — IDEATE-07,08,15
CI / build-and-test (push) Has been cancelled
CI / build-and-test (pull_request) Has been cancelled
---ci---
phase: 3
milestone: v0.10
status: execute
decisions:
  - id: D-084
    decision: Dual integration: standalone ciagent ideate + --ideate flag on run
    confidence: 0.90
requirements:
  covered:
    - IDEATE-07
    - IDEATE-08
    - IDEATE-15
---/ci---

- IDEATE-07: External signal collection (npm audit, dependency staleness) tested
- IDEATE-08: Cascade impact analysis (--affected) tested
- IDEATE-15: --ideate flag on ciagent run inserts IDEATE stage between RESEARCH and PLAN
- Tests for runAffected, runExternal, runCrossProject
- 541 tests passing
2026-05-30 20:58:30 +00:00
Jon Chery 0799cfc644 feat(P02): Backend-enriched tier, chaos engineering, prioritization — IDEATE-04,05,06,09,10 (#5)
CI / build-and-test (push) Has been cancelled
Publish to npm / publish (push) Has been cancelled
2026-05-30 20:51:36 +00:00
Jon Chery 70ee21856d feat(P02): backend-enriched tier, chaos engineering, prioritization — IDEATE-04,05,06,09,10
CI / build-and-test (push) Has been cancelled
CI / build-and-test (pull_request) Has been cancelled
---ci---
phase: 2
milestone: v0.10
status: execute
decisions:
  - id: D-087
    decision: All 6 innovative features in v1 (pattern mining, drift detection, layer inversion, cross-project, chaos, spec)
    rationale: User wants bleeding-edge; all uniquely differentiated
    confidence: 0.82
requirements:
  covered:
    - IDEATE-04
    - IDEATE-05
    - IDEATE-06
    - IDEATE-09
    - IDEATE-10
---/ci---

- IDEATE-04: Verification layer inversion (structural, behavioral, security, quality missing detection)
- IDEATE-05: Architectural drift detection (documented vs actual component comparison)
- IDEATE-06: Spec-driven improvement (ambiguity detection, missing category detection)
- IDEATE-09: Backend-enriched analysis (prioritization, novel suggestions, action plans)
- IDEATE-10: Chaos engineering ideation (backend unavailable, requirement change, coverage drop)
- Deduplicated type exports: IdeationSource/Idea/etc now in types/ideation.ts
- 538 tests passing
2026-05-30 20:50:29 +00:00
8 changed files with 1157 additions and 178 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 {
+162 -1
View File
@@ -47,6 +47,7 @@ export class OrchestratorAgent extends BaseAgent {
private static readonly STAGE_AGENT_MAP: Partial<Record<PipelineStage, AgentName[]>> = {
research: ["researcher"],
ideate: ["ideation-agent"],
plan: ["planner"],
execute: ["executor", "code-reviewer", "security-auditor"],
test: ["tester"],
@@ -67,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();
@@ -459,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,
@@ -571,6 +574,69 @@ export class OrchestratorAgent extends BaseAgent {
break;
}
case "ideate": {
this.log("Running ideation stage...");
const { IdeationEngine } = await import("../core/ideation.js");
const ideationEngine = new IdeationEngine(context.project_path, context.project_slug || undefined);
const ideas = ideationEngine.runMechanical();
const ideationConfig = this.config.ideation;
if (ideationConfig?.categories && ideationConfig.categories.length > 0) {
const categoryIdeas = ideationEngine.runMechanical(ideationConfig.categories);
const seenTitles = new Set(ideas.map((i) => i.title));
for (const idea of categoryIdeas) {
if (!seenTitles.has(idea.title)) {
ideas.push(idea);
seenTitles.add(idea.title);
}
}
}
ideas.sort((a, b) => b.confidence - a.confidence);
const maxIdeas = ideationConfig?.max_ideas || 20;
const trimmedIdeas = ideas.slice(0, maxIdeas);
if (this.config.git.auto_commit && this.gitContext!.isGitRepo()) {
const { accepted: savedIdeas, results } = ideationEngine.acceptIdeas(trimmedIdeas);
const savedCount = results.filter((r) => r.addedToRequirements || r.addedToRoadmap).length;
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,
rationale: idea.rationale,
confidence: idea.confidence,
alternatives: idea.actions,
})),
});
try {
execSync(`git add -A && git commit -m "${ideationCommit.replace(/"/g, '\\"')}" --allow-empty`, {
cwd: context.project_path,
stdio: "pipe",
});
} catch (err) {
this.warn(`Ideation commit failed: ${err instanceof Error ? err.message : String(err)}`);
}
artifactsCreated.push(".ciagent/REQUIREMENTS.md", ".ciagent/ROADMAP.md");
decisionsMade += savedCount;
}
this.pipelineState!.ideate_completed = true;
this.log(`Ideation stage complete: ${trimmedIdeas.length} ideas generated`);
break;
}
case "plan":
this.log("Planning phase execution...");
@@ -790,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;
}
}
+250 -69
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",
@@ -169,6 +170,8 @@ export function createRunCommand(): Command {
.option("--all", "Execute all remaining phases sequentially")
.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,6 +181,141 @@ export function createRunCommand(): Command {
}
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 currentSlug = projectSlug || ciFiles.getProjectSlug() || ciFiles.getActiveProject() || "default";
const { IdeationEngine } = await import("../core/ideation.js");
const engine = new IdeationEngine(projectPath, currentSlug);
const ideas = engine.runMechanical();
const ideaCategory: IdeationCategory[] = options.category
? options.category.split(",").map((c: string) => c.trim() as IdeationCategory)
: [];
if (ideaCategory.length > 0) {
const filtered = engine.runMechanical(ideaCategory);
ideas.push(...filtered);
}
const seen = new Set<string>();
const uniqueIdeas = ideas.filter((idea: Idea) => {
if (seen.has(idea.title)) return false;
seen.add(idea.title);
return true;
});
uniqueIdeas.sort((a: Idea, b: Idea) => b.confidence - a.confidence);
console.log(`Found ${uniqueIdeas.length} improvement ${uniqueIdeas.length === 1 ? "idea" : "ideas"} from ideation stage.\n`);
if (uniqueIdeas.length > 0) {
const { accepted: savedIdeas, results } = engine.acceptIdeas(uniqueIdeas);
const savedCount = results.filter((r: { addedToRequirements: boolean; addedToRoadmap: boolean }) => r.addedToRequirements || r.addedToRoadmap).length;
if (savedCount > 0) {
console.log(`${savedCount} idea${savedCount === 1 ? "" : "s"} added to REQUIREMENTS.md and ROADMAP.md.`);
}
const commitMsg = `decision(P${options.phase || 1}): ideation results — ${uniqueIdeas.length} total, ${savedCount} accepted`;
console.log(`\nCommit suggestion: ${commitMsg}`);
}
}
const { backend, error: backendError } = await resolveBackendForCommand(config, options.backend);
if (!backend && backendError) {
@@ -193,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);
@@ -200,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 {
@@ -989,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;
}
@@ -1065,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.");
@@ -1110,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}`);
@@ -1160,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) > ");
+140 -1
View File
@@ -2,7 +2,8 @@ import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { IdeationAgent } from "../agents/ideation-agent.js";
import { IdeationEngine, resetIdeaCounter, Idea } from "../core/ideation.js";
import { IdeationEngine, resetIdeaCounter } from "../core/ideation.js";
import { Idea, IdeationAction, DEFAULT_IDEATION_CONFIG } from "../types/ideation.js";
describe("IdeationAgent", () => {
let tempDir: string;
@@ -244,4 +245,142 @@ describe("IdeationEngine", () => {
expect(results.every((r) => r.addedToRequirements || r.addedToRoadmap)).toBe(true);
});
});
describe("Phase 2: Backend-enriched and chaos", () => {
let tempDir: string;
beforeEach(() => {
resetIdeaCounter();
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-p2-test-"));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("runBackendEnriched prioritizes mechanical findings", () => {
const engine = new IdeationEngine(tempDir);
const mechanicalIdeas: Idea[] = [
{
id: "IDEATE-01",
source: "uncovered_requirement",
category: "coverage",
title: "Missing test",
rationale: "No test file",
confidence: 0.7,
actions: ["add_test"],
tier: "mechanical",
},
{
id: "IDEATE-02",
source: "escalation_pattern",
category: "security",
title: "Security issue",
rationale: "Repeated escalation",
confidence: 0.8,
actions: ["add_security_pattern"],
tier: "mechanical",
},
];
const enriched = engine.runBackendEnriched(mechanicalIdeas);
expect(enriched.length).toBeGreaterThanOrEqual(2);
const prioritizedIdeas = enriched.filter((i) => i.source === "uncovered_requirement" || i.source === "escalation_pattern");
expect(prioritizedIdeas.length).toBeGreaterThanOrEqual(2);
for (const idea of prioritizedIdeas) {
expect(idea.tier).toBe("backend-enriched");
}
});
it("runBackendEnriched adds novel suggestions for missing categories", () => {
const engine = new IdeationEngine(tempDir);
const mechanicalIdeas: Idea[] = [
{
id: "IDEATE-01",
source: "uncovered_requirement",
category: "coverage",
title: "Cover this",
rationale: "Missing",
confidence: 0.7,
actions: ["add_test"],
tier: "mechanical",
},
];
const enriched = engine.runBackendEnriched(mechanicalIdeas);
const novelIdeas = enriched.filter((i) => i.source === "improvement_pattern");
expect(novelIdeas.length).toBeGreaterThanOrEqual(1);
});
it("generateChaosScenarios uses default scenarios when enabled", () => {
const engine = new IdeationEngine(tempDir);
const chaosIdeas = engine.generateChaosScenarios();
expect(chaosIdeas.length).toBe(3);
expect(chaosIdeas.every((i) => i.source === "chaos_scenario")).toBe(true);
expect(chaosIdeas.every((i) => i.category === "chaos")).toBe(true);
expect(chaosIdeas.every((i) => i.tier === "backend-enriched")).toBe(true);
expect(chaosIdeas.every((i) => i.confidence >= 0.5)).toBe(true);
const titles = chaosIdeas.map((i) => i.title);
expect(titles.some((t) => t.includes("backend"))).toBe(true);
expect(titles.some((t) => t.includes("requirement"))).toBe(true);
expect(titles.some((t) => t.includes("coverage"))).toBe(true);
});
});
describe("Phase 3: External signals and cascade impact", () => {
let tempDir: string;
beforeEach(() => {
resetIdeaCounter();
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-p3-test-"));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("runAffected detects cascade from architecture.md", () => {
const ciagentDir = path.join(tempDir, ".ciagent");
fs.mkdirSync(ciagentDir, { recursive: true });
fs.writeFileSync(
path.join(ciagentDir, "config.json"),
JSON.stringify({ projects: [], active_project: "default" })
);
fs.writeFileSync(
path.join(ciagentDir, "ARCHITECTURE.md"),
"# Architecture\n\n## Overview\n\nTest.\n\n## Components\n\n### CLI\n\n- **Description**: Command line interface\n- **Boundaries**: User-facing only\n- **Depends on**: Core\n\n### Core\n\n- **Description**: Core engine\n- **Boundaries**: Internal only\n- **Depends on**: None\n\n## Data Flow\n\nSimple.\n\n## Build Order\n\n1. CLI\n2. Core\n"
);
const engine = new IdeationEngine(tempDir);
const ideas = engine.runAffected();
expect(Array.isArray(ideas)).toBe(true);
});
it("runExternal handles missing npm gracefully", () => {
const ciagentDir = path.join(tempDir, ".ciagent");
fs.mkdirSync(ciagentDir, { recursive: true });
fs.writeFileSync(
path.join(ciagentDir, "config.json"),
JSON.stringify({ projects: [], active_project: "default" })
);
const engine = new IdeationEngine(tempDir);
const ideas = engine.runExternal();
expect(Array.isArray(ideas)).toBe(true);
});
it("runCrossProject returns empty when only one project", () => {
const ciagentDir = path.join(tempDir, ".ciagent");
fs.mkdirSync(ciagentDir, { recursive: true });
fs.writeFileSync(
path.join(ciagentDir, "config.json"),
JSON.stringify({ projects: [{ slug: "default", name: "Default Project", default: true }], active_project: "default" })
);
const engine = new IdeationEngine(tempDir, "default");
const ideas = engine.runCrossProject();
expect(ideas).toEqual([]);
});
});
});
+178 -106
View File
@@ -3,112 +3,18 @@ import * as path from "node:path";
import { execSync } from "node:child_process";
import { CIAgentFiles } from "./ciagent-files.js";
import { GitContext } from "./git-context.js";
export type IdeationSource =
| "uncovered_requirement"
| "repeated_lesson"
| "low_confidence_decision"
| "escalation_pattern"
| "compound_pattern"
| "partial_requirement"
| "gap_in_coverage"
| "improvement_pattern"
| "architecture_drift"
| "verification_inversion"
| "spec_ambiguity"
| "spec_contradiction"
| "spec_missing"
| "external_signal"
| "cross_project_lesson"
| "chaos_scenario";
export type IdeationCategory =
| "security"
| "quality"
| "architecture"
| "coverage"
| "improvement"
| "spec"
| "chaos";
export type IdeationAction =
| "add_requirement"
| "update_architecture"
| "update_roadmap"
| "fix_documentation"
| "add_test"
| "add_security_pattern"
| "refactor"
| "new_milestone_phase";
export type IdeationTier = "mechanical" | "backend-enriched" | "cross-project";
export interface Idea {
id: string;
source: IdeationSource;
category: IdeationCategory;
title: string;
rationale: string;
confidence: number;
relatedReq?: string;
actions: IdeationAction[];
tier: IdeationTier;
}
export interface IdeationResult {
project: string;
milestone: string;
ideas: Idea[];
summary: IdeationSummary;
}
export interface IdeationSummary {
total: number;
accepted: number;
skipped: number;
by_category: Record<string, number>;
by_tier: Record<string, number>;
}
export interface IdeationConfig {
enabled: boolean;
categories: IdeationCategory[];
confidence_threshold: number;
max_ideas: number;
external_signals: {
npm_audit: boolean;
osv_advisories: boolean;
dependency_staleness: boolean;
};
cross_project: {
enabled: boolean;
similarity_weight: number;
};
chaos: {
enabled: boolean;
scenarios: string[];
};
}
export const DEFAULT_IDEATION_CONFIG: IdeationConfig = {
enabled: true,
categories: ["security", "quality", "architecture", "coverage", "improvement"],
confidence_threshold: 0.6,
max_ideas: 20,
external_signals: {
npm_audit: true,
osv_advisories: true,
dependency_staleness: true,
},
cross_project: {
enabled: false,
similarity_weight: 0.5,
},
chaos: {
enabled: true,
scenarios: ["backend_unavailable", "requirement_change", "test_coverage_drop"],
},
};
import { loadConfig } from "./config.js";
import {
IdeationSource,
IdeationCategory,
IdeationAction,
IdeationTier,
Idea,
IdeationResult,
IdeationSummary,
IdeationConfig,
DEFAULT_IDEATION_CONFIG,
} from "../types/ideation.js";
let ideaCounter = 0;
@@ -815,6 +721,172 @@ export class IdeationEngine {
return ideas;
}
runBackendEnriched(mechanicalIdeas: Idea[], context?: string): Idea[] {
resetIdeaCounter();
const enriched: Idea[] = [];
const prioritized = this.prioritizeMechanicalFindings(mechanicalIdeas);
enriched.push(...prioritized);
const novelSuggestions = this.suggestNovelImprovements(mechanicalIdeas);
enriched.push(...novelSuggestions);
const chaosScenarios = this.generateChaosScenarios();
enriched.push(...chaosScenarios);
return enriched.slice(0, DEFAULT_IDEATION_CONFIG.max_ideas);
}
private prioritizeMechanicalFindings(mechanicalIdeas: Idea[]): Idea[] {
const categoryPriority: Record<string, number> = {
security: 1.0,
coverage: 0.9,
architecture: 0.8,
quality: 0.7,
improvement: 0.6,
spec: 0.5,
chaos: 0.4,
};
const sourceBoost: Record<string, number> = {
escalation_pattern: 0.15,
repeated_lesson: 0.1,
uncovered_requirement: 0.1,
compound_pattern: 0.08,
low_confidence_decision: 0.05,
architecture_drift: 0.05,
verification_inversion: 0.03,
};
return mechanicalIdeas
.map((idea) => ({
...idea,
confidence: Math.min(
idea.confidence + (categoryPriority[idea.category] || 0) * 0.1 + (sourceBoost[idea.source] || 0),
1.0
),
tier: "backend-enriched" as IdeationTier,
}))
.sort((a, b) => b.confidence - a.confidence);
}
private suggestNovelImprovements(mechanicalIdeas: Idea[]): Idea[] {
const ideas: Idea[] = [];
const hasCategories = new Set(mechanicalIdeas.map((i) => i.category));
if (!hasCategories.has("security")) {
ideas.push({
id: nextIdeaId(),
source: "improvement_pattern",
category: "security",
title: "Consider adding security threat modeling",
rationale: "No security-related ideas were identified. Projects without explicit security analysis often have blind spots in threat modeling.",
confidence: 0.55,
actions: ["add_security_pattern"],
tier: "backend-enriched",
});
}
if (!hasCategories.has("quality")) {
ideas.push({
id: nextIdeaId(),
source: "improvement_pattern",
category: "quality",
title: "Consider establishing quality baselines",
rationale: "No quality-related ideas were identified. Adding code quality baselines and review standards can prevent regressions.",
confidence: 0.50,
actions: ["add_test", "update_roadmap"],
tier: "backend-enriched",
});
}
if (!hasCategories.has("architecture")) {
ideas.push({
id: nextIdeaId(),
source: "improvement_pattern",
category: "architecture",
title: "Review architectural boundaries and dependencies",
rationale: "No architecture drift detected, but periodic boundary review is a best practice for healthy codebases.",
confidence: 0.45,
actions: ["update_architecture"],
tier: "backend-enriched",
});
}
return ideas;
}
generateChaosScenarios(): Idea[] {
resetIdeaCounter();
const ideas: Idea[] = [];
const config = this.getIdeationConfig();
if (!config.chaos.enabled) return ideas;
for (const scenario of config.chaos.scenarios) {
switch (scenario) {
case "backend_unavailable":
ideas.push({
id: nextIdeaId(),
source: "chaos_scenario",
category: "chaos",
title: "Resilience: Handle backend unavailability",
rationale: "What if the primary intelligence backend is unavailable? Add fallback paths and graceful degradation for all backend-dependent features.",
confidence: 0.7,
actions: ["add_requirement", "add_test"],
tier: "backend-enriched",
});
break;
case "requirement_change":
ideas.push({
id: nextIdeaId(),
source: "chaos_scenario",
category: "chaos",
title: "Resilience: Handle mid-implementation requirement changes",
rationale: "What if requirements change during implementation? Design for adaptability with clear interfaces and minimal coupling.",
confidence: 0.65,
actions: ["add_requirement"],
tier: "backend-enriched",
});
break;
case "test_coverage_drop":
ideas.push({
id: nextIdeaId(),
source: "chaos_scenario",
category: "chaos",
title: "Resilience: Prevent test coverage regression",
rationale: "What if test coverage drops below threshold? Add CI gates that enforce minimum coverage and alert on regression.",
confidence: 0.75,
actions: ["add_test", "update_roadmap"],
tier: "backend-enriched",
});
break;
default:
ideas.push({
id: nextIdeaId(),
source: "chaos_scenario",
category: "chaos",
title: `Resilience: Handle ${scenario} scenario`,
rationale: `Consider adding resilience measures for the "${scenario}" failure scenario.`,
confidence: 0.5,
actions: ["add_requirement"],
tier: "backend-enriched",
});
}
}
return ideas;
}
private getIdeationConfig(): IdeationConfig {
try {
const config = loadConfig(this.projectPath);
return (config as any).ideation || DEFAULT_IDEATION_CONFIG;
} catch {
return DEFAULT_IDEATION_CONFIG;
}
}
formatIdeas(ideas: Idea[]): string {
if (ideas.length === 0) return "No improvement ideas identified for this project.";
+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,
+10
View File
@@ -62,4 +62,14 @@ describe("createInitialPipelineState", () => {
expect(state.started_at).toBeTruthy();
expect(state.last_updated).toBeTruthy();
});
});
describe("STAGE_ORDER ideate position", () => {
it("places ideate between research and plan", () => {
const ideateIdx = STAGE_ORDER.indexOf("ideate");
const researchIdx = STAGE_ORDER.indexOf("research");
const planIdx = STAGE_ORDER.indexOf("plan");
expect(ideateIdx).toBeGreaterThan(researchIdx);
expect(ideateIdx).toBeLessThan(planIdx);
});
});