Compare commits

...

3 Commits

Author SHA1 Message Date
Jon Chery b7d02ee4a4 feat(P01): interactive validation + doc updates + multi-project CLI — IDEATE-12,13,14 + MULTI-02,06
CI / build-and-test (pull_request) Has been cancelled
Publish to npm / publish (push) Has been cancelled
CI / build-and-test (push) Has been cancelled
---ci---
phase: 1
milestone: v0.10
status: execute
decisions:
  - id: D-083
    decision: Interactive one-at-a-time validation with accept/skip/modify
    rationale: Gives user full control over ideation results
    confidence: 0.87
  - id: D-085
    decision: Ask-after-validation kickoff of run workflow
    rationale: Balances automation with user control
    confidence: 0.85
  - id: D-091
    decision: Full multi-project support with active_projects array + parallel execution
    rationale: User wants complete multi-project capability
    confidence: 0.85
requirements:
  covered:
    - IDEATE-12
    - IDEATE-13
    - IDEATE-14
    - MULTI-02
    - MULTI-06
---/ci---

- IDEATE-12: Interactive accept/skip/modify validation with readline
- IDEATE-13: acceptIdea/acceptIdeas methods update REQUIREMENTS.md and ROADMAP.md
- IDEATE-14: Ask-after-validation kickoff prompt for
- MULTI-02: --project flag accepts comma-separated or 'all' in pre-action hook
- MULTI-06: ciagent status shows active_projects and ideation config
- projects list shows all active projects with multi-marker
- projects set updates both active_project and active_projects
2026-05-30 20:26:36 +00:00
Jon Chery 8e50049ba5 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.
2026-05-30 20:13:43 +00:00
Jon Chery da528cc493 docs: add ideate workflow + update run workflow with IDEATE stage and multi-project
---ci---
phase: 0
milestone: v0.10
status: specify
decisions:
  - id: D-089
    decision: No separate codebase map command — subsumed by ideation
    rationale: Git-native + .ciagent/ covers all mapping needs; avoids tree-sitter dep
    confidence: 0.88
  - id: D-090
    decision: Milestone v0.10 for ideate + multi-project
    rationale: Significant features but not schema-breaking
    confidence: 0.95
---/ci---

- Add opencode/ci/workflows/ideate.md: full ideation pipeline specification
- Update opencode/ci/workflows/run.md: add IDEATE stage, update multi-project Step 0
2026-05-30 19:45:30 +00:00
14 changed files with 1921 additions and 243 deletions
+288
View File
@@ -0,0 +1,288 @@
---
description: Run the CIAgent ideation pipeline — analyze project for improvement opportunities, validate recommendations with user, update long-term documents
---
# CIAgent Ideate
Run the CIAgent ideation engine to discover improvement opportunities based on git-native signals, codebase analysis, and cross-project patterns.
**Usage:** `ciagent ideate [options]`
## Step 0: Confirm Active Project
Check `ci listProjects()` or read `.ciagent/config.json` to determine project context.
If `.ciagent/config.json` has `active_projects` array with length > 0:
- Use `--project <slug>` to target a specific project
- Use `--project all` to run ideation across all active projects (deduplicate findings)
- If no `--project` flag, use first project in `active_projects`
If `.ciagent/config.json` has `active_project` string (legacy):
- Use that project as the target
- Backwards-compatible: if both `active_project` and `active_projects` exist, `active_projects` takes precedence
## Step 1: Load Project Context
```bash
git log --max-count=50
git branch -a
```
Read project reference files:
- `.ciagent/PROJECT.md` — Vision, requirements, constraints, key decisions
- `.ciagent/ROADMAP.md` — Phases, milestones, success criteria
- `.ciagent/REQUIREMENTS.md` — REQ-IDs, status, traceability
- `.ciagent/ARCHITECTURE.md` — Component boundaries, data flow
- `.ciagent/config.json` — Ideation configuration, autonomy level
## Step 2: Run Ideation Tiers
Execute tiers in order. Each tier produces `Idea[]` objects. Ideas from all tiers are merged and deduplicated before presentation.
### Tier 1: Mechanical Analysis (Always Available)
No backend required. All signals come from git history, `.ciagent/` files, and filesystem.
#### 2.1 Git-Native Pattern Mining
```bash
git log --all --grep="lessons:" --format="%B" -50
git log --all --grep="decisions:" --format="%B" -50 -- "***confidence***0.*"
git log --all --grep="escalation:" --format="%B" -50
git log --all --grep="compound:" --format="%B" -50
```
Extract:
- **Repeated lessons** — topics appearing > 1 time → systemic issue
- **Low-confidence decisions** — confidence < 0.7 in `---ci---` blocks → improvement targets
- **Escalation types** — each type identifies a process gap
- **Compound solutions** — suggest generalizing patterns that were solved multiple times
- **Partial requirements** — `requirements: partial: [REQ-XX]` in `---ci---` blocks
#### 2.2 Coverage Gap Analysis
- Parse REQUIREMENTS.md for `pending` and `in_progress` status requirements
- Cross-reference with PLAN.md task completion
- Identify requirements with no corresponding implementation tasks
#### 2.3 Verification Layer Inversion
For each verification layer, identify what's MISSING:
- **Structural**: Files referenced but not created, stubs, TODOs, placeholder implementations
- **Behavioral**: Test suites with < 80% coverage, missing test files for covered requirements
- **Security**: No STRIDE analysis for modified components, missing input validation patterns
- **Quality**: P1/P2 review findings unresolved, consistent style violations
#### 2.4 Architectural Drift Detection
- Parse ARCHITECTURE.md component tree
- Compare against actual `src/` directory structure
- Flag components documented but not implemented
- Flag components implemented but not documented
- Check import graph for unauthorized dependencies between components
#### 2.5 Spec-Driven Improvement
- Analyze REQUIREMENTS.md for ambiguous language ("should" vs "must", undefined terms)
- Check for contradictions between requirements
- Compare against common patterns for the project type (identified from package.json keywords)
- Flag requirements with no verification criteria
### Tier 2: Backend-Enriched Analysis (When LLM Available)
Requires an intelligence backend (opencode, openai, anthropic, or ollama).
#### 2.6 Prioritization and Ranking
- Evaluate all mechanical findings for impact and feasibility
- Rank ideas by: (1) number of signals corroborating, (2) severity of the gap, (3) ease of addressing
#### 2.7 Novel Improvement Suggestions
- Suggest improvements beyond pattern matching (e.g., "consider rate limiting" based on industry best practices, not just a repeated lesson)
- Generate concrete action plans for each accepted idea
- Identify bleeding-edge approaches relevant to the project's tech stack
#### 2.8 Chaos Engineering Ideation
- Generate failure scenarios: "What if the backend is unavailable?", "What if a requirement changes mid-implementation?", "What if test coverage drops below threshold?"
- Map failure scenarios to code that would break
- Suggest resilience improvements for each scenario
### Tier 3: Cross-Project Pattern Transfer (When Multi-Project Registry Exists)
#### 2.9 Cross-Project Mining
For each project in `.ciagent/config.json` projects array:
- Read that project's `---ci---` blocks for lessons, decisions, compound solutions
- Find patterns relevant to the current project (same requirement area, same tech stack from package.json)
- Suggest adaptations of lessons learned elsewhere
- Calculate relevance score based on tech stack similarity
## Step 3: Merge and DeduplicateIdeas
Combine ideas from all tiers. Deduplicate by:
- Same `title` strings → keep highest confidence version
- Same `relatedReq` → merge into single idea with combined sources
- Same `category` + overlapping domains → keep most specific
Sort by confidence (descending), then by number of corroborating signals.
## Step 4: Interactive Validation
Present ideas one-at-a-time to the user:
```
═══ Recommendation N of M ═══
Category: [CATEGORY] | Confidence: [0.XX] | Tier: [mechanical/backend-enriched/cross-project]
Title: [idea title]
Rationale: [idea rationale]
Related Req: [REQ-ID or "new requirement"]
Source: [source signal type]
Actions:
1. Accept (add to next milestone as new requirement)
2. Skip
3. Modify (edit title/rationale before accepting)
4. Details (show full analysis including signal sources)
```
For each accepted idea:
1. Generate `IDEATE-NN` requirement ID
2. Prompt for milestone placement (append to existing or create new)
3. Add to REQUIREMENTS.md with status `pending`
4. Add to ROADMAP.md next milestone
## Step 5: Update Long-Term Documents
For each accepted idea:
### REQUIREMENTS.md
Add a new row in the appropriate milestone section:
```
| IDEATE-NN | [idea title] | [priority] | [phase] | pending |
```
### ROADMAP.md
Add the idea to the next milestone's phase structure:
- If next milestone has a matching phase category, append to that phase
- If no matching phase, suggest a new phase
### ARCHITECTURE.md
If the idea involves architectural changes, note the component change needed.
### PROJECT.md
If the idea adds new requirements or key decisions, update accordingly.
Commit all document updates:
```
decision(P##): ideation results — [N] accepted, [M] skipped
```
## Step 6: Ask-After-Validation Kickoff
After all ideas have been validated:
```
Accepted: [N] recommendations
Skipped: [M] recommendations
Would you like to kick off the run workflow for these ideas? (y/n)
```
If yes: Start `ciagent run` with the updated project context. The `--ideate` flag is NOT needed because the ideas are already in ROADMAP.md and REQUIREMENTS.md — the standard pipeline will pick them up.
If no: Output summary and exit.
## Command Flags
| Flag | Description |
|------|-------------|
| `--category <cats>` | Focus on specific categories: security,quality,architecture,coverage,improvement,spec,chaos (comma-separated) |
| `--affected` | Cascade impact analysis: given current changes, what else needs updating |
| `--spec` | Analyze specification completeness and ambiguity |
| `--external` | Include external signals: npm audit, OSV advisories, dependency staleness |
| `--cross-project` | Mine patterns from all projects in multi-project registry |
| `--output <format>` | Output format: interactive (default), json, markdown |
| `--project <slugs>` | Target project(s): slug, comma-separated, or `all` |
| `--backend <provider>` | Override intelligence backend for enrichment tier |
## Pipeline Integration
When `ciagent run --ideate` is used, the IDEATE stage is inserted between RESEARCH and PLAN:
```
SPECIFY → CLARIFY → RESEARCH → IDEATE → PLAN → EXECUTE → TEST → VERIFY → COMPLETE
```
IDEATE stage commit:
```
---ci---
phase: [phase-number]
milestone: [milestone-version]
status: ideate
decisions:
- id: D-XXX
decision: "Accepted [N] ideation recommendations"
rationale: "[summary of accepted ideas]"
confidence: [avg confidence]
requirements:
covered: [IDEATE-NN, ...]
---/ci---
```
## Output Modes
### Interactive (default)
Presented one-at-a-time with accept/skip/modify actions.
### JSON
```json
{
"project": "[slug]",
"milestone": "[version]",
"ideas": [
{
"id": "IDEATE-NN",
"source": "[source type]",
"category": "[category]",
"title": "[title]",
"rationale": "[rationale]",
"confidence": 0.XX,
"relatedReq": "[REQ-ID or null]",
"actions": ["[action types]"],
"tier": "[mechanical/backend-enriched/cross-project]",
"accepted": true
}
],
"summary": {
"total": 8,
"accepted": 6,
"skipped": 2,
"by_category": { "coverage": 2, "architecture": 1, "security": 1, "quality": 1, "improvement": 1 }
}
}
```
### Markdown
Formatted report suitable for PR descriptions or documentation.
## Error Recovery
On tier failure:
1. Mechanical tier always succeeds (git + filesystem only)
2. Backend-enriched tier: if backend unavailable, fall back to mechanical-only output
3. Cross-project tier: if no other projects in registry, skip silently
On validation failure (no ideas generated):
- Output "No improvement ideas identified for this project."
- Suggest `ciagent ideate --spec` for specification analysis or `--external` for external signals
+19 -3
View File
@@ -14,11 +14,18 @@ If no phase number specified, continues from the current phase (detected from gi
Check `ci listProjects()` or read `.ciagent/config.json` to determine if multi-project mode is active.
If `.ciagent/config.json` has `projects[]` with length > 0:
- Confirm `active_project` is correct for this run
- If not, set it with `ci setActiveProject(<slug>)`
If `.ciagent/config.json` has `projects[]` with length > 0, or `active_projects` array exists:
- Confirm `active_projects` is correct for this run
- If `--project all` is specified: iterate over all projects in `active_projects`
- If `--project <slug>` is specified: run for that project only
- If no `--project` flag: use first project in `active_projects`
- All commit messages must include `project: <slug>` in `---ci---` block
For multi-project execution (`--project all`):
- Execute pipeline for each project sequentially by default
- When `parallelization.enabled=true`: execute projects concurrently up to `max_concurrent_agents`
- Each project has independent phase branches and milestone tracking
If single-project mode: proceed with existing conventions.
## Step 1: Load Git Context
@@ -60,6 +67,15 @@ For each stage in order (starting from current or from `specify`):
- Update `.ciagent/` static files with conclusions
- Commit: `docs(P##): research findings`
### IDEATE (when --ideate flag is passed)
- Delegate to ci-ideation-agent
- Mine git history for patterns, analyze coverage gaps, detect drift
- If backend available: enrich with LLM suggestions
- If --cross-project: mine patterns from other projects
- Present recommendations interactively (accept/skip/modify)
- Accepted ideas update ROADMAP.md and REQUIREMENTS.md
- Commit: `decision(P##): ideation results — [N] accepted, [M] skipped`
### PLAN
- Delegate to ci-planner
- Create vertical-slice plans with wave ordering
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@continuous-intelligence/ciagent",
"version": "0.7.0",
"version": "0.9.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@continuous-intelligence/ciagent",
"version": "0.7.0",
"version": "0.9.0",
"license": "MIT",
"dependencies": {
"commander": "^12.1.0",
+16 -66
View File
@@ -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 });
}
});
});
+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();
}
}
+255 -5
View File
@@ -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";
@@ -19,6 +20,7 @@ import { CIAgentFiles } from "../core/ciagent-files.js";
import { GiteaClient, generateReleaseNotes } from "../core/gitea.js";
import * as fs from "node:fs";
import * as path from "node:path";
import * as readline from "node:readline";
import { execSync } from "node:child_process";
export function createInitCommand(): Command {
@@ -412,7 +414,8 @@ export function createReviewCommand(): Command {
export function createStatusCommand(): Command {
return new Command("status")
.description("Non-interactive project status")
.action(() => {
.option("--project <slug>", "Show status for specific project (comma-separated or 'all')")
.action((options) => {
const projectPath = process.cwd();
if (!isCIAgentInitialized(projectPath)) {
@@ -422,14 +425,31 @@ export function createStatusCommand(): Command {
}
const config = loadConfig(projectPath);
const ciFiles = new CIAgentFiles(projectPath);
const artifacts = new ArtifactManager(projectPath);
console.log("─── CIAgent Project Status ───");
console.log(`\nAutonomy: ${config.autonomy.level}`);
const activeProjects: string[] = (config as any).active_projects?.length > 0
? (config as any).active_projects
: config.active_project ? [config.active_project] : [];
console.log("─── CIAgent Project Status ───\n");
if (activeProjects.length > 1 || (options.project && options.project === "all")) {
console.log(`Active Projects: ${activeProjects.join(", ")}`);
console.log(`Total: ${activeProjects.length} projects`);
console.log("");
}
console.log(`Autonomy: ${config.autonomy.level}`);
console.log(`Model Profile: ${config.model_profile}`);
console.log(`Backend: ${config.backend?.provider || "auto"}`);
console.log(`Parallelization: ${config.parallelization.enabled ? "enabled" : "disabled"}`);
const ideationConfig = (config as any).ideation;
if (ideationConfig) {
console.log(`Ideation: ${ideationConfig.enabled ? "enabled" : "disabled"} (categories: ${ideationConfig.categories?.join(", ") || "default"})`);
}
const state = artifacts.readState();
if (state) {
console.log(`\nCurrent Phase: ${state.current_phase}`);
@@ -660,6 +680,9 @@ export function createProjectsCommand(): Command {
const ciFiles = new CIAgentFiles(projectPath);
const projects = ciFiles.listProjects();
const activeProject = config.active_project || ciFiles.getActiveProject();
const activeProjects: string[] = (config as any).active_projects?.length > 0
? (config as any).active_projects
: activeProject ? [activeProject] : [];
if (projects.length === 0) {
console.log("No projects registered.");
@@ -669,11 +692,13 @@ export function createProjectsCommand(): Command {
console.log("─── CIAgent Projects ───\n");
for (const project of projects) {
const isActive = project.slug === activeProject;
const isActive = activeProjects.includes(project.slug);
const marker = isActive ? " *" : "";
console.log(` ${project.slug}${project.name}${marker}`);
}
console.log("\n * = active project");
if (activeProjects.length > 0) {
console.log(`\n Active: ${activeProjects.join(", ")}`);
}
});
cmd.command("add <slug> <name>")
@@ -712,6 +737,7 @@ export function createProjectsCommand(): Command {
ciFiles.setActiveProject(slug);
const config = loadConfig(projectPath);
config.active_project = slug;
(config as any).active_projects = [slug];
saveConfig(projectPath, config);
console.log(`✓ Active project set to: ${slug}`);
});
@@ -941,4 +967,228 @@ 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 <categories>", "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 <format>", "Output format: interactive, json, markdown", "interactive")
.option("--project <slug>", "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<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);
if (options.output === "json") {
const result = engine.formatIdeasJson(allIdeas);
result.summary.accepted = 0;
result.summary.skipped = allIdeas.length;
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;
}
if (options.output !== "interactive") {
console.log("Use --output interactive for accept/skip/modify validation.");
return;
}
const accepted: Idea[] = [];
const skipped: Idea[] = [];
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const askQuestion = (question: string): Promise<string> => {
return new Promise((resolve) => {
rl.question(question, (answer: string) => {
resolve(answer.trim().toLowerCase());
});
});
};
for (let i = 0; i < allIdeas.length; i++) {
const idea = allIdeas[i];
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(` 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("");
console.log(" 1) Accept (add to next milestone)");
console.log(" 2) Skip");
console.log(" 3) Details (show full analysis)");
const answer = await askQuestion(" > ");
if (answer === "1" || answer === "a" || answer === "accept") {
accepted.push(idea);
console.log(` ✓ Accepted: ${idea.id}${idea.title}`);
} else if (answer === "3" || answer === "d" || answer === "details") {
console.log(`\n ─── Details for ${idea.id} ───`);
console.log(` ID: ${idea.id}`);
console.log(` Source: ${idea.source}`);
console.log(` Category: ${idea.category}`);
console.log(` Confidence: ${idea.confidence.toFixed(2)}`);
console.log(` Tier: ${idea.tier}`);
console.log(` Title: ${idea.title}`);
console.log(` Rationale: ${idea.rationale}`);
if (idea.relatedReq) console.log(` Related Req: ${idea.relatedReq}`);
console.log(` Actions: ${idea.actions.join(", ")}`);
console.log("");
const retryAnswer = await askQuestion(" Accept this idea? (y/n) > ");
if (retryAnswer === "y" || retryAnswer === "yes") {
accepted.push(idea);
console.log(` ✓ Accepted: ${idea.id}${idea.title}`);
} else {
skipped.push(idea);
console.log(` ✗ Skipped: ${idea.id}`);
}
} else {
skipped.push(idea);
console.log(` ✗ Skipped: ${idea.id}`);
}
}
rl.close();
console.log("\n─── Summary ───\n");
console.log(`Accepted: ${accepted.length} recommendation${accepted.length === 1 ? "" : "s"}`);
console.log(`Skipped: ${skipped.length} recommendation${skipped.length === 1 ? "" : "s"}`);
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;
if (savedCount > 0) {
console.log(`\n${savedCount} idea${savedCount === 1 ? "" : "s"} 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) > ");
if (kickoffAnswer === "y" || kickoffAnswer === "yes") {
console.log("\nStarting CIAgent pipeline...");
console.log("Run: ciagent run --ideate\n");
}
}
rl.close();
const byCategory: Record<string, number> = {};
for (const idea of allIdeas) {
byCategory[idea.category] = (byCategory[idea.category] || 0) + 1;
}
console.log("\n─── Category Breakdown ───\n");
for (const [cat, count] of Object.entries(byCategory)) {
console.log(` ${cat}: ${count}`);
}
});
}
+8 -3
View File
@@ -17,6 +17,7 @@ import {
createRollbackCommand,
createShipCommand,
createProjectsCommand,
createIdeateCommand,
} from "./commands.js";
let activeEscalationProtocol: { dispose(): void } | null = null;
@@ -44,12 +45,15 @@ program
.name("ciagent")
.description("CIAgent — Continuous Intelligence: autonomous AI-driven software engineering harness")
.version(VERSION)
.option("--project <slug>", "Specify which project to operate on")
.option("--project <slug>", "Specify which project to operate on (comma-separated or 'all')")
.hook("preAction", () => {
const opts = program.opts();
if (opts.project && isCIAgentInitialized(process.cwd())) {
const ciFiles = new CIAgentFiles(process.cwd());
ciFiles.setProjectSlug(opts.project);
const projectSlug = opts.project;
if (projectSlug !== "all" && !projectSlug.includes(",")) {
ciFiles.setProjectSlug(projectSlug);
}
}
})
.addCommand(createInitCommand())
@@ -63,6 +67,7 @@ program
.addCommand(createClarifyCommand())
.addCommand(createRollbackCommand())
.addCommand(createShipCommand())
.addCommand(createProjectsCommand());
.addCommand(createProjectsCommand())
.addCommand(createIdeateCommand());
program.parse();
+247
View File
@@ -0,0 +1,247 @@
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";
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");
});
describe("acceptIdea", () => {
let acceptDir: string;
beforeEach(() => {
resetIdeaCounter();
acceptDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-accept-test-"));
const ciagentDir = path.join(acceptDir, ".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- **CORE-01**: Test core requirement\n\n## Traceability\n\n| Requirement | Phase | Status |\n|-------------|-------|--------|\n| CORE-01 | Phase 1 | pending |\n"
);
fs.writeFileSync(
path.join(ciagentDir, "ROADMAP.md"),
"# Roadmap\n\n## Overview\n\nTest roadmap.\n\n## Phases\n\n- [x] **Phase 1: Init** - Starting\n\n## Phase Details\n\n### Phase 1: Init\n\n**Goal.**: Start\n**Status**: complete\n**Requirements**: CORE-01\n**Depends on**: Nothing\n**Success Criteria**:\n1. Project initialized\n"
);
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, "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"
);
});
afterEach(() => {
fs.rmSync(acceptDir, { recursive: true, force: true });
});
it("accepts an idea and updates REQUIREMENTS.md and ROADMAP.md", () => {
const engine = new IdeationEngine(acceptDir);
const idea: Idea = {
id: "IDEATE-01",
source: "uncovered_requirement",
category: "coverage",
title: "Add rate limiting to cloud backends",
rationale: "No rate limiting REQ exists for cloud backends.",
confidence: 0.92,
actions: ["add_requirement", "update_roadmap"],
tier: "mechanical",
};
const result = engine.acceptIdea(idea);
expect(result.addedToRequirements).toBe(true);
expect(result.addedToRoadmap).toBe(true);
expect(result.reqId).toBe("IDEATE-01");
});
it("acceptIdeas accepts multiple ideas", () => {
const engine = new IdeationEngine(acceptDir);
const ideas: Idea[] = [
{
id: "IDEATE-01",
source: "uncovered_requirement",
category: "coverage",
title: "Add rate limiting",
rationale: "No rate limiting.",
confidence: 0.9,
actions: ["add_requirement"],
tier: "mechanical",
},
{
id: "IDEATE-02",
source: "architecture_drift",
category: "architecture",
title: "Fix architecture drift",
rationale: "Component documented but missing.",
confidence: 0.8,
actions: ["update_architecture"],
tier: "mechanical",
},
];
const { accepted, results } = engine.acceptIdeas(ideas);
expect(accepted.length).toBe(2);
expect(results.length).toBe(2);
expect(results.every((r) => r.addedToRequirements || r.addedToRoadmap)).toBe(true);
});
});
});
+940
View File
@@ -0,0 +1,940 @@
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<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"],
},
};
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<string>();
for (const t of reqs.traceability) {
if (t.status === "complete") {
coveredReqs.add(t.requirement);
}
}
const allReqIds = new Set<string>();
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<string, number> = {};
const topicDetails: Record<string, string[]> = {};
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<string, number> = {};
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<string, number> = {};
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<string>();
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<string, { severity?: string; title?: string }>)) {
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<string, number> = {};
const byTier: Record<string, number> = {};
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,
},
};
}
acceptIdea(idea: Idea): { reqId: string; addedToRequirements: boolean; addedToRoadmap: boolean } {
const reqId = idea.id;
const reqs = this.ciFiles.readRequirementsMd();
const roadmap = this.ciFiles.readRoadmapMd();
let addedToRequirements = false;
let addedToRoadmap = false;
if (reqs) {
const categoryMap: Record<string, string> = {
security: "Security",
quality: "Quality",
architecture: "Architecture",
coverage: "Coverage",
improvement: "Improvement",
spec: "Specification",
chaos: "Resilience",
};
const categoryName = categoryMap[idea.category] || "Improvement";
let foundCategory = false;
for (const cat of reqs.v1) {
if (cat.category.toLowerCase() === categoryName.toLowerCase()) {
cat.items.push({ id: reqId, description: idea.title });
foundCategory = true;
break;
}
}
if (!foundCategory) {
reqs.v1.push({
category: categoryName,
items: [{ id: reqId, description: idea.title }],
});
}
reqs.traceability.push({
requirement: reqId,
phase: 0,
status: "pending",
});
this.ciFiles.writeRequirementsMd(reqs);
addedToRequirements = true;
}
if (roadmap) {
const lastPhase = roadmap.phases.length > 0
? roadmap.phases[roadmap.phases.length - 1]
: null;
const nextPhaseNumber = lastPhase ? lastPhase.number + 1 : 1;
const phaseName = idea.category === "security" ? "security-hardening"
: idea.category === "architecture" ? "architecture-fix"
: idea.category === "coverage" ? "coverage-expansion"
: idea.category === "quality" ? "quality-improvement"
: idea.category === "spec" ? "spec-refinement"
: idea.category === "chaos" ? "resilience-hardening"
: "improvement";
roadmap.phases.push({
number: nextPhaseNumber,
name: phaseName,
description: idea.title,
status: "not_started",
dependsOn: lastPhase ? [lastPhase.number] : [],
requirements: [reqId],
successCriteria: [idea.rationale],
});
this.ciFiles.writeRoadmapMd(roadmap);
addedToRoadmap = true;
}
return { reqId, addedToRequirements, addedToRoadmap };
}
acceptIdeas(ideas: Idea[]): { accepted: Idea[]; results: Array<{ reqId: string; addedToRequirements: boolean; addedToRoadmap: boolean }> } {
const accepted: Idea[] = [];
const results: Array<{ reqId: string; addedToRequirements: boolean; addedToRoadmap: boolean }> = [];
for (const idea of ideas) {
const result = this.acceptIdea(idea);
if (result.addedToRequirements || result.addedToRoadmap) {
accepted.push(idea);
results.push(result);
}
}
return { accepted, results };
}
}
+23
View File
@@ -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"],
},
},
};
+105
View File
@@ -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<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"],
},
};
+1 -1
View File
@@ -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");
+5 -2
View File
@@ -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);
+4
View File
@@ -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,