From 8e50049ba52f27a6aaa005cbd527fadce66e41d4 Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Sat, 30 May 2026 20:13:43 +0000 Subject: [PATCH] =?UTF-8?q?feat(P01):=20add=20ideation=20engine=20+=20ciag?= =?UTF-8?q?ent=20ideate=20command=20=E2=80=94=20IDEATE-01,02,03,17=20+=20M?= =?UTF-8?q?ULTI-01?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ---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. --- src/agents/ideation-agent.test.ts | 82 +-- src/agents/ideation-agent.ts | 169 +----- src/cli/commands.ts | 142 +++++ src/cli/index.ts | 4 +- src/core/ideation.test.ts | 161 ++++++ src/core/ideation.ts | 849 ++++++++++++++++++++++++++++++ src/types/config.ts | 23 + src/types/ideation.ts | 105 ++++ src/types/index.test.ts | 2 +- src/types/pipeline.test.ts | 7 +- src/types/pipeline.ts | 4 + 11 files changed, 1317 insertions(+), 231 deletions(-) create mode 100644 src/core/ideation.test.ts create mode 100644 src/core/ideation.ts create mode 100644 src/types/ideation.ts diff --git a/src/agents/ideation-agent.test.ts b/src/agents/ideation-agent.test.ts index a2eb418..bf3f954 100644 --- a/src/agents/ideation-agent.test.ts +++ b/src/agents/ideation-agent.test.ts @@ -4,74 +4,24 @@ import * as os from "node:os"; import { IdeationAgent } from "../agents/ideation-agent.js"; describe("IdeationAgent", () => { - let tempDir: string; - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-ideation-test-")); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - it("generates ideas from uncovered requirements", () => { - const ciagentDir = path.join(tempDir, ".ciagent"); - fs.mkdirSync(ciagentDir, { recursive: true }); - fs.writeFileSync( - path.join(ciagentDir, "REQUIREMENTS.md"), - "REQ-1: First requirement\nREQ-2: Second requirement" - ); - - const agent = new IdeationAgent(); - const ideas = agent.mechanicalIdeate(tempDir); - - const reqIdeas = ideas.filter((i) => i.source === "uncovered_requirement"); - expect(reqIdeas.length).toBeGreaterThanOrEqual(2); - expect(reqIdeas.some((i) => i.relatedReq === "REQ-1")).toBe(true); - }); - - it("identifies coverage gaps from PROJECT.md", () => { - const ciagentDir = path.join(tempDir, ".ciagent"); - fs.mkdirSync(ciagentDir, { recursive: true }); - fs.writeFileSync( - path.join(ciagentDir, "PROJECT.md"), - "We use agent: magic-agent and agent: super-agent for tasks." - ); - - const srcDir = path.join(tempDir, "src", "agents"); - fs.mkdirSync(srcDir, { recursive: true }); - fs.writeFileSync(path.join(srcDir, "base.ts"), ""); - fs.writeFileSync(path.join(srcDir, "index.ts"), ""); - - const agent = new IdeationAgent(); - const gaps = agent.identifyCoverageGaps(tempDir); - - expect(gaps).toContain("magic-agent"); - expect(gaps).toContain("super-agent"); - }); - - it("finds repeated patterns from lessons list", () => { - const agent = new IdeationAgent(); - const lessons = [ - { topic: "testing", detail: "testing: tests are flaky" }, - { topic: "testing", detail: "testing: more test failures" }, - { topic: "build", detail: "build: CI broken" }, - ]; - - const repeated = agent.findRepeatedPatterns(lessons); - expect(repeated).toContain("testing"); - expect(repeated).not.toContain("build"); - }); - - it("returns empty ideas when no project files exist", () => { - const agent = new IdeationAgent(); - const ideas = agent.mechanicalIdeate(tempDir); - - expect(ideas).toEqual(expect.arrayContaining([])); - }); - it("agent name is ideation-agent", () => { const agent = new IdeationAgent(); expect(agent.name).toBe("ideation-agent"); }); + + it("workflow is research", () => { + const agent = new IdeationAgent(); + expect(agent.workflow).toBe("research"); + }); + + it("delegates mechanicalIdeate to IdeationEngine", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-agent-test-")); + try { + const agent = new IdeationAgent(); + const ideas = agent.mechanicalIdeate(tempDir); + expect(Array.isArray(ideas)).toBe(true); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); }); \ No newline at end of file diff --git a/src/agents/ideation-agent.ts b/src/agents/ideation-agent.ts index 4dad2de..8b01b29 100644 --- a/src/agents/ideation-agent.ts +++ b/src/agents/ideation-agent.ts @@ -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 { @@ -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(); - 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 = {}; - 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(); } } \ No newline at end of file diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 7be416c..7ff25ab 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -1,5 +1,6 @@ import { Command } from "commander"; import { CIAgentConfig, AutonomyLevel } from "../types/config.js"; +import { IdeationCategory, Idea } from "../types/ideation.js"; import { initCIAgent, loadConfig, isCIAgentInitialized, saveConfig } from "../core/config.js"; import { Specification, parseSpecification } from "../types/specification.js"; import { saveSpecification } from "../core/clarify.js"; @@ -941,4 +942,145 @@ function getPreviousTag(projectPath: string, currentTag: string): string | null } catch {} return null; +} + +export function createIdeateCommand(): Command { + return new Command("ideate") + .description("Discover improvement opportunities based on git-native signals and codebase analysis") + .option("-c, --category ", "Focus on specific categories: security,quality,architecture,coverage,improvement,spec,chaos (comma-separated)") + .option("--affected", "Cascade impact analysis: given current changes, identify what else needs updating", false) + .option("--spec", "Analyze specification completeness and ambiguity", false) + .option("--external", "Include external signals: npm audit, dependency staleness", false) + .option("--cross-project", "Mine patterns from all projects in multi-project registry", false) + .option("--output ", "Output format: interactive, json, markdown", "interactive") + .option("--project ", "Target project slug (comma-separated or 'all')") + .action(async (options) => { + const projectPath = process.cwd(); + + if (!isCIAgentInitialized(projectPath)) { + console.error("CIAgent project not initialized in this directory."); + console.error("Run 'ciagent init' to get started."); + process.exit(1); + } + + 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 { IdeationEngine } = await import("../core/ideation.js"); + const engine = new IdeationEngine(projectPath, ciFiles.getProjectSlug() || undefined); + + let allIdeas: Idea[] = []; + + console.log("Running mechanical analysis (tier 1)..."); + allIdeas = engine.runMechanical(categories.length > 0 ? categories : undefined); + + if (options.affected) { + console.log("Running cascade impact analysis (--affected)..."); + const affectedIdeas = engine.runAffected(); + allIdeas = [...allIdeas, ...affectedIdeas]; + } + + 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]; + } + + 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); + + if (options.output === "json") { + const result = engine.formatIdeasJson(allIdeas); + console.log(JSON.stringify(result, null, 2)); + return; + } + + if (options.output === "markdown") { + console.log("\n## Ideation Results\n"); + if (allIdeas.length === 0) { + 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(""); + } + return; + } + + console.log(`\nFound ${allIdeas.length} improvement ${allIdeas.length === 1 ? "idea" : "ideas"}\n`); + + if (allIdeas.length === 0) { + console.log("No improvement ideas identified for this project."); + console.log("Try running with --spec, --external, or --cross-project for additional signals."); + return; + } + + for (let i = 0; i < allIdeas.length; i++) { + const idea = allIdeas[i]; + console.log(`═══ Recommendation ${i + 1} of ${allIdeas.length} ═══\n`); + console.log(`Category: ${idea.category.toUpperCase()} | Confidence: ${idea.confidence.toFixed(2)} | Tier: ${idea.tier}`); + console.log(`Title: ${idea.title}`); + console.log(`Rationale: ${idea.rationale}`); + if (idea.relatedReq) console.log(`Related Req: ${idea.relatedReq}`); + console.log(`Source: ${idea.source}`); + console.log(`Actions: ${idea.actions.join(", ")}`); + console.log(""); + } + + const byCategory: Record = {}; + for (const idea of allIdeas) { + byCategory[idea.category] = (byCategory[idea.category] || 0) + 1; + } + + console.log("─── Summary ───\n"); + console.log(`Total ideas: ${allIdeas.length}`); + for (const [cat, count] of Object.entries(byCategory)) { + console.log(` ${cat}: ${count}`); + } + }); } \ No newline at end of file diff --git a/src/cli/index.ts b/src/cli/index.ts index 448e979..e15c095 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -17,6 +17,7 @@ import { createRollbackCommand, createShipCommand, createProjectsCommand, + createIdeateCommand, } from "./commands.js"; let activeEscalationProtocol: { dispose(): void } | null = null; @@ -63,6 +64,7 @@ program .addCommand(createClarifyCommand()) .addCommand(createRollbackCommand()) .addCommand(createShipCommand()) - .addCommand(createProjectsCommand()); + .addCommand(createProjectsCommand()) + .addCommand(createIdeateCommand()); program.parse(); \ No newline at end of file diff --git a/src/core/ideation.test.ts b/src/core/ideation.test.ts new file mode 100644 index 0000000..e313ce3 --- /dev/null +++ b/src/core/ideation.test.ts @@ -0,0 +1,161 @@ +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 } from "../core/ideation.js"; + +describe("IdeationAgent", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-ideation-test-")); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it("agent name is ideation-agent", () => { + const agent = new IdeationAgent(); + expect(agent.name).toBe("ideation-agent"); + }); + + it("delegates to IdeationEngine for mechanical ideation", () => { + const agent = new IdeationAgent(); + const ideas = agent.mechanicalIdeate(tempDir); + expect(Array.isArray(ideas)).toBe(true); + }); +}); + +describe("IdeationEngine", () => { + let tempDir: string; + + beforeEach(() => { + resetIdeaCounter(); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-engine-test-")); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it("generates ideas from uncovered requirements", () => { + 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, "REQUIREMENTS.md"), + "# Requirements\n\n## v1 Requirements\n\n### Core\n\n- **REQ-01**: First requirement\n- **REQ-02**: Second requirement\n\n## Traceability\n\n| Requirement | Phase | Status |\n|-------------|-------|--------|\n| REQ-01 | Phase 1 | pending |\n| REQ-02 | Phase 1 | pending |\n" + ); + fs.writeFileSync( + path.join(ciagentDir, "PROJECT.md"), + "# Test Project\n\n## What This Is\n\nA test project.\n\n## Requirements\n\n### Validated\n\n- REQ-01: First\n\n### Active\n\n- [ ] REQ-02: Second\n\n## Constraints\n\n- Must work\n\n## Key Decisions\n\n| Decision | Rationale | Outcome |\n|----------|-----------|--------|\n" + ); + fs.writeFileSync( + path.join(ciagentDir, "ROADMAP.md"), + "# Roadmap\n\n## Overview\n\nTest roadmap.\n\n## Phases\n\n- [ ] **Phase 1: Init** - Starting\n\n## Phase Details\n\n### Phase 1: Init\n\n**Goal.**: Start\n**Status**: not_started\n**Requirements**: REQ-01\n**Depends on**: Nothing\n**Success Criteria**:\n1. Project initialized\n" + ); + fs.writeFileSync( + path.join(ciagentDir, "ARCHITECTURE.md"), + "# Architecture\n\n## Overview\n\nTest architecture.\n\n## Components\n\n### Core\n\n- **Description**: Core module\n- **Boundaries**: Internal only\n- **Depends on**: None\n\n## Data Flow\n\nSimple flow.\n\n## Build Order\n\n1. Core\n" + ); + + const engine = new IdeationEngine(tempDir); + const ideas = engine.runMechanical(["coverage"]); + const reqIdeas = ideas.filter((i) => i.source === "uncovered_requirement"); + expect(reqIdeas.length).toBeGreaterThanOrEqual(1); + }); + + it("detects architecture drift when documented components are missing", () => { + 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### NonExistentModule\n\n- **Description**: A module that does not exist\n- **Boundaries**: None\n- **Depends on**: None\n\n## Data Flow\n\nFlow.\n\n## Build Order\n\n1. Core\n" + ); + + const engine = new IdeationEngine(tempDir); + const ideas = engine.runMechanical(["architecture"]); + const driftIdeas = ideas.filter((i) => i.source === "architecture_drift"); + expect(driftIdeas.length).toBeGreaterThanOrEqual(1); + }); + + it("detects spec ambiguity or spec missing", () => { + 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, "PROJECT.md"), + "# Test\n\n## What This Is\n\nThe system should handle user input and could process data. It might also log events.\n\n## Requirements\n\n### Validated\n\n\n### Active\n\n- [ ] The system should handle errors\n- [ ] Users could configure settings\n- [ ] It might send notifications\n\n## Constraints\n\n- Must work\n\n## Key Decisions\n\n| Decision | Rationale | Outcome |\n|----------|-----------|--------|\n" + ); + fs.writeFileSync( + path.join(ciagentDir, "REQUIREMENTS.md"), + "# Requirements\n\n## v1\n\n- REQ-01: Test\n\n## Traceability\n\n| Requirement | Phase | Status |\n|-------------|-------|--------|\n" + ); + fs.writeFileSync( + path.join(ciagentDir, "ROADMAP.md"), + "# Roadmap\n\n## Overview\n\nTest\n\n## Phases\n\n\n## Phase Details\n\n" + ); + fs.writeFileSync( + path.join(ciagentDir, "ARCHITECTURE.md"), + "# Architecture\n\n## Overview\n\nTest\n\n## Components\n\n## Data Flow\n\nTest\n\n## Build Order\n\n1. Test\n" + ); + + const engine = new IdeationEngine(tempDir); + const ideas = engine.runMechanical(["spec"]); + const specIdeas = ideas.filter((i) => i.source === "spec_ambiguity" || i.source === "spec_missing" || i.source === "spec_contradiction"); + expect(specIdeas.length).toBeGreaterThanOrEqual(1); + }); + + it("returns empty ideas when no project files exist", () => { + const engine = new IdeationEngine(tempDir); + const ideas = engine.runMechanical(); + expect(Array.isArray(ideas)).toBe(true); + }); + + it("formats ideas as readable text", () => { + 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, "PROJECT.md"), "# Test\n\n## What This Is\n\nTest\n\n## Requirements\n\n### Validated\n\n\n### Active\n\n\n## Constraints\n\n- None\n\n## Key Decisions\n\n| Decision | Rationale | Outcome |\n|----------|-----------|--------|\n"); + fs.writeFileSync(path.join(ciagentDir, "REQUIREMENTS.md"), "# Requirements\n\n## v1\n\n- REQ-01: Test\n\n## Traceability\n\n| Requirement | Phase | Status |\n|-------------|-------|--------|\n| REQ-01 | Phase 1 | pending |\n"); + fs.writeFileSync(path.join(ciagentDir, "ROADMAP.md"), "# Roadmap\n\n## Overview\n\nTest\n\n## Phases\n\n\n## Phase Details\n\n"); + fs.writeFileSync(path.join(ciagentDir, "ARCHITECTURE.md"), "# Architecture\n\n## Overview\n\nTest\n\n## Components\n\n## Data Flow\n\nTest\n\n## Build Order\n\n1. Test\n"); + + const engine = new IdeationEngine(tempDir); + const formatted = engine.formatIdeas(engine.runMechanical()); + expect(typeof formatted).toBe("string"); + }); + + it("formats ideas as JSON", () => { + 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, "PROJECT.md"), "# Test\n\n## What This Is\n\nTest\n\n## Requirements\n\n### Validated\n\n\n### Active\n\n\n## Constraints\n\n- None\n\n## Key Decisions\n\n| Decision | Rationale | Outcome |\n|----------|-----------|--------|\n"); + fs.writeFileSync(path.join(ciagentDir, "REQUIREMENTS.md"), "# Requirements\n\n## v1\n\n- REQ-01: Test\n\n## Traceability\n\n| Requirement | Phase | Status |\n|-------------|-------|--------|\n| REQ-01 | Phase 1 | pending |\n"); + fs.writeFileSync(path.join(ciagentDir, "ROADMAP.md"), "# Roadmap\n\n## Overview\n\nTest\n\n## Phases\n\n\n## Phase Details\n\n"); + fs.writeFileSync(path.join(ciagentDir, "ARCHITECTURE.md"), "# Architecture\n\n## Overview\n\nTest\n\n## Components\n\n## Data Flow\n\nTest\n\n## Build Order\n\n1. Test\n"); + + const engine = new IdeationEngine(tempDir); + const result = engine.formatIdeasJson(engine.runMechanical()); + expect(result).toHaveProperty("ideas"); + expect(result).toHaveProperty("summary"); + expect(result).toHaveProperty("project"); + }); +}); \ No newline at end of file diff --git a/src/core/ideation.ts b/src/core/ideation.ts new file mode 100644 index 0000000..22adf1c --- /dev/null +++ b/src/core/ideation.ts @@ -0,0 +1,849 @@ +import * as fs from "node:fs"; +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; + by_tier: Record; +} + +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"], + }, +}; + +let ideaCounter = 0; + +function nextIdeaId(): string { + ideaCounter++; + return `IDEATE-${String(ideaCounter).padStart(2, "0")}`; +} + +export function resetIdeaCounter(): void { + ideaCounter = 0; +} + +export class IdeationEngine { + private ciFiles: CIAgentFiles; + private projectPath: string; + + constructor(projectPath: string, projectSlug?: string) { + this.projectPath = projectPath; + this.ciFiles = new CIAgentFiles(projectPath); + if (projectSlug) { + this.ciFiles.setProjectSlug(projectSlug); + } + } + + runMechanical(categories?: IdeationCategory[]): Idea[] { + resetIdeaCounter(); + const ideas: Idea[] = []; + const filterCategories = categories || DEFAULT_IDEATION_CONFIG.categories; + + const shouldCategory = (cat: IdeationCategory): boolean => + filterCategories.length === 0 || filterCategories.includes(cat); + + if (shouldCategory("coverage")) { + ideas.push(...this.mineUncoveredRequirements()); + ideas.push(...this.minePartialRequirements()); + ideas.push(...this.mineCoverageGaps()); + } + + if (shouldCategory("quality") || shouldCategory("improvement")) { + ideas.push(...this.mineRepeatedLessons()); + ideas.push(...this.mineLowConfidenceDecisions()); + ideas.push(...this.mineCompoundPatterns()); + } + + if (shouldCategory("architecture")) { + ideas.push(...this.mineArchitectureDrift()); + } + + if (shouldCategory("security")) { + ideas.push(...this.mineEscalationPatterns()); + } + + if (shouldCategory("improvement")) { + ideas.push(...this.mineImprovementPatterns()); + } + + if (shouldCategory("spec")) { + ideas.push(...this.mineSpecAmbiguity()); + ideas.push(...this.mineSpecContradictions()); + ideas.push(...this.mineSpecMissing()); + } + + if (shouldCategory("quality")) { + ideas.push(...this.mineVerificationInversion()); + } + + ideas.sort((a, b) => b.confidence - a.confidence); + + return ideas.slice(0, DEFAULT_IDEATION_CONFIG.max_ideas); + } + + private mineUncoveredRequirements(): Idea[] { + const ideas: Idea[] = []; + const reqs = this.ciFiles.readRequirementsMd(); + if (!reqs) return ideas; + + const coveredReqs = new Set(); + for (const t of reqs.traceability) { + if (t.status === "complete") { + coveredReqs.add(t.requirement); + } + } + + const allReqIds = new Set(); + for (const cat of [...reqs.v1, ...reqs.v2]) { + for (const item of cat.items) { + allReqIds.add(item.id); + } + } + + for (const reqId of allReqIds) { + if (!coveredReqs.has(reqId)) { + ideas.push({ + id: nextIdeaId(), + source: "uncovered_requirement", + category: "coverage", + title: `Address uncovered requirement: ${reqId}`, + rationale: `Requirement ${reqId} exists in REQUIREMENTS.md but has no completed implementation traceability record.`, + confidence: 0.85, + relatedReq: reqId, + actions: ["add_requirement", "update_roadmap"], + tier: "mechanical", + }); + } + } + + return ideas; + } + + private minePartialRequirements(): Idea[] { + const ideas: Idea[] = []; + const reqs = this.ciFiles.readRequirementsMd(); + if (!reqs) return ideas; + + for (const t of reqs.traceability) { + if (t.status === "in_progress") { + ideas.push({ + id: nextIdeaId(), + source: "partial_requirement", + category: "coverage", + title: `Complete in-progress requirement: ${t.requirement}`, + rationale: `Requirement ${t.requirement} (Phase ${t.phase}) is in progress but not complete. In-progress items may be blocked or abandoned.`, + confidence: 0.75, + relatedReq: t.requirement, + actions: ["add_requirement"], + tier: "mechanical", + }); + } + } + + return ideas; + } + + private mineCoverageGaps(): Idea[] { + const ideas: Idea[] = []; + const projectMd = this.ciFiles.readProjectMd(); + if (!projectMd) return ideas; + + const mentionedAgents: string[] = []; + const agentRegex = /(?:agent|Agent)[:\s]+(\S+)/g; + let match; + while ((match = agentRegex.exec(projectMd.coreValue || "")) !== null) { + mentionedAgents.push(match[1]); + } + + const agentsDir = path.join(this.projectPath, "src", "agents"); + if (!fs.existsSync(agentsDir)) return ideas; + + 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", "")) + ); + + for (const agent of mentionedAgents) { + if (!existingAgents.has(agent) && !existingAgents.has(agent.replace(/-agent$/, ""))) { + ideas.push({ + id: nextIdeaId(), + source: "gap_in_coverage", + category: "coverage", + title: `Fill coverage gap: ${agent}`, + rationale: `Agent "${agent}" is mentioned in PROJECT.md but not found in the agent registry.`, + confidence: 0.75, + actions: ["add_requirement"], + tier: "mechanical", + }); + } + } + + return ideas; + } + + private mineRepeatedLessons(): Idea[] { + const ideas: Idea[] = []; + const lessons = this.readGitLessons(); + const topicCounts: Record = {}; + const topicDetails: Record = {}; + + for (const lesson of lessons) { + const topic = lesson.topic; + topicCounts[topic] = (topicCounts[topic] || 0) + 1; + if (!topicDetails[topic]) topicDetails[topic] = []; + topicDetails[topic].push(lesson.detail); + } + + for (const [topic, count] of Object.entries(topicCounts)) { + if (count > 1) { + ideas.push({ + id: nextIdeaId(), + source: "repeated_lesson", + category: "improvement", + title: `Investigate repeated lesson: ${topic}`, + rationale: `Topic "${topic}" appears ${count} times in commit lessons (${topicDetails[topic].slice(0, 2).join("; ")}), indicating a systemic issue.`, + confidence: Math.min(0.7 + count * 0.05, 0.95), + actions: ["add_requirement", "refactor"], + tier: "mechanical", + }); + } + } + + return ideas; + } + + private mineLowConfidenceDecisions(): Idea[] { + const ideas: Idea[] = []; + try { + const log = execSync( + 'git log --all --grep="decisions:" --format="%B" -50', + { cwd: this.projectPath, encoding: "utf-8", timeout: 5000 } + ); + + const decisionRegex = /confidence:\s*([\d.]+)/gi; + let match; + while ((match = decisionRegex.exec(log)) !== null) { + const confidence = parseFloat(match[1]); + if (confidence < 0.7 && confidence > 0) { + const contextStart = Math.max(0, match.index - 200); + const context = log.slice(contextStart, match.index + 100); + const idMatch = context.match(/id:\s*(D-\d+)/i); + ideas.push({ + id: nextIdeaId(), + source: "low_confidence_decision", + category: "improvement", + title: `Revisit low-confidence decision${idMatch ? ` ${idMatch[1]}` : ""}`, + rationale: `A decision was made with confidence ${confidence.toFixed(2)} (below 0.7 threshold). Low-confidence decisions are prime candidates for re-evaluation.`, + confidence: 0.8, + actions: ["update_roadmap"], + tier: "mechanical", + }); + } + } + } catch {} + + return ideas; + } + + private mineEscalationPatterns(): Idea[] { + const ideas: Idea[] = []; + try { + const log = execSync( + 'git log --all --grep="escalation:" --format="%B" -50', + { cwd: this.projectPath, encoding: "utf-8", timeout: 5000 } + ); + + const typeCounts: Record = {}; + const escalationRegex = /type:\s*(\S+)/gi; + let match; + while ((match = escalationRegex.exec(log)) !== null) { + const type = match[1].toLowerCase(); + typeCounts[type] = (typeCounts[type] || 0) + 1; + } + + for (const [type, count] of Object.entries(typeCounts)) { + if (count >= 1) { + ideas.push({ + id: nextIdeaId(), + source: "escalation_pattern", + category: "security", + title: `Address escalation pattern: ${type}`, + rationale: `Escalation type "${type}" occurred ${count} time(s). Recurring escalation types indicate process gaps that should be addressed.`, + confidence: 0.7 + Math.min(count * 0.1, 0.2), + actions: ["add_security_pattern", "update_roadmap"], + tier: "mechanical", + }); + } + } + } catch {} + + return ideas; + } + + private mineCompoundPatterns(): Idea[] { + const ideas: Idea[] = []; + try { + const log = execSync( + 'git log --all --grep="compound:" --format="%B" -50', + { cwd: this.projectPath, encoding: "utf-8", timeout: 5000 } + ); + + const compoundRegex = /compound:\s*\n((?:\s+-\s+.+\n?)+)/g; + const topicCounts: Record = {}; + let match; + while ((match = compoundRegex.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(); + topicCounts[topic] = (topicCounts[topic] || 0) + 1; + } + } + + for (const [topic, count] of Object.entries(topicCounts)) { + if (count > 1) { + ideas.push({ + id: nextIdeaId(), + source: "compound_pattern", + category: "improvement", + title: `Generalize compounded solution: ${topic}`, + rationale: `Solution pattern "${topic}" was compounded ${count} times. Consider generalizing this into a shared utility or documented approach.`, + confidence: 0.75, + actions: ["refactor", "update_architecture"], + tier: "mechanical", + }); + } + } + } catch {} + + return ideas; + } + + private mineArchitectureDrift(): Idea[] { + const ideas: Idea[] = []; + const archMd = this.ciFiles.readArchitectureMd(); + if (!archMd) return ideas; + + for (const component of archMd.components) { + const expectedDir = component.name.toLowerCase().replace(/\s+/g, "-"); + const possiblePaths = [ + path.join(this.projectPath, "src", expectedDir), + path.join(this.projectPath, "src", component.name), + path.join(this.projectPath, component.name.toLowerCase()), + ]; + + const dirExists = possiblePaths.some((p) => fs.existsSync(p)); + if (!dirExists) { + ideas.push({ + id: nextIdeaId(), + source: "architecture_drift", + category: "architecture", + title: `Documented component not found: ${component.name}`, + rationale: `ARCHITECTURE.md documents component "${component.name}" but no corresponding directory exists in src/. Either the component is missing or the documentation is stale.`, + confidence: 0.7, + actions: ["update_architecture", "fix_documentation"], + tier: "mechanical", + }); + } + } + + const srcDir = path.join(this.projectPath, "src"); + if (fs.existsSync(srcDir)) { + const knownComponents = new Set(archMd.components.map((c) => c.name.toLowerCase().replace(/\s+/g, "-"))); + + try { + const srcEntries = fs.readdirSync(srcDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name.toLowerCase()); + + for (const entry of srcEntries) { + if (!knownComponents.has(entry) && !entry.startsWith(".") && entry !== "types" && entry !== "utils") { + ideas.push({ + id: nextIdeaId(), + source: "architecture_drift", + category: "architecture", + title: `Undocumented source directory: src/${entry}`, + rationale: `Directory src/${entry}/ exists but is not documented in ARCHITECTURE.md. This indicates architectural drift.`, + confidence: 0.65, + actions: ["update_architecture"], + tier: "mechanical", + }); + } + } + } catch {} + } + + return ideas; + } + + private mineVerificationInversion(): Idea[] { + const ideas: Idea[] = []; + + const srcDir = path.join(this.projectPath, "src"); + if (!fs.existsSync(srcDir)) return ideas; + + const testFiles: string[] = []; + const srcFiles: string[] = []; + + try { + const walkDir = (dir: string) => { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules") { + walkDir(fullPath); + } else if (entry.isFile() && entry.name.endsWith(".ts")) { + if (entry.name.endsWith(".test.ts")) { + testFiles.push(entry.name.replace(".test.ts", "")); + } else if (!entry.name.endsWith(".d.ts") && !entry.name.includes(".test.")) { + srcFiles.push(entry.name.replace(".ts", "")); + } + } + } + }; + walkDir(srcDir); + } catch {} + + const testedModules = new Set(testFiles); + for (const srcModule of srcFiles) { + if (!testedModules.has(srcModule) && srcModule !== "index" && srcModule !== "base") { + ideas.push({ + id: nextIdeaId(), + source: "verification_inversion", + category: "quality", + title: `Missing tests for: ${srcModule}`, + rationale: `Source file ${srcModule}.ts has no corresponding test file ${srcModule}.test.ts. The behavioral verification layer identifies this as a coverage gap.`, + confidence: 0.7, + actions: ["add_test"], + tier: "mechanical", + }); + } + } + + return ideas.slice(0, 10); + } + + private mineImprovementPatterns(): Idea[] { + const ideas: Idea[] = []; + const reqs = this.ciFiles.readRequirementsMd(); + const lessons = this.readGitLessons(); + + if (!reqs) return ideas; + + const uncoveredSet = new Set(); + for (const t of reqs.traceability) { + if (t.status === "pending") { + uncoveredSet.add(t.requirement); + } + } + + const topics = lessons.map((l) => l.topic.toLowerCase()); + + for (const reqId of uncoveredSet) { + for (const topic of topics) { + if (reqId.toLowerCase().includes(topic) || topic.includes(reqId.toLowerCase())) { + ideas.push({ + id: nextIdeaId(), + source: "improvement_pattern", + category: "improvement", + title: `Cross-reference: ${reqId} ↔ ${topic}`, + rationale: `Repeated lesson "${topic}" directly relates to uncovered requirement ${reqId}. Addressing the lesson may resolve the requirement.`, + confidence: 0.85, + relatedReq: reqId, + actions: ["add_requirement", "update_roadmap"], + tier: "mechanical", + }); + } + } + } + + return ideas; + } + + private mineSpecAmbiguity(): Idea[] { + const ideas: Idea[] = []; + const projectMd = this.ciFiles.readProjectMd(); + if (!projectMd) return ideas; + + const ambiguousTerms = ["should", "could", "might", "may", "would", "possibly", "perhaps"]; + const specText = [projectMd.coreValue, ...projectMd.requirements.active].join(" "); + + for (const term of ambiguousTerms) { + const regex = new RegExp(`\\b${term}\\b`, "gi"); + const matches = specText.match(regex); + if (matches && matches.length > 2) { + ideas.push({ + id: nextIdeaId(), + source: "spec_ambiguity", + category: "spec", + title: `Ambiguous language in specification: "${term}" (${matches.length} occurrences)`, + rationale: `The term "${term}" appears ${matches.length} times in project specification. Consider replacing with "must" or "shall" for clarity, or marking as optional.`, + confidence: 0.65, + actions: ["fix_documentation"], + tier: "mechanical", + }); + } + } + + return ideas; + } + + private mineSpecContradictions(): Idea[] { + return []; + } + + private mineSpecMissing(): Idea[] { + const ideas: Idea[] = []; + const projectMd = this.ciFiles.readProjectMd(); + if (!projectMd) return ideas; + + const specText = (projectMd.coreValue + " " + projectMd.requirements.active.join(" ")).toLowerCase(); + + const commonCategories: Array<{ keyword: string; title: string; category: IdeationCategory }> = [ + { keyword: "auth", title: "Add authentication and authorization requirements", category: "security" }, + { keyword: "rate", title: "Add rate limiting requirements", category: "security" }, + { keyword: "log", title: "Add logging and observability requirements", category: "quality" }, + { keyword: "error", title: "Add error handling and recovery requirements", category: "quality" }, + { keyword: "test", title: "Add testing strategy requirements", category: "coverage" }, + { keyword: "doc", title: "Add documentation requirements", category: "improvement" }, + { keyword: "config", title: "Add configuration management requirements", category: "architecture" }, + ]; + + for (const cat of commonCategories) { + if (!specText.includes(cat.keyword)) { + ideas.push({ + id: nextIdeaId(), + source: "spec_missing", + category: cat.category, + title: cat.title, + rationale: `No mention of "${cat.keyword}" in the project specification. This is a common requirement category that may be missing.`, + confidence: 0.55, + actions: ["add_requirement"], + tier: "mechanical", + }); + } + } + + return ideas; + } + + private readGitLessons(): Array<{ topic: string; detail: string }> { + const lessons: Array<{ topic: string; detail: string }> = []; + try { + const log = execSync('git log --all --grep="lessons:" --format="%B" -50', { + cwd: this.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; + } + + runAffected(): Idea[] { + resetIdeaCounter(); + const ideas: Idea[] = []; + + try { + const diff = execSync("git diff --name-only HEAD", { + cwd: this.projectPath, + encoding: "utf-8", + timeout: 5000, + }); + + const changedFiles = diff.trim().split("\n").filter(Boolean); + if (changedFiles.length === 0) return ideas; + + const archMd = this.ciFiles.readArchitectureMd(); + if (!archMd) return ideas; + + for (const changedFile of changedFiles) { + const parts = changedFile.split("/").filter(Boolean); + const srcIdx = parts.indexOf("src"); + if (srcIdx >= 0 && parts.length > srcIdx + 1) { + const component = parts[srcIdx + 1]; + const matchingComponent = archMd.components.find( + (c) => c.name.toLowerCase().replace(/\s+/g, "-") === component.toLowerCase() + ); + + if (matchingComponent) { + for (const dep of matchingComponent.dependsOn) { + ideas.push({ + id: nextIdeaId(), + source: "gap_in_coverage", + category: "architecture", + title: `Cascade impact: ${changedFile} may affect ${dep}`, + rationale: `Component "${matchingComponent.name}" (which depends on "${dep}") was modified. Verify that "${dep}" still works correctly.`, + confidence: 0.7, + actions: ["add_test"], + tier: "mechanical", + }); + } + } + } + } + } catch {} + + return ideas; + } + + runExternal(): Idea[] { + resetIdeaCounter(); + const ideas: Idea[] = []; + + try { + const auditResult = execSync("npm audit --json 2>/dev/null || echo '{}'", { + cwd: this.projectPath, + encoding: "utf-8", + timeout: 30000, + }); + + const audit = JSON.parse(auditResult); + const vulnerabilities = audit.vulnerabilities || {}; + + for (const [pkg, info] of Object.entries(vulnerabilities as Record)) { + const severity = info.severity || "unknown"; + if (severity === "high" || severity === "critical") { + ideas.push({ + id: nextIdeaId(), + source: "external_signal", + category: "security", + title: `${severity.toUpperCase()} vulnerability in ${pkg}`, + rationale: `npm audit reports a ${severity} severity vulnerability in "${pkg}". This should be addressed immediately.`, + confidence: 0.95, + actions: ["add_security_pattern", "update_roadmap"], + tier: "mechanical", + }); + } else if (severity === "moderate") { + ideas.push({ + id: nextIdeaId(), + source: "external_signal", + category: "security", + title: `Moderate vulnerability in ${pkg}`, + rationale: `npm audit reports a moderate severity vulnerability in "${pkg}". Consider upgrading this dependency.`, + confidence: 0.8, + actions: ["add_security_pattern"], + tier: "mechanical", + }); + } + } + } catch {} + + try { + const result = execSync("npm outdated --json 2>/dev/null || echo '{}'", { + cwd: this.projectPath, + encoding: "utf-8", + timeout: 15000, + }); + + const outdated = JSON.parse(result); + let staleCount = 0; + for (const pkg of Object.keys(outdated)) { + staleCount++; + } + + if (staleCount > 5) { + ideas.push({ + id: nextIdeaId(), + source: "external_signal", + category: "quality", + title: `${staleCount} outdated dependencies`, + rationale: `${staleCount} packages are outdated. Consider scheduling a dependency upgrade task.`, + confidence: 0.6, + actions: ["update_roadmap"], + tier: "mechanical", + }); + } + } catch {} + + return ideas; + } + + runCrossProject(): Idea[] { + resetIdeaCounter(); + const ideas: Idea[] = []; + + const projects = this.ciFiles.listProjects(); + if (projects.length <= 1) return ideas; + + const currentSlug = this.ciFiles.getProjectSlug() || projects[0].slug; + + for (const project of projects) { + if (project.slug === currentSlug) continue; + + const projectDir = path.join(this.projectPath, ".ciagent", project.slug); + if (!fs.existsSync(projectDir)) continue; + + try { + const log = execSync( + `git log --all --grep="lessons:" --format="%B" -20`, + { cwd: this.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(); + ideas.push({ + id: nextIdeaId(), + source: "cross_project_lesson", + category: "improvement", + title: `Cross-project lesson from ${project.slug}: ${topic}`, + rationale: `Project "${project.slug}" learned: "${detail}". Consider whether this applies to the current project.`, + confidence: 0.6, + actions: ["add_requirement"], + tier: "cross-project", + }); + } + } + } catch {} + } + + return ideas; + } + + formatIdeas(ideas: Idea[]): string { + if (ideas.length === 0) return "No improvement ideas identified for this project."; + + 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"); + } + + formatIdeasJson(ideas: Idea[]): IdeationResult { + const byCategory: Record = {}; + const byTier: Record = {}; + for (const idea of ideas) { + byCategory[idea.category] = (byCategory[idea.category] || 0) + 1; + byTier[idea.tier] = (byTier[idea.tier] || 0) + 1; + } + + return { + project: this.ciFiles.getProjectSlug() || "default", + milestone: "", + ideas, + summary: { + total: ideas.length, + accepted: 0, + skipped: 0, + by_category: byCategory, + by_tier: byTier, + }, + }; + } +} \ No newline at end of file diff --git a/src/types/config.ts b/src/types/config.ts index 3c7aaba..bbf2ded 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -1,4 +1,5 @@ import { BackendConfigSection } from "../backends/types.js"; +import { IdeationConfig, IdeationCategory } from "./ideation.js"; export type AutonomyLevel = "full" | "supervised" | "guided"; @@ -82,6 +83,7 @@ export interface ProjectEntry { export interface CIAgentConfig { projects: ProjectEntry[]; active_project: string; + active_projects: string[]; autonomy: AutonomyConfig; model_profile: ModelProfile; parallelization: ParallelizationConfig; @@ -90,11 +92,13 @@ export interface CIAgentConfig { git: GitConfig; backend: BackendConfigSection; gitea?: GiteaConfig; + ideation?: IdeationConfig; } export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = { projects: [], active_project: "", + active_projects: [], autonomy: { level: "full", escalation_hooks: ["deploy", "delete_data", "merge_to_main"], @@ -165,4 +169,23 @@ export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = { owner: "", repo: "", }, + ideation: { + enabled: true, + categories: ["security", "quality", "architecture", "coverage", "improvement"] as IdeationCategory[], + 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"], + }, + }, }; \ No newline at end of file diff --git a/src/types/ideation.ts b/src/types/ideation.ts new file mode 100644 index 0000000..9c83073 --- /dev/null +++ b/src/types/ideation.ts @@ -0,0 +1,105 @@ +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; + by_tier: Record; +} + +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"], + }, +}; \ No newline at end of file diff --git a/src/types/index.test.ts b/src/types/index.test.ts index ece11cd..e3b5c97 100644 --- a/src/types/index.test.ts +++ b/src/types/index.test.ts @@ -7,7 +7,7 @@ import { DEFAULT_CIAGENT_CONFIG } from "../types/config.js"; describe("Type exports", () => { it("pipeline types are importable and functional", () => { - expect(STAGE_ORDER).toHaveLength(8); + expect(STAGE_ORDER).toHaveLength(9); expect(getNextStage("specify")).toBe("clarify"); const state = createInitialPipelineState("/tmp/test"); expect(state.current_stage).toBe("specify"); diff --git a/src/types/pipeline.test.ts b/src/types/pipeline.test.ts index cc6eebb..d2449a0 100644 --- a/src/types/pipeline.test.ts +++ b/src/types/pipeline.test.ts @@ -8,11 +8,12 @@ import { } from "../types/pipeline.js"; describe("STAGE_ORDER", () => { - it("has 8 stages in correct order", () => { + it("has 9 stages in correct order", () => { expect(STAGE_ORDER).toEqual([ "specify", "clarify", "research", + "ideate", "plan", "execute", "test", @@ -26,7 +27,8 @@ describe("getNextStage", () => { it("returns the next stage in sequence", () => { expect(getNextStage("specify")).toBe("clarify"); expect(getNextStage("clarify")).toBe("research"); - expect(getNextStage("research")).toBe("plan"); + expect(getNextStage("research")).toBe("ideate"); + expect(getNextStage("ideate")).toBe("plan"); expect(getNextStage("plan")).toBe("execute"); expect(getNextStage("execute")).toBe("test"); expect(getNextStage("test")).toBe("verify"); @@ -51,6 +53,7 @@ describe("createInitialPipelineState", () => { expect(state.specification_loaded).toBe(false); expect(state.clarify_completed).toBe(false); expect(state.research_completed).toBe(false); + expect(state.ideate_completed).toBe(false); expect(state.plan_completed).toBe(false); expect(state.execute_completed).toBe(false); expect(state.test_completed).toBe(false); diff --git a/src/types/pipeline.ts b/src/types/pipeline.ts index 2e60dfd..1fd4364 100644 --- a/src/types/pipeline.ts +++ b/src/types/pipeline.ts @@ -4,6 +4,7 @@ export type PipelineStage = | "specify" | "clarify" | "research" + | "ideate" | "plan" | "execute" | "test" @@ -18,6 +19,7 @@ export interface PipelineState { specification_loaded: boolean; clarify_completed: boolean; research_completed: boolean; + ideate_completed: boolean; plan_completed: boolean; execute_completed: boolean; test_completed: boolean; @@ -61,6 +63,7 @@ export const STAGE_ORDER: PipelineStage[] = [ "specify", "clarify", "research", + "ideate", "plan", "execute", "test", @@ -85,6 +88,7 @@ export function createInitialPipelineState( specification_loaded: false, clarify_completed: false, research_completed: false, + ideate_completed: false, plan_completed: false, execute_completed: false, test_completed: false,