feat(P01): add ideation engine + ciagent ideate command — IDEATE-01,02,03,17 + MULTI-01

---ci---
phase: 1
milestone: v0.10
status: execute
decisions:
  - id: D-080
    decision: Three-tier ideation (mechanical, backend-enriched, cross-project)
    rationale: Mechanical tier always produces output without backend
    confidence: 0.92
  - id: D-089
    decision: No separate codebase map command
    rationale: Git-native + .ciagent/ covers mapping; avoids tree-sitter dep
    confidence: 0.88
requirements:
  covered:
    - IDEATE-01
    - IDEATE-02
    - IDEATE-03
    - IDEATE-17
    - MULTI-01
---/ci---

Add IdeationEngine core module with 15 signal collectors:
- Uncovered/partial requirements from REQUIREMENTS.md
- Coverage gaps (documented but unimplemented agents)
- Repeated lessons from git history
- Low-confidence decisions from ---ci--- blocks
- Escalation patterns from git history
- Compound solution patterns
- Architecture drift (ARCHITECTURE.md vs src/)
- Verification inversion (missing test files)
- Improvement patterns (cross-referencing lessons + requirements)
- Spec ambiguity (should/could/might patterns)
- Spec missing (common requirement categories)
- Cascade impact (--affected from git diff)
- External signals (npm audit, dependency staleness)
- Cross-project lesson mining

Add ciagent ideate CLI command with flags:
--category, --affected, --spec, --external, --cross-project, --output

Add active_projects to CIAgentConfig (backwards compatible with active_project).
Add IDEATE pipeline stage between RESEARCH and PLAN.
Update IdeationAgent to delegate to IdeationEngine.

533 tests passing.
This commit is contained in:
Jon Chery
2026-05-30 20:13:43 +00:00
parent da528cc493
commit 8e50049ba5
11 changed files with 1317 additions and 231 deletions
+8 -161
View File
@@ -1,18 +1,9 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { BaseAgent, AgentContext, AgentResult } from "./base.js";
interface Idea {
source: "uncovered_requirement" | "repeated_lesson" | "gap_in_coverage" | "improvement_pattern";
title: string;
rationale: string;
confidence: number;
relatedReq?: string;
}
import { IdeationEngine } from "../core/ideation.js";
export class IdeationAgent extends BaseAgent {
readonly name = "ideation-agent";
readonly description = "Generates improvement ideas. Output feeds directly into planning pipeline.";
readonly description = "Generates improvement ideas using git-native pattern mining, coverage gap analysis, and architectural drift detection. Output feeds directly into planning pipeline.";
readonly workflow = "research";
async execute(context: AgentContext): Promise<AgentResult> {
@@ -27,8 +18,9 @@ export class IdeationAgent extends BaseAgent {
return { ...result, duration_ms: Date.now() - start };
}
const ideas = this.mechanicalIdeate(context.project_path);
const output = this.formatIdeas(ideas);
const engine = new IdeationEngine(context.project_path);
const ideas = engine.runMechanical();
const output = engine.formatIdeas(ideas);
return {
success: true,
@@ -40,153 +32,8 @@ export class IdeationAgent extends BaseAgent {
};
}
mechanicalIdeate(projectPath: string): Idea[] {
const ideas: Idea[] = [];
const uncoveredReqs = this.readUncoveredRequirements(projectPath);
const lessons = this.readRecentLessons(projectPath);
const repeated = this.findRepeatedPatterns(lessons);
const coverageGaps = this.identifyCoverageGaps(projectPath);
for (const req of uncoveredReqs) {
ideas.push({
source: "uncovered_requirement",
title: `Address uncovered requirement: ${req}`,
rationale: `Requirement ${req} has no corresponding implementation task.`,
confidence: 0.8,
relatedReq: req,
});
}
for (const topic of repeated) {
ideas.push({
source: "repeated_lesson",
title: `Investigate repeated lesson: ${topic}`,
rationale: `Topic "${topic}" appears in multiple commit lessons, indicating a systemic issue.`,
confidence: 0.7,
});
}
for (const gap of coverageGaps) {
ideas.push({
source: "gap_in_coverage",
title: `Fill coverage gap: ${gap}`,
rationale: `Agent "${gap}" is claimed in PROJECT.md but not found in the agent registry.`,
confidence: 0.75,
});
}
this.generateIdeas(uncoveredReqs, repeated, ideas);
return ideas;
}
readUncoveredRequirements(projectPath: string): string[] {
const reqPath = path.join(projectPath, ".ciagent", "REQUIREMENTS.md");
if (!fs.existsSync(reqPath)) return [];
const content = fs.readFileSync(reqPath, "utf-8");
const reqIds: string[] = [];
const reqIdRegex = /REQ-(\d+)/g;
let match;
while ((match = reqIdRegex.exec(content)) !== null) {
reqIds.push(`REQ-${match[1]}`);
}
const planPath = path.join(projectPath, ".ciagent", "PLAN.md");
if (!fs.existsSync(planPath)) return reqIds;
const planContent = fs.readFileSync(planPath, "utf-8");
const coveredReqIds = new Set<string>();
const planRegex = /REQ-(\d+)/g;
let planMatch;
while ((planMatch = planRegex.exec(planContent)) !== null) {
coveredReqIds.add(`REQ-${planMatch[1]}`);
}
return reqIds.filter((id) => !coveredReqIds.has(id));
}
readRecentLessons(projectPath: string): Array<{ topic: string; detail: string }> {
const lessons: Array<{ topic: string; detail: string }> = [];
try {
const { execSync } = require("node:child_process");
const log = execSync('git log --grep="lessons:" --format="%B" -50', {
cwd: projectPath,
encoding: "utf-8",
timeout: 5000,
});
const lessonsRegex = /lessons:\s*\n((?:\s+-\s+.+\n?)+)/g;
let match;
while ((match = lessonsRegex.exec(log)) !== null) {
const items = match[1].split("\n").filter((l: string) => l.trim().startsWith("-"));
for (const item of items) {
const detail = item.replace(/^\s*-\s*/, "").trim();
const topic = detail.split(":")[0].trim().toLowerCase();
lessons.push({ topic, detail });
}
}
} catch {}
return lessons;
}
findRepeatedPatterns(lessons: Array<{ topic: string; detail: string }>): string[] {
const counts: Record<string, number> = {};
for (const lesson of lessons) {
counts[lesson.topic] = (counts[lesson.topic] || 0) + 1;
}
return Object.entries(counts)
.filter(([, count]) => count > 1)
.map(([topic]) => topic);
}
generateIdeas(uncoveredReqs: string[], repeated: string[], ideas: Idea[]): void {
const repeatedSet = new Set(repeated.map((r) => r.toLowerCase()));
for (const req of uncoveredReqs) {
for (const topic of repeated) {
if (req.toLowerCase().includes(topic) || topic.includes(req.toLowerCase())) {
ideas.push({
source: "improvement_pattern",
title: `Cross-reference: ${req}${topic}`,
rationale: `Repeated lesson "${topic}" directly relates to uncovered requirement ${req}.`,
confidence: 0.85,
relatedReq: req,
});
}
}
}
}
identifyCoverageGaps(projectPath: string): string[] {
const projectMdPath = path.join(projectPath, ".ciagent", "PROJECT.md");
if (!fs.existsSync(projectMdPath)) return [];
const content = fs.readFileSync(projectMdPath, "utf-8");
const agentMentionRegex = /(?:agent|Agent):\s*(\S+)/g;
const mentionedAgents: string[] = [];
let match;
while ((match = agentMentionRegex.exec(content)) !== null) {
mentionedAgents.push(match[1]);
}
const agentsDir = path.join(projectPath, "src", "agents");
if (!fs.existsSync(agentsDir)) return mentionedAgents;
const existingAgents = new Set(
fs.readdirSync(agentsDir)
.filter((f) => f.endsWith(".ts") && !f.endsWith(".test.ts") && !f.endsWith(".d.ts") && f !== "index.ts" && f !== "base.ts")
.map((f) => f.replace(".ts", ""))
);
return mentionedAgents.filter((a) => !existingAgents.has(a) && !existingAgents.has(a.replace(/-agent$/, "")));
}
private formatIdeas(ideas: Idea[]): string {
if (ideas.length === 0) return "No improvement ideas generated.";
const lines: string[] = ["Improvement Ideas:", ""];
for (const idea of ideas) {
lines.push(`[${idea.source}|${idea.confidence.toFixed(2)}] ${idea.title}${idea.rationale}${idea.relatedReq ? ` (req: ${idea.relatedReq})` : ""}`);
}
return lines.join("\n");
mechanicalIdeate(projectPath: string) {
const engine = new IdeationEngine(projectPath);
return engine.runMechanical();
}
}