From b7d02ee4a4cfc6e11e31cebcbcb9011c4b5c5a57 Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Sat, 30 May 2026 20:26:36 +0000 Subject: [PATCH] =?UTF-8?q?feat(P01):=20interactive=20validation=20+=20doc?= =?UTF-8?q?=20updates=20+=20multi-project=20CLI=20=E2=80=94=20IDEATE-12,13?= =?UTF-8?q?,14=20+=20MULTI-02,06?= 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-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 --- package-lock.json | 4 +- src/cli/commands.ts | 138 +++++++++++++++++++++++++++++++++----- src/cli/index.ts | 7 +- src/core/ideation.test.ts | 88 +++++++++++++++++++++++- src/core/ideation.ts | 91 +++++++++++++++++++++++++ 5 files changed, 308 insertions(+), 20 deletions(-) diff --git a/package-lock.json b/package-lock.json index 87093c7..188e712 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 7ff25ab..3bf8d8b 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -20,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 { @@ -413,7 +414,8 @@ export function createReviewCommand(): Command { export function createStatusCommand(): Command { return new Command("status") .description("Non-interactive project status") - .action(() => { + .option("--project ", "Show status for specific project (comma-separated or 'all')") + .action((options) => { const projectPath = process.cwd(); if (!isCIAgentInitialized(projectPath)) { @@ -423,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}`); @@ -661,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."); @@ -670,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 ") @@ -713,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}`); }); @@ -1028,6 +1053,8 @@ export function createIdeateCommand(): Command { 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; } @@ -1060,25 +1087,106 @@ export function createIdeateCommand(): Command { 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 => { + 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(`═══ 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(`\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 = {}; for (const idea of allIdeas) { byCategory[idea.category] = (byCategory[idea.category] || 0) + 1; } - - console.log("─── Summary ───\n"); - console.log(`Total ideas: ${allIdeas.length}`); + console.log("\n─── Category Breakdown ───\n"); for (const [cat, count] of Object.entries(byCategory)) { console.log(` ${cat}: ${count}`); } diff --git a/src/cli/index.ts b/src/cli/index.ts index e15c095..54f141c 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -45,12 +45,15 @@ program .name("ciagent") .description("CIAgent — Continuous Intelligence: autonomous AI-driven software engineering harness") .version(VERSION) - .option("--project ", "Specify which project to operate on") + .option("--project ", "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()) diff --git a/src/core/ideation.test.ts b/src/core/ideation.test.ts index e313ce3..d04ff42 100644 --- a/src/core/ideation.test.ts +++ b/src/core/ideation.test.ts @@ -2,7 +2,7 @@ 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"; +import { IdeationEngine, resetIdeaCounter, Idea } from "../core/ideation.js"; describe("IdeationAgent", () => { let tempDir: string; @@ -158,4 +158,90 @@ describe("IdeationEngine", () => { 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); + }); + }); }); \ No newline at end of file diff --git a/src/core/ideation.ts b/src/core/ideation.ts index 22adf1c..1124ef7 100644 --- a/src/core/ideation.ts +++ b/src/core/ideation.ts @@ -846,4 +846,95 @@ export class IdeationEngine { }, }; } + + 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 = { + 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 }; + } } \ No newline at end of file