Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e8c6c5c917 | |||
| 4de1f65c10 | |||
| 6902c37ced | |||
| bbabd2dc0a |
+13
-1
@@ -19,7 +19,7 @@
|
||||
"dev": "ts-node src/cli.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "jest",
|
||||
"prepublishOnly": "npm run build",
|
||||
"prepublishOnly": "npm run build && npm test",
|
||||
"install-opencode": "node scripts/postinstall.js"
|
||||
},
|
||||
"keywords": ["ciagent", "autonomous", "ai", "software-engineering", "agent", "multi-project"],
|
||||
@@ -27,6 +27,18 @@
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.cloudinit.dev/continuous-intelligence/ciagent.git"
|
||||
},
|
||||
"homepage": "https://git.cloudinit.dev/continuous-intelligence/ciagent",
|
||||
"bugs": {
|
||||
"url": "https://git.cloudinit.dev/continuous-intelligence/ciagent/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"commander": "^12.1.0",
|
||||
"zod": "^3.23.0"
|
||||
|
||||
+163
-7
@@ -1,4 +1,21 @@
|
||||
import { BaseAgent, AgentContext, AgentResult } from "./base.js";
|
||||
import { execSync } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
export interface ExecutorResult {
|
||||
success: boolean;
|
||||
tasksExecuted: number;
|
||||
tasksCommitted: number;
|
||||
testsPassing: boolean;
|
||||
mustHavesChecked: { name: string; passed: boolean }[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface MustHaveItem {
|
||||
name: string;
|
||||
passed: boolean;
|
||||
}
|
||||
|
||||
export class ExecutorAgent extends BaseAgent {
|
||||
readonly name = "executor";
|
||||
@@ -8,21 +25,160 @@ export class ExecutorAgent extends BaseAgent {
|
||||
async execute(context: AgentContext): Promise<AgentResult> {
|
||||
const start = Date.now();
|
||||
this.log("Executing tasks...");
|
||||
|
||||
if (context.backend) {
|
||||
const result = await this.executeViaBackend(
|
||||
context,
|
||||
`Execute implementation for stage ${context.stage}, phase ${context.phase}. Specification: ${context.specification}`
|
||||
);
|
||||
return { ...result, duration_ms: Date.now() - start };
|
||||
const taskPrompt = await this.buildBackendTaskPrompt(context);
|
||||
const backendResult = await this.executeViaBackend(context, taskPrompt);
|
||||
|
||||
const verification = await this.verifyExecution(context);
|
||||
|
||||
return {
|
||||
...backendResult,
|
||||
output: `${backendResult.output}\nVerification: tests=${verification.testsPassing ? "passing" : "failing"}, must-haves checked=${verification.mustHavesChecked.length}`,
|
||||
duration_ms: Date.now() - start,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
output: "Execution requires an intelligence backend. Configure one with: ci init --backend",
|
||||
output: "Executor requires intelligence backend for code implementation",
|
||||
artifacts_created: [],
|
||||
decisions: 0,
|
||||
escalations: 0,
|
||||
duration_ms: Date.now() - start,
|
||||
error: "No intelligence backend available",
|
||||
error: "Executor requires intelligence backend for code implementation",
|
||||
};
|
||||
}
|
||||
|
||||
private async buildBackendTaskPrompt(context: AgentContext): Promise<string> {
|
||||
const parts: string[] = [
|
||||
`Execute implementation for stage ${context.stage}, phase ${context.phase}.`,
|
||||
"",
|
||||
"## Specification",
|
||||
context.specification || "No specification provided",
|
||||
];
|
||||
|
||||
const planContent = this.readPlanFile(context);
|
||||
if (planContent) {
|
||||
parts.push("", "## Plan", planContent);
|
||||
}
|
||||
|
||||
const ciDir = path.join(context.project_path, ".ciagent");
|
||||
const roadmapPath = path.join(ciDir, "ROADMAP.md");
|
||||
const archPath = path.join(ciDir, "ARCHITECTURE.md");
|
||||
|
||||
if (fs.existsSync(roadmapPath)) {
|
||||
try {
|
||||
const roadmap = fs.readFileSync(roadmapPath, "utf-8");
|
||||
parts.push("", "## Roadmap Context", roadmap.slice(0, 2000));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (fs.existsSync(archPath)) {
|
||||
try {
|
||||
const arch = fs.readFileSync(archPath, "utf-8");
|
||||
parts.push("", "## Architecture Boundaries", arch.slice(0, 2000));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
parts.push("", "## Execution Rules");
|
||||
parts.push("- Execute one task at a time");
|
||||
parts.push("- Commit after each task with ---ci--- block");
|
||||
parts.push("- Never pause for checkpoints");
|
||||
parts.push("- Create automated verification for traditionally human tasks");
|
||||
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
private readPlanFile(context: AgentContext): string | null {
|
||||
const planPath = path.join(context.project_path, ".ciagent", "PLAN.md");
|
||||
try {
|
||||
if (fs.existsSync(planPath)) {
|
||||
return fs.readFileSync(planPath, "utf-8");
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async verifyExecution(context: AgentContext): Promise<ExecutorResult> {
|
||||
const mustHavesChecked: MustHaveItem[] = this.checkMustHaves(context);
|
||||
let testsPassing = false;
|
||||
let tasksExecuted = 0;
|
||||
let tasksCommitted = 0;
|
||||
|
||||
try {
|
||||
const logOutput = execSync("git log --max-count=20 --oneline", {
|
||||
cwd: context.project_path,
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim();
|
||||
const commitLines = logOutput.split("\n").filter(Boolean);
|
||||
tasksCommitted = commitLines.filter((l) => /feat|fix|test/.test(l)).length;
|
||||
tasksExecuted = tasksCommitted;
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
execSync("npm test", {
|
||||
cwd: context.project_path,
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
timeout: 120000,
|
||||
});
|
||||
testsPassing = true;
|
||||
} catch {
|
||||
testsPassing = false;
|
||||
}
|
||||
|
||||
return {
|
||||
success: mustHavesChecked.every((m) => m.passed) && testsPassing,
|
||||
tasksExecuted,
|
||||
tasksCommitted,
|
||||
testsPassing,
|
||||
mustHavesChecked,
|
||||
};
|
||||
}
|
||||
|
||||
private checkMustHaves(context: AgentContext): MustHaveItem[] {
|
||||
const planPath = path.join(context.project_path, ".ciagent", "PLAN.md");
|
||||
const results: MustHaveItem[] = [];
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(planPath)) return results;
|
||||
const planContent = fs.readFileSync(planPath, "utf-8");
|
||||
const mustHaveRegex = /-\s*\[x\]\s*(.+)/g;
|
||||
let match;
|
||||
while ((match = mustHaveRegex.exec(planContent)) !== null) {
|
||||
const name = match[1].trim();
|
||||
const passed = this.verifyMustHaveItem(name, context);
|
||||
results.push({ name, passed });
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private verifyMustHaveItem(item: string, context: AgentContext): boolean {
|
||||
const fileMatch = item.match(/(?:exists|created?|present).*?[\s:]+([^\s]+\.(ts|js|json|md))/i);
|
||||
if (fileMatch) {
|
||||
const filePath = path.join(context.project_path, fileMatch[1]);
|
||||
return fs.existsSync(filePath);
|
||||
}
|
||||
|
||||
const testMatch = item.match(/(?:test|tests?)\s+(?:pass|passing)/i);
|
||||
if (testMatch) {
|
||||
try {
|
||||
execSync("npm test", {
|
||||
cwd: context.project_path,
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
timeout: 120000,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -1,9 +1,9 @@
|
||||
export { BaseAgent, AgentContext, AgentResult, backendResultToAgentResult } from "./base.js";
|
||||
export { OrchestratorAgent } from "./orchestrator.js";
|
||||
export { PlannerAgent } from "./planner.js";
|
||||
export { ExecutorAgent } from "./executor.js";
|
||||
export { VerifierAgent } from "./verifier.js";
|
||||
export { ResearcherAgent } from "./researcher.js";
|
||||
export { PlannerAgent, PlannerResult } from "./planner.js";
|
||||
export { ExecutorAgent, ExecutorResult } from "./executor.js";
|
||||
export { VerifierAgent, VerifierResult } from "./verifier.js";
|
||||
export { ResearcherAgent, ResearcherResult } from "./researcher.js";
|
||||
export { ChallengerAgent } from "./challenger.js";
|
||||
export { SecurityAuditorAgent } from "./security-auditor.js";
|
||||
export { DebuggerAgent } from "./debugger.js";
|
||||
@@ -17,7 +17,7 @@ export { ProjectResearcherAgent } from "./project-researcher.js";
|
||||
export { ResearchSynthesizerAgent } from "./research-synthesizer.js";
|
||||
export { SolutionWriterAgent } from "./solution-writer.js";
|
||||
export { PhaseResearcherAgent } from "./phase-researcher.js";
|
||||
export { TesterAgent } from "./tester.js";
|
||||
export { TesterAgent, TesterResult } from "./tester.js";
|
||||
|
||||
import { AgentName } from "../types/config.js";
|
||||
import { BaseAgent as BaseAgentType } from "./base.js";
|
||||
|
||||
+96
-18
@@ -44,12 +44,13 @@ export class OrchestratorAgent extends BaseAgent {
|
||||
private phaseResults: PhaseResult[] = [];
|
||||
private totalPhases: number = 1;
|
||||
|
||||
private static readonly STAGE_AGENT_MAP: Partial<Record<PipelineStage, AgentName>> = {
|
||||
research: "researcher",
|
||||
plan: "planner",
|
||||
execute: "executor",
|
||||
test: "tester",
|
||||
verify: "verifier",
|
||||
private static readonly STAGE_AGENT_MAP: Partial<Record<PipelineStage, AgentName[]>> = {
|
||||
research: ["researcher"],
|
||||
plan: ["planner"],
|
||||
execute: ["executor", "code-reviewer", "security-auditor"],
|
||||
test: ["tester"],
|
||||
verify: ["verifier"],
|
||||
complete: ["doc-writer"],
|
||||
};
|
||||
|
||||
constructor(config?: CIAgentConfig) {
|
||||
@@ -331,29 +332,82 @@ export class OrchestratorAgent extends BaseAgent {
|
||||
context: AgentContext
|
||||
): Promise<PhaseResult> {
|
||||
const stageStart = Date.now();
|
||||
const agentName = OrchestratorAgent.STAGE_AGENT_MAP[stage];
|
||||
const agentNames = OrchestratorAgent.STAGE_AGENT_MAP[stage];
|
||||
|
||||
if (agentName && context.backend) {
|
||||
this.log(`Delegating ${stage} to ${agentName} agent via backend...`);
|
||||
if (agentNames && agentNames.length > 0 && context.backend) {
|
||||
this.log(`Delegating ${stage} to ${agentNames.join(", ")} agent(s) via backend...`);
|
||||
try {
|
||||
const agent = getAgent(agentName);
|
||||
const gitContext = this.buildGitAgentContext(context);
|
||||
const result = await agent.execute(gitContext);
|
||||
let primaryResult: AgentResult | null = null;
|
||||
const allArtifacts: string[] = [];
|
||||
let totalDecisions = 0;
|
||||
let totalEscalations = 0;
|
||||
let lastError: string | undefined;
|
||||
|
||||
for (let i = 0; i < agentNames.length; i++) {
|
||||
const agentName = agentNames[i];
|
||||
const agent = getAgent(agentName);
|
||||
const gitContext = this.buildGitAgentContext(context);
|
||||
|
||||
if (i === 0) {
|
||||
const result = await agent.execute(gitContext);
|
||||
primaryResult = result;
|
||||
if (Array.isArray(result.artifacts_created)) {
|
||||
allArtifacts.push(...result.artifacts_created);
|
||||
}
|
||||
totalDecisions += result.decisions;
|
||||
totalEscalations += result.escalations;
|
||||
|
||||
if (!result.success) {
|
||||
this.warn(`Primary agent ${agentName} failed for ${stage}`);
|
||||
return {
|
||||
phase: this.pipelineState!.current_phase,
|
||||
stage,
|
||||
success: false,
|
||||
artifacts_created: allArtifacts,
|
||||
decisions_made: totalDecisions,
|
||||
escalations_raised: totalEscalations,
|
||||
duration_ms: Date.now() - stageStart,
|
||||
error: result.error || `Primary agent ${agentName} failed`,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const reviewContext: AgentContext = {
|
||||
...gitContext,
|
||||
specification: `${context.specification}\n\nPrimary agent (${agentNames[0]}) completed. Review context:\n- Success: ${primaryResult!.success}\n- Output: ${primaryResult!.output}\n- Artifacts: ${Array.isArray(primaryResult!.artifacts_created) ? primaryResult!.artifacts_created.join(", ") : String(primaryResult!.artifacts_created)}`,
|
||||
};
|
||||
const result = await agent.execute(reviewContext);
|
||||
if (Array.isArray(result.artifacts_created)) {
|
||||
allArtifacts.push(...result.artifacts_created);
|
||||
}
|
||||
totalDecisions += result.decisions;
|
||||
totalEscalations += result.escalations;
|
||||
|
||||
if (!result.success) {
|
||||
this.warn(`Review agent ${agentName} reported issues for ${stage}: ${result.error || "unspecified"}`);
|
||||
lastError = result.error;
|
||||
}
|
||||
} catch (err) {
|
||||
this.warn(`Review agent ${agentName} failed for ${stage}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
phase: this.pipelineState!.current_phase,
|
||||
stage,
|
||||
success: result.success,
|
||||
artifacts_created: Array.isArray(result.artifacts_created) ? result.artifacts_created : [],
|
||||
decisions_made: result.decisions,
|
||||
escalations_raised: result.escalations,
|
||||
success: primaryResult?.success ?? false,
|
||||
artifacts_created: allArtifacts,
|
||||
decisions_made: totalDecisions,
|
||||
escalations_raised: totalEscalations,
|
||||
duration_ms: Date.now() - stageStart,
|
||||
error: result.error,
|
||||
error: lastError,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof BackendUnavailableError) {
|
||||
this.warn(`Backend unavailable for ${stage}, falling back to mechanical execution`);
|
||||
} else {
|
||||
this.warn(`Agent ${agentName} failed for ${stage}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
this.warn(`Agents failed for ${stage}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -609,6 +663,30 @@ export class OrchestratorAgent extends BaseAgent {
|
||||
}
|
||||
}
|
||||
|
||||
const versionTag = `${this.currentMilestone}-P${String(this.pipelineState!.current_phase).padStart(2, "0")}`;
|
||||
try {
|
||||
execSync(`git tag "${versionTag}"`, {
|
||||
cwd: context.project_path,
|
||||
stdio: "pipe",
|
||||
});
|
||||
this.log(`Created version tag: ${versionTag}`);
|
||||
artifactsCreated.push(`tag:${versionTag}`);
|
||||
} catch (err) {
|
||||
this.warn(`Version tag creation failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
if (this.config.git.auto_push && this.gitContext!.isGitRepo()) {
|
||||
try {
|
||||
execSync(`git push origin ${versionTag}`, {
|
||||
cwd: context.project_path,
|
||||
stdio: "pipe",
|
||||
});
|
||||
this.log(`Pushed version tag: ${versionTag}`);
|
||||
} catch (err) {
|
||||
this.warn(`Version tag push failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
+323
-9
@@ -1,4 +1,27 @@
|
||||
import { BaseAgent, AgentContext, AgentResult } from "./base.js";
|
||||
import { CIAgentFiles, RequirementsMd, RoadmapMd, ArchitectureMd } from "../core/ciagent-files.js";
|
||||
import { GitContext } from "../core/git-context.js";
|
||||
import { CommitBuilder } from "../core/commit-builder.js";
|
||||
import { writeFile, readFile, ensureDir } from "../utils/file.js";
|
||||
import { execSync } from "node:child_process";
|
||||
import * as path from "node:path";
|
||||
|
||||
export interface PlannerResult {
|
||||
success: boolean;
|
||||
planCount: number;
|
||||
waves: { wave: number; plans: string[] }[];
|
||||
decisions: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface PlanEntry {
|
||||
name: string;
|
||||
wave: number;
|
||||
requirements: string[];
|
||||
dependsOn: string[];
|
||||
tasks: string[];
|
||||
mustHaves: string[];
|
||||
}
|
||||
|
||||
export class PlannerAgent extends BaseAgent {
|
||||
readonly name = "planner";
|
||||
@@ -8,21 +31,312 @@ export class PlannerAgent extends BaseAgent {
|
||||
async execute(context: AgentContext): Promise<AgentResult> {
|
||||
const start = Date.now();
|
||||
this.log("Creating phase plan...");
|
||||
|
||||
if (context.backend) {
|
||||
const result = await this.executeViaBackend(
|
||||
context,
|
||||
`Create a phase plan for stage ${context.stage}, phase ${context.phase}. Specification: ${context.specification}`
|
||||
);
|
||||
const taskPrompt = await this.buildBackendTaskPrompt(context);
|
||||
const result = await this.executeViaBackend(context, taskPrompt);
|
||||
return { ...result, duration_ms: Date.now() - start };
|
||||
}
|
||||
|
||||
return this.executeMechanical(context, start);
|
||||
}
|
||||
|
||||
private async buildBackendTaskPrompt(context: AgentContext): Promise<string> {
|
||||
const ciFiles = new CIAgentFiles(context.project_path);
|
||||
const parts: string[] = [
|
||||
`Create a phase plan for stage ${context.stage}, phase ${context.phase}.`,
|
||||
"",
|
||||
"## Project Context",
|
||||
];
|
||||
|
||||
const roadmap = ciFiles.readRoadmapMd();
|
||||
if (roadmap) {
|
||||
const currentPhase = roadmap.phases.find((p) => p.number === context.phase);
|
||||
if (currentPhase) {
|
||||
parts.push("", "### Phase Goal", currentPhase.description);
|
||||
parts.push("", "### Phase Requirements", currentPhase.requirements.join(", ") || "None specified");
|
||||
parts.push("", "### Phase Dependencies", currentPhase.dependsOn.length > 0 ? currentPhase.dependsOn.map((d) => `Phase ${d}`).join(", ") : "None");
|
||||
parts.push("", "### Success Criteria", ...currentPhase.successCriteria.map((sc) => `- ${sc}`));
|
||||
}
|
||||
}
|
||||
|
||||
const requirements = ciFiles.readRequirementsMd();
|
||||
if (requirements) {
|
||||
const phaseReqs = requirements.traceability.filter((t) => t.phase === context.phase);
|
||||
if (phaseReqs.length > 0) {
|
||||
parts.push("", "### Requirements for Phase", ...phaseReqs.map((t) => `- ${t.requirement} (${t.status})`));
|
||||
}
|
||||
}
|
||||
|
||||
const architecture = ciFiles.readArchitectureMd();
|
||||
if (architecture) {
|
||||
parts.push("", "### Architecture Boundaries", ...architecture.components.map((c) => `- ${c.name}: ${c.boundaries}`));
|
||||
parts.push("", "### Build Order", ...architecture.buildOrder.map((bo) => `${bo}`));
|
||||
}
|
||||
|
||||
parts.push("", "## Specification", context.specification || "No specification provided");
|
||||
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
private executeMechanical(context: AgentContext, start: number): AgentResult {
|
||||
const ciFiles = new CIAgentFiles(context.project_path);
|
||||
ciFiles.ensureCIDir();
|
||||
|
||||
const requirements = ciFiles.readRequirementsMd();
|
||||
const roadmap = ciFiles.readRoadmapMd();
|
||||
const architecture = ciFiles.readArchitectureMd();
|
||||
|
||||
if (!requirements && !roadmap) {
|
||||
return {
|
||||
success: false,
|
||||
output: "Planning requires either .ciagent/REQUIREMENTS.md or .ciagent/ROADMAP.md. Initialize the project first.",
|
||||
artifacts_created: [],
|
||||
decisions: 0,
|
||||
escalations: 0,
|
||||
duration_ms: Date.now() - start,
|
||||
error: "No requirements or roadmap found for mechanical planning",
|
||||
};
|
||||
}
|
||||
|
||||
let gitLogSummary = "";
|
||||
try {
|
||||
gitLogSummary = execSync("git log --max-count=20 --oneline", {
|
||||
cwd: context.project_path,
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim();
|
||||
} catch {
|
||||
gitLogSummary = "(no git history available)";
|
||||
}
|
||||
|
||||
const phaseGoal = this.extractPhaseGoal(roadmap, context.phase);
|
||||
const phaseRequirements = this.extractPhaseRequirements(requirements, context.phase);
|
||||
const componentBoundaries = architecture ? architecture.components.map((c) => c.name) : [];
|
||||
|
||||
const plans = this.buildPlans(phaseRequirements, componentBoundaries, context.phase);
|
||||
|
||||
const planFileContent = this.formatPlanFile(context.phase, phaseGoal, plans);
|
||||
|
||||
const planFilePath = path.join(context.project_path, ".ciagent", "PLAN.md");
|
||||
ensureDir(path.dirname(planFilePath));
|
||||
writeFile(planFilePath, planFileContent);
|
||||
|
||||
const decisionCount = plans.length > 0 ? 1 : 0;
|
||||
|
||||
if (this.shouldCommit(context)) {
|
||||
try {
|
||||
const commitMessage = CommitBuilder.buildTaskCommit({
|
||||
type: "docs",
|
||||
phase: context.phase,
|
||||
milestone: "v1.0",
|
||||
plan: "01",
|
||||
task: "01-01",
|
||||
subject: `create ${plans.length} phase plans`,
|
||||
status: "plan",
|
||||
decisions: decisionCount > 0 ? [{
|
||||
id: "D-001",
|
||||
decision: `Decomposed phase ${context.phase} into ${plans.length} vertical-slice plans`,
|
||||
rationale: "Requirements grouped by dependency analysis — independent requirements in wave 1, dependent in wave 2+",
|
||||
confidence: 0.75,
|
||||
alternatives: ["single monolithic plan", "per-requirement plans"],
|
||||
}] : undefined,
|
||||
});
|
||||
execSync(`git add -A && git commit -m "${commitMessage.replace(/"/g, '\\"')}" --allow-empty`, {
|
||||
cwd: context.project_path,
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch {
|
||||
this.warn("Plan commit failed");
|
||||
}
|
||||
}
|
||||
|
||||
const waves = this.groupPlansByWave(plans);
|
||||
const plannerResult: PlannerResult = {
|
||||
success: true,
|
||||
planCount: plans.length,
|
||||
waves,
|
||||
decisions: decisionCount,
|
||||
};
|
||||
|
||||
return {
|
||||
success: false,
|
||||
output: "Planning requires an intelligence backend. Configure one with: ci init --backend",
|
||||
artifacts_created: [],
|
||||
decisions: 0,
|
||||
success: true,
|
||||
output: `Created ${plans.length} plan(s) across ${waves.length} wave(s) for phase ${context.phase}`,
|
||||
artifacts_created: [".ciagent/PLAN.md"],
|
||||
decisions: decisionCount,
|
||||
escalations: 0,
|
||||
duration_ms: Date.now() - start,
|
||||
error: "No intelligence backend available",
|
||||
};
|
||||
}
|
||||
|
||||
private extractPhaseGoal(roadmap: RoadmapMd | null, phase: number): string {
|
||||
if (!roadmap) return "No roadmap available";
|
||||
const phaseEntry = roadmap.phases.find((p) => p.number === phase);
|
||||
if (phaseEntry) return `${phaseEntry.name}: ${phaseEntry.description}`;
|
||||
return `Phase ${phase} (no roadmap entry)`;
|
||||
}
|
||||
|
||||
private extractPhaseRequirements(requirements: RequirementsMd | null, phase: number): Array<{ id: string; description: string; phase: number; status: string }> {
|
||||
if (!requirements) return [];
|
||||
return requirements.traceability
|
||||
.filter((t) => t.phase === phase)
|
||||
.map((t) => {
|
||||
let description = t.requirement;
|
||||
for (const cat of [...requirements.v1, ...requirements.v2]) {
|
||||
const item = cat.items.find((i) => i.id === t.requirement);
|
||||
if (item) {
|
||||
description = `${t.requirement}: ${item.description}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { id: t.requirement, description, phase: t.phase, status: t.status };
|
||||
});
|
||||
}
|
||||
|
||||
private buildPlans(
|
||||
phaseRequirements: Array<{ id: string; description: string; phase: number; status: string }>,
|
||||
componentBoundaries: string[],
|
||||
phase: number
|
||||
): PlanEntry[] {
|
||||
if (phaseRequirements.length === 0) {
|
||||
return [{
|
||||
name: `Phase ${phase} Core Implementation`,
|
||||
wave: 1,
|
||||
requirements: [],
|
||||
dependsOn: [],
|
||||
tasks: [`Implement phase ${phase} deliverables as specified in ROADMAP.md`],
|
||||
mustHaves: [`Phase ${phase} deliverables exist and pass verification`],
|
||||
}];
|
||||
}
|
||||
|
||||
const independentReqs = phaseRequirements.filter((r) => r.status !== "blocked");
|
||||
const blockedReqs = phaseRequirements.filter((r) => r.status === "blocked");
|
||||
|
||||
const plans: PlanEntry[] = [];
|
||||
|
||||
if (independentReqs.length > 0) {
|
||||
const taskChunks = this.chunkByComponent(independentReqs, componentBoundaries);
|
||||
for (const chunk of taskChunks) {
|
||||
plans.push({
|
||||
name: this.inferPlanName(chunk, phase),
|
||||
wave: 1,
|
||||
requirements: chunk.map((r) => r.id),
|
||||
dependsOn: [],
|
||||
tasks: chunk.map((r) => {
|
||||
const desc = r.description.split(": ").slice(1).join(": ") || r.description;
|
||||
return desc !== r.id ? `Implement ${r.id}: ${desc}` : `Implement ${r.id}`;
|
||||
}),
|
||||
mustHaves: chunk.map((r) => `${r.id} implemented and testable`),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (blockedReqs.length > 0) {
|
||||
const taskChunks = this.chunkByComponent(blockedReqs, componentBoundaries);
|
||||
for (const chunk of taskChunks) {
|
||||
plans.push({
|
||||
name: this.inferPlanName(chunk, phase),
|
||||
wave: plans.length > 0 ? Math.max(...plans.map((p) => p.wave)) + 1 : 2,
|
||||
requirements: chunk.map((r) => r.id),
|
||||
dependsOn: plans.slice(0, plans.length > 0 ? 1 : 0).map((p) => p.name),
|
||||
tasks: chunk.map((r) => {
|
||||
const desc = r.description.split(": ").slice(1).join(": ") || r.description;
|
||||
return desc !== r.id ? `Implement ${r.id}: ${desc}` : `Implement ${r.id}`;
|
||||
}),
|
||||
mustHaves: chunk.map((r) => `${r.id} implemented and testable`),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (plans.length === 0) {
|
||||
plans.push({
|
||||
name: `Phase ${phase} Default`,
|
||||
wave: 1,
|
||||
requirements: [],
|
||||
dependsOn: [],
|
||||
tasks: [`Implement phase ${phase} deliverables`],
|
||||
mustHaves: [`Phase ${phase} deliverables pass verification`],
|
||||
});
|
||||
}
|
||||
|
||||
return plans;
|
||||
}
|
||||
|
||||
private chunkByComponent(
|
||||
reqs: Array<{ id: string; description: string; phase: number; status: string }>,
|
||||
_componentBoundaries: string[]
|
||||
): Array<Array<{ id: string; description: string; phase: number; status: string }>> {
|
||||
if (reqs.length <= 3) return [reqs];
|
||||
const chunks: Array<Array<{ id: string; description: string; phase: number; status: string }>> = [];
|
||||
const chunkSize = Math.ceil(reqs.length / Math.ceil(reqs.length / 3));
|
||||
for (let i = 0; i < reqs.length; i += chunkSize) {
|
||||
chunks.push(reqs.slice(i, i + chunkSize));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
private inferPlanName(chunk: Array<{ id: string; description: string; phase: number; status: string }>, phase: number): string {
|
||||
if (chunk.length === 1) return `Phase ${phase}: ${chunk[0].id}`;
|
||||
return `Phase ${phase}: ${chunk[0].id}–${chunk[chunk.length - 1].id}`;
|
||||
}
|
||||
|
||||
private groupPlansByWave(plans: PlanEntry[]): { wave: number; plans: string[] }[] {
|
||||
const waveMap = new Map<number, string[]>();
|
||||
for (const plan of plans) {
|
||||
const existing = waveMap.get(plan.wave) || [];
|
||||
existing.push(plan.name);
|
||||
waveMap.set(plan.wave, existing);
|
||||
}
|
||||
return Array.from(waveMap.entries())
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([wave, names]) => ({ wave, plans: names }));
|
||||
}
|
||||
|
||||
private formatPlanFile(phase: number, phaseGoal: string, plans: PlanEntry[]): string {
|
||||
const lines: string[] = [
|
||||
`# Phase ${phase} Plan`,
|
||||
"",
|
||||
"## Phase Goal",
|
||||
phaseGoal,
|
||||
"",
|
||||
"## Plans",
|
||||
"",
|
||||
];
|
||||
|
||||
for (let i = 0; i < plans.length; i++) {
|
||||
const plan = plans[i];
|
||||
const planNum = i + 1;
|
||||
lines.push(`### Plan ${planNum}: ${plan.name}`);
|
||||
lines.push(`- Wave: ${plan.wave}`);
|
||||
if (plan.requirements.length > 0) {
|
||||
lines.push(`- Requirements: [${plan.requirements.join(", ")}]`);
|
||||
}
|
||||
if (plan.dependsOn.length > 0) {
|
||||
lines.push(`- Depends on: ${plan.dependsOn.join(", ")}`);
|
||||
}
|
||||
lines.push("- Tasks:");
|
||||
for (const task of plan.tasks) {
|
||||
lines.push(` 1. ${task}`);
|
||||
}
|
||||
lines.push("- Must-haves:");
|
||||
for (const mh of plan.mustHaves) {
|
||||
lines.push(` - [x] ${mh}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
private shouldCommit(context: AgentContext): boolean {
|
||||
try {
|
||||
execSync("git rev-parse --is-inside-work-tree", {
|
||||
cwd: context.project_path,
|
||||
stdio: "pipe",
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
+240
-6
@@ -1,4 +1,20 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { BaseAgent, AgentContext, AgentResult } from "./base.js";
|
||||
import { GitContext } from "../core/git-context.js";
|
||||
import { CIAgentFiles, ArchitectureMd, ProjectMd } from "../core/ciagent-files.js";
|
||||
import { CommitBuilder } from "../core/commit-builder.js";
|
||||
import { CommitDecision } from "../types/commit-meta.js";
|
||||
import { fileExists, readFile } from "../utils/file.js";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
export interface ResearcherResult {
|
||||
success: boolean;
|
||||
findingsCount: number;
|
||||
decisionsLogged: number;
|
||||
filesUpdated: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class ResearcherAgent extends BaseAgent {
|
||||
readonly name = "researcher";
|
||||
@@ -8,21 +24,239 @@ export class ResearcherAgent extends BaseAgent {
|
||||
async execute(context: AgentContext): Promise<AgentResult> {
|
||||
const start = Date.now();
|
||||
this.log("Researching domain...");
|
||||
|
||||
if (context.backend) {
|
||||
const result = await this.executeViaBackend(
|
||||
context,
|
||||
`Research the domain for: ${context.specification}`
|
||||
`Research the domain for phase ${context.phase}. Specification: ${context.specification}. Read git history (last 50 commits), .ciagent/PROJECT.md, .ciagent/ARCHITECTURE.md, .ciagent/REQUIREMENTS.md. Scan src/ directory structure. Generate findings about module boundaries, risks, and approach. Update .ciagent/ARCHITECTURE.md with component boundary conclusions. Update .ciagent/PROJECT.md key decisions if warranted. Commit findings with CommitBuilder.buildResearchCommit().`
|
||||
);
|
||||
return { ...result, duration_ms: Date.now() - start };
|
||||
}
|
||||
|
||||
const result = await this.runMechanicalResearch(context);
|
||||
const output = JSON.stringify(result, null, 2);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
output: "Research requires an intelligence backend. Configure one with: ci init --backend",
|
||||
artifacts_created: [],
|
||||
decisions: 0,
|
||||
success: result.success,
|
||||
output,
|
||||
artifacts_created: result.filesUpdated,
|
||||
decisions: result.decisionsLogged,
|
||||
escalations: 0,
|
||||
duration_ms: Date.now() - start,
|
||||
error: "No intelligence backend available",
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
private async runMechanicalResearch(context: AgentContext): Promise<ResearcherResult> {
|
||||
try {
|
||||
const gitContext = new GitContext(context.project_path);
|
||||
const ciFiles = new CIAgentFiles(context.project_path);
|
||||
|
||||
const findings: string[] = [];
|
||||
const decisions: CommitDecision[] = [];
|
||||
const filesUpdated: string[] = [];
|
||||
|
||||
const commits = gitContext.getRecentCommits(50);
|
||||
if (commits.length > 0) {
|
||||
findings.push(`Analyzed ${commits.length} recent commits for project history`);
|
||||
const researchCommits = commits.filter(c => c.ci?.status === "research");
|
||||
if (researchCommits.length > 0) {
|
||||
findings.push(`Found ${researchCommits.length} prior research commits`);
|
||||
}
|
||||
}
|
||||
|
||||
const projectMd = ciFiles.readProjectMd();
|
||||
if (projectMd) {
|
||||
findings.push(`Project: ${projectMd.name} — core value: ${projectMd.coreValue.slice(0, 80)}`);
|
||||
findings.push(`Active requirements: ${projectMd.requirements.active.length}, validated: ${projectMd.requirements.validated.length}`);
|
||||
} else {
|
||||
findings.push("No PROJECT.md found — project context unavailable");
|
||||
}
|
||||
|
||||
const archMd = ciFiles.readArchitectureMd();
|
||||
if (archMd) {
|
||||
findings.push(`Architecture: ${archMd.components.length} components, ${archMd.buildOrder.length} build steps`);
|
||||
for (const comp of archMd.components) {
|
||||
findings.push(` Component: ${comp.name} — boundaries: ${comp.boundaries.slice(0, 60)}, deps: ${comp.dependsOn.join(", ") || "none"}`);
|
||||
}
|
||||
} else {
|
||||
findings.push("No ARCHITECTURE.md found — architecture analysis unavailable");
|
||||
}
|
||||
|
||||
const reqsMd = ciFiles.readRequirementsMd();
|
||||
if (reqsMd) {
|
||||
const totalReqs = reqsMd.traceability.length;
|
||||
const covered = reqsMd.traceability.filter(t => t.status === "complete").length;
|
||||
const phaseReqs = reqsMd.traceability.filter(t => t.phase === context.phase);
|
||||
findings.push(`Requirements: ${totalReqs} total, ${covered} complete, ${phaseReqs.length} for phase ${context.phase}`);
|
||||
}
|
||||
|
||||
const srcDir = path.join(context.project_path, "src");
|
||||
if (fs.existsSync(srcDir)) {
|
||||
const moduleDirs = fs.readdirSync(srcDir, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory() && d.name !== "node_modules")
|
||||
.map(d => d.name);
|
||||
findings.push(`Source modules: ${moduleDirs.join(", ")}`);
|
||||
|
||||
const updatedArch = this.deriveArchitectureFromSource(srcDir, archMd, moduleDirs);
|
||||
if (updatedArch) {
|
||||
ciFiles.writeArchitectureMd(updatedArch);
|
||||
filesUpdated.push(".ciagent/ARCHITECTURE.md");
|
||||
findings.push("Updated ARCHITECTURE.md with source-derived component boundaries");
|
||||
|
||||
decisions.push({
|
||||
id: `D-P${context.phase}-001`,
|
||||
decision: "Updated component boundaries from source scan",
|
||||
rationale: "Source directory structure reveals actual module boundaries",
|
||||
confidence: 0.75,
|
||||
alternatives: ["manual architecture review", "no update"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (projectMd && archMd) {
|
||||
const updatedProject = this.maybeUpdateKeyDecisions(projectMd, findings);
|
||||
if (updatedProject) {
|
||||
ciFiles.writeProjectMd(updatedProject, "research findings update");
|
||||
filesUpdated.push(".ciagent/PROJECT.md");
|
||||
findings.push("Updated PROJECT.md key decisions from research");
|
||||
|
||||
decisions.push({
|
||||
id: `D-P${context.phase}-002`,
|
||||
decision: "Logged research-based decisions to PROJECT.md",
|
||||
rationale: "Research findings warrant recording as key decisions",
|
||||
confidence: 0.70,
|
||||
alternatives: ["defer decision logging", "log after execution"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.commitFindings(context, findings, decisions);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
findingsCount: findings.length,
|
||||
decisionsLogged: decisions.length,
|
||||
filesUpdated,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
findingsCount: 0,
|
||||
decisionsLogged: 0,
|
||||
filesUpdated: [],
|
||||
error: `Research failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private deriveArchitectureFromSource(srcDir: string, existing: ArchitectureMd | null, moduleDirs: string[]): ArchitectureMd | null {
|
||||
const newComponents = moduleDirs.map(dir => {
|
||||
const dirPath = path.join(srcDir, dir);
|
||||
const fileCount = this.countTsFiles(dirPath);
|
||||
const existingComp = existing?.components.find(c => c.name.toLowerCase() === dir.toLowerCase());
|
||||
|
||||
return {
|
||||
name: existingComp?.name || this.capitalize(dir),
|
||||
description: existingComp?.description || `${dir} module with ${fileCount} source files`,
|
||||
boundaries: existingComp?.boundaries || `src/${dir}/ — ${fileCount} files, internal module`,
|
||||
dependsOn: existingComp?.dependsOn || [],
|
||||
};
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
const existingNames = new Set(existing.components.map(c => c.name.toLowerCase()));
|
||||
const hasNew = newComponents.some(c => !existingNames.has(c.name.toLowerCase()));
|
||||
if (!hasNew) {
|
||||
return {
|
||||
...existing,
|
||||
components: existing.components.map(comp => {
|
||||
const updated = newComponents.find(n => n.name.toLowerCase() === comp.name.toLowerCase());
|
||||
return updated || comp;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const merged = [...existing.components];
|
||||
for (const nc of newComponents) {
|
||||
if (!existingNames.has(nc.name.toLowerCase())) {
|
||||
merged.push(nc);
|
||||
}
|
||||
}
|
||||
return { ...existing, components: merged };
|
||||
}
|
||||
|
||||
return {
|
||||
overview: "Architecture derived from source directory scan",
|
||||
components: newComponents,
|
||||
dataFlow: "Modules communicate via typed interfaces and shared utilities",
|
||||
buildOrder: moduleDirs.map(d => `Build ${d} module`),
|
||||
};
|
||||
}
|
||||
|
||||
private maybeUpdateKeyDecisions(projectMd: ProjectMd, findings: string[]): ProjectMd | null {
|
||||
const researchDecisions = findings
|
||||
.filter(f => f.includes("Updated") || f.includes("Found") || f.includes("derived"))
|
||||
.map(f => ({
|
||||
decision: f.slice(0, 50),
|
||||
rationale: "Derived from mechanical source analysis",
|
||||
outcome: "logged by researcher",
|
||||
}));
|
||||
|
||||
if (researchDecisions.length === 0) return null;
|
||||
|
||||
const existingDecisions = projectMd.keyDecisions || [];
|
||||
const existingDecisionTexts = new Set(existingDecisions.map(d => d.decision));
|
||||
|
||||
const novelDecisions = researchDecisions.filter(d => !existingDecisionTexts.has(d.decision));
|
||||
if (novelDecisions.length === 0) return null;
|
||||
|
||||
return {
|
||||
...projectMd,
|
||||
keyDecisions: [...existingDecisions, ...novelDecisions],
|
||||
};
|
||||
}
|
||||
|
||||
private commitFindings(context: AgentContext, findings: string[], decisions: CommitDecision[]): void {
|
||||
try {
|
||||
const gitContext = new GitContext(context.project_path);
|
||||
const projectState = gitContext.reconstructState();
|
||||
const milestone = projectState.currentMilestone || "v1.0";
|
||||
|
||||
const commitMsg = CommitBuilder.buildResearchCommit(
|
||||
context.phase,
|
||||
milestone,
|
||||
`phase ${context.phase} domain research`,
|
||||
findings,
|
||||
decisions.length > 0 ? decisions : undefined,
|
||||
);
|
||||
|
||||
if (fileExists(path.join(context.project_path, ".git"))) {
|
||||
execSync(`git add -A && git commit -m "${commitMsg.replace(/"/g, '\\"')}" --allow-empty`, {
|
||||
cwd: context.project_path,
|
||||
stdio: "pipe",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
this.warn(`Research commit failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private countTsFiles(dir: string): number {
|
||||
if (!fs.existsSync(dir)) return 0;
|
||||
let count = 0;
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && entry.name !== "node_modules") {
|
||||
count += this.countTsFiles(path.join(dir, entry.name));
|
||||
} else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts") && !entry.name.endsWith(".test.ts")) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private capitalize(s: string): string {
|
||||
return s.split("-").map(p => p.charAt(0).toUpperCase() + p.slice(1)).join("-");
|
||||
}
|
||||
}
|
||||
+159
-6
@@ -1,28 +1,181 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { BaseAgent, AgentContext, AgentResult } from "./base.js";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
export interface TesterResult {
|
||||
success: boolean;
|
||||
integrationTestsFound: number;
|
||||
integrationTestsPassed: number;
|
||||
e2eTestsFound: number;
|
||||
e2eTestsPassed: number;
|
||||
overallPassed: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class TesterAgent extends BaseAgent {
|
||||
readonly name = "tester";
|
||||
readonly description = "Runs automated tests and validates test coverage.";
|
||||
readonly description = "Runs integration, e2e, functional tests. Validates non-unit test coverage.";
|
||||
readonly workflow = "test";
|
||||
|
||||
async execute(context: AgentContext): Promise<AgentResult> {
|
||||
const start = Date.now();
|
||||
this.log("Running automated tests...");
|
||||
|
||||
if (context.backend) {
|
||||
const result = await this.executeViaBackend(
|
||||
context,
|
||||
`Run automated tests for: ${context.specification}`
|
||||
`Run integration, e2e, and functional tests for phase ${context.phase}. Specification: ${context.specification}. Detect *.integration.test.ts, *.e2e.test.ts, *.functional.test.ts files. Run npm test. Parse output for pass/fail counts per category. Report structured TesterResult. Do NOT write any test files — only detect and run existing ones.`
|
||||
);
|
||||
return { ...result, duration_ms: Date.now() - start };
|
||||
}
|
||||
|
||||
const result = await this.runMechanicalTests(context);
|
||||
const output = JSON.stringify(result, null, 2);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
output: "Testing requires an intelligence backend.",
|
||||
success: result.success,
|
||||
output,
|
||||
artifacts_created: [],
|
||||
decisions: 0,
|
||||
escalations: 0,
|
||||
escalations: result.overallPassed ? 0 : 1,
|
||||
duration_ms: Date.now() - start,
|
||||
error: "No intelligence backend available",
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
private async runMechanicalTests(context: AgentContext): Promise<TesterResult> {
|
||||
try {
|
||||
const srcDir = path.join(context.project_path, "src");
|
||||
const integrationFiles = fs.existsSync(srcDir) ? this.findTestFiles(srcDir, /\.integration\.test\.ts$/) : [];
|
||||
const e2eFiles = fs.existsSync(srcDir) ? this.findTestFiles(srcDir, /\.e2e\.test\.ts$/) : [];
|
||||
const functionalFiles = fs.existsSync(srcDir) ? this.findTestFiles(srcDir, /\.functional\.test\.ts$/) : [];
|
||||
|
||||
const integrationTestsFound = integrationFiles.length;
|
||||
const e2eTestsFound = e2eFiles.length + functionalFiles.length;
|
||||
|
||||
let overallPassed = false;
|
||||
let integrationTestsPassed = 0;
|
||||
let e2eTestsPassed = 0;
|
||||
|
||||
try {
|
||||
const testOutput = execSync("npm test 2>&1", {
|
||||
cwd: context.project_path,
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
timeout: 120000,
|
||||
});
|
||||
overallPassed = true;
|
||||
|
||||
const passCounts = this.parseTestOutput(testOutput);
|
||||
integrationTestsPassed = integrationTestsFound > 0 ? integrationTestsFound : 0;
|
||||
e2eTestsPassed = e2eTestsFound > 0 ? e2eTestsFound : 0;
|
||||
|
||||
if (integrationTestsFound > 0) {
|
||||
integrationTestsPassed = this.estimateCategoryPassed(testOutput, "integration");
|
||||
}
|
||||
if (e2eTestsFound > 0) {
|
||||
e2eTestsPassed = this.estimateCategoryPassed(testOutput, "e2e");
|
||||
}
|
||||
} catch (err) {
|
||||
const output = err instanceof Error && "stdout" in err
|
||||
? (err as unknown as { stdout: string }).stdout || ""
|
||||
: "";
|
||||
const stderr = err instanceof Error && "stderr" in err
|
||||
? (err as unknown as { stderr: string }).stderr || ""
|
||||
: "";
|
||||
|
||||
const combined = `${output}\n${stderr}`;
|
||||
overallPassed = false;
|
||||
|
||||
const passCounts = this.parseTestOutput(combined);
|
||||
|
||||
if (integrationTestsFound > 0) {
|
||||
integrationTestsPassed = this.estimateCategoryPassed(combined, "integration");
|
||||
}
|
||||
if (e2eTestsFound > 0) {
|
||||
e2eTestsPassed = this.estimateCategoryPassed(combined, "e2e");
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
integrationTestsFound,
|
||||
integrationTestsPassed,
|
||||
e2eTestsFound,
|
||||
e2eTestsPassed,
|
||||
overallPassed: false,
|
||||
error: `npm test failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: overallPassed,
|
||||
integrationTestsFound,
|
||||
integrationTestsPassed,
|
||||
e2eTestsFound,
|
||||
e2eTestsPassed,
|
||||
overallPassed,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
integrationTestsFound: 0,
|
||||
integrationTestsPassed: 0,
|
||||
e2eTestsFound: 0,
|
||||
e2eTestsPassed: 0,
|
||||
overallPassed: false,
|
||||
error: `Test execution failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private findTestFiles(dir: string, pattern: RegExp): string[] {
|
||||
const files: string[] = [];
|
||||
if (!fs.existsSync(dir)) return files;
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory() && entry.name !== "node_modules") {
|
||||
files.push(...this.findTestFiles(fullPath, pattern));
|
||||
} else if (pattern.test(entry.name)) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
private parseTestOutput(output: string): { total: number; passed: number; failed: number } {
|
||||
const jestSummary = output.match(/Tests:\s+(\d+)\s+passed(?:,\s+(\d+)\s+failed)?/);
|
||||
if (jestSummary) {
|
||||
const passed = parseInt(jestSummary[1], 10) || 0;
|
||||
const failed = parseInt(jestSummary[2], 10) || 0;
|
||||
return { total: passed + failed, passed, failed };
|
||||
}
|
||||
|
||||
const jestAlt = output.match(/(\d+)\s+passing/);
|
||||
const jestAltFail = output.match(/(\d+)\s+failing/);
|
||||
if (jestAlt) {
|
||||
const passed = parseInt(jestAlt[1], 10) || 0;
|
||||
const failed = jestAltFail ? parseInt(jestAltFail[1], 10) || 0 : 0;
|
||||
return { total: passed + failed, passed, failed };
|
||||
}
|
||||
|
||||
return { total: 0, passed: 0, failed: 0 };
|
||||
}
|
||||
|
||||
private estimateCategoryPassed(output: string, category: string): number {
|
||||
const categoryPattern = category === "integration"
|
||||
? /\.integration\.test\.ts/g
|
||||
: /\.e2e\.test\.ts|\.functional\.test\.ts/g;
|
||||
|
||||
const mentions = (output.match(categoryPattern) || []).length;
|
||||
if (mentions > 0) {
|
||||
const failPattern = /FAIL|failed|error/i;
|
||||
const lines = output.split("\n").filter(l => categoryPattern.test(l));
|
||||
const failed = lines.filter(l => failPattern.test(l)).length;
|
||||
return Math.max(mentions - failed, 0);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
+217
-5
@@ -1,4 +1,22 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { BaseAgent, AgentContext, AgentResult } from "./base.js";
|
||||
import { VerificationPipeline } from "../verification/index.js";
|
||||
import { CommitBuilder, VerifyCommitInput } from "../core/commit-builder.js";
|
||||
import { GitContext } from "../core/git-context.js";
|
||||
import { CIAgentFiles } from "../core/ciagent-files.js";
|
||||
import { fileExists } from "../utils/file.js";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
export interface VerifierResult {
|
||||
success: boolean;
|
||||
mustHaveScore: number;
|
||||
requirementsCovered: string[];
|
||||
requirementsPartial: string[];
|
||||
integrationChecks: { import: string; resolved: boolean }[];
|
||||
layers: { name: string; passed: boolean }[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class VerifierAgent extends BaseAgent {
|
||||
readonly name = "verifier";
|
||||
@@ -8,21 +26,215 @@ export class VerifierAgent extends BaseAgent {
|
||||
async execute(context: AgentContext): Promise<AgentResult> {
|
||||
const start = Date.now();
|
||||
this.log("Verifying phase output...");
|
||||
|
||||
if (context.backend) {
|
||||
const result = await this.executeViaBackend(
|
||||
context,
|
||||
`Verify phase ${context.phase} output. Specification: ${context.specification}`
|
||||
`Verify phase ${context.phase} output against must-haves, requirement coverage, and integration links. Specification: ${context.specification}. Check all .ciagent/ reference files. Run the 4-layer verification pipeline (structural, behavioral, security, quality). Verify imports resolve. Report structured VerifierResult.`
|
||||
);
|
||||
return { ...result, duration_ms: Date.now() - start };
|
||||
}
|
||||
|
||||
const result = await this.runMechanicalVerification(context);
|
||||
const output = JSON.stringify(result, null, 2);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
output: "Verification requires an intelligence backend. Configure one with: ci init --backend",
|
||||
success: result.success,
|
||||
output,
|
||||
artifacts_created: [],
|
||||
decisions: 0,
|
||||
escalations: 0,
|
||||
escalations: result.success ? 0 : 1,
|
||||
duration_ms: Date.now() - start,
|
||||
error: "No intelligence backend available",
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
private async runMechanicalVerification(context: AgentContext): Promise<VerifierResult> {
|
||||
try {
|
||||
const pipeline = new VerificationPipeline(context.project_path);
|
||||
const pipelineResult = await pipeline.run(context.phase);
|
||||
|
||||
const layers: { name: string; passed: boolean }[] = [
|
||||
{ name: pipelineResult.structural.name, passed: pipelineResult.structural.passed },
|
||||
{ name: pipelineResult.behavioral.name, passed: pipelineResult.behavioral.passed },
|
||||
{ name: pipelineResult.security.name, passed: pipelineResult.security.passed },
|
||||
{ name: pipelineResult.quality.name, passed: pipelineResult.quality.passed },
|
||||
];
|
||||
|
||||
const gitContext = new GitContext(context.project_path);
|
||||
const ciFiles = new CIAgentFiles(context.project_path);
|
||||
|
||||
const mustHaveScore = this.checkMustHaves(context, gitContext, ciFiles);
|
||||
const reqCoverage = this.checkRequirementCoverage(gitContext, ciFiles);
|
||||
const integrationChecks = this.checkIntegrationLinks(context.project_path);
|
||||
|
||||
const allPassed = pipelineResult.all_passed &&
|
||||
mustHaveScore >= 1.0 &&
|
||||
reqCoverage.partial.length === 0;
|
||||
|
||||
const result: VerifierResult = {
|
||||
success: allPassed,
|
||||
mustHaveScore,
|
||||
requirementsCovered: reqCoverage.covered,
|
||||
requirementsPartial: reqCoverage.partial,
|
||||
integrationChecks,
|
||||
layers,
|
||||
};
|
||||
|
||||
if (!allPassed) {
|
||||
result.error = `Verification gaps: mustHaveScore=${mustHaveScore}, partialReqs=${reqCoverage.partial.join(",")}, layerFailures=${layers.filter(l => !l.passed).map(l => l.name).join(",")}`;
|
||||
}
|
||||
|
||||
this.commitVerificationResult(context, result, ciFiles);
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
mustHaveScore: 0,
|
||||
requirementsCovered: [],
|
||||
requirementsPartial: [],
|
||||
integrationChecks: [],
|
||||
layers: [],
|
||||
error: `Verification failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private checkMustHaves(context: AgentContext, gitContext: GitContext, ciFiles: CIAgentFiles): number {
|
||||
const roadmap = ciFiles.readRoadmapMd();
|
||||
if (!roadmap) return 0;
|
||||
|
||||
const currentPhase = roadmap.phases.find(p => p.number === context.phase);
|
||||
if (!currentPhase) return 0;
|
||||
|
||||
const successCriteria = currentPhase.successCriteria;
|
||||
if (successCriteria.length === 0) return 1;
|
||||
|
||||
let passing = 0;
|
||||
for (const criterion of successCriteria) {
|
||||
const fileHint = criterion.match(/(?:file|exists|present|created|written)[:\s]+([^\s,;]+)/i);
|
||||
if (fileHint) {
|
||||
const candidate = path.join(context.project_path, fileHint[1]);
|
||||
if (fileExists(candidate)) {
|
||||
passing++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (fileExists(path.join(context.project_path, ".ciagent"))) {
|
||||
passing++;
|
||||
}
|
||||
}
|
||||
|
||||
return Math.min(passing / successCriteria.length, 1);
|
||||
}
|
||||
|
||||
private checkRequirementCoverage(gitContext: GitContext, ciFiles: CIAgentFiles): { covered: string[]; partial: string[] } {
|
||||
const gitCoverage = gitContext.getRequirementsCoverage();
|
||||
const reqsMd = ciFiles.readRequirementsMd();
|
||||
|
||||
if (!reqsMd || reqsMd.traceability.length === 0) {
|
||||
return { covered: gitCoverage.covered, partial: gitCoverage.partial };
|
||||
}
|
||||
|
||||
const covered = new Set(gitCoverage.covered);
|
||||
const partial = new Set(gitCoverage.partial);
|
||||
|
||||
for (const t of reqsMd.traceability) {
|
||||
if (t.status === "complete") {
|
||||
covered.add(t.requirement);
|
||||
partial.delete(t.requirement);
|
||||
} else if (t.status === "in_progress" || t.status === "blocked") {
|
||||
partial.add(t.requirement);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
covered: [...covered].sort(),
|
||||
partial: [...partial].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
private checkIntegrationLinks(projectPath: string): { import: string; resolved: boolean }[] {
|
||||
const checks: { import: string; resolved: boolean }[] = [];
|
||||
const srcDir = path.join(projectPath, "src");
|
||||
|
||||
if (!fs.existsSync(srcDir)) return checks;
|
||||
|
||||
const tsFiles = this.collectTsFiles(srcDir);
|
||||
const importPattern = /import\s+.*from\s+['"](\.\/[^'"]+)['"]/g;
|
||||
|
||||
for (const file of tsFiles) {
|
||||
const content = fs.readFileSync(file, "utf-8");
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = importPattern.exec(content)) !== null) {
|
||||
const importPath = match[1];
|
||||
const resolved = this.resolveImport(file, importPath);
|
||||
if (importPath.startsWith(".")) {
|
||||
checks.push({ import: `${path.relative(projectPath, file)}:${importPath}`, resolved: resolved !== null });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return checks;
|
||||
}
|
||||
|
||||
private commitVerificationResult(context: AgentContext, result: VerifierResult, ciFiles: CIAgentFiles): void {
|
||||
try {
|
||||
const projectState = new GitContext(context.project_path).reconstructState();
|
||||
const milestone = projectState.currentMilestone || "v1.0";
|
||||
|
||||
const verifyInput: VerifyCommitInput = {
|
||||
phase: context.phase,
|
||||
milestone,
|
||||
subject: result.success ? "passed" : "gaps_found",
|
||||
requirements: {
|
||||
covered: result.requirementsCovered,
|
||||
partial: result.requirementsPartial,
|
||||
},
|
||||
lessons: result.success ? ["All verification checks passed"] : [`Must-have score: ${result.mustHaveScore}`, `Layer failures: ${result.layers.filter(l => !l.passed).map(l => l.name).join(", ")}`],
|
||||
};
|
||||
|
||||
const commitMsg = CommitBuilder.buildVerifyCommit(verifyInput);
|
||||
if (fileExists(path.join(context.project_path, ".git"))) {
|
||||
execSync(`git add -A && git commit -m "${commitMsg.replace(/"/g, '\\"')}" --allow-empty`, {
|
||||
cwd: context.project_path,
|
||||
stdio: "pipe",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
this.warn(`Verification commit failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private collectTsFiles(dir: string): string[] {
|
||||
const files: string[] = [];
|
||||
if (!fs.existsSync(dir)) return files;
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory() && entry.name !== "node_modules") {
|
||||
files.push(...this.collectTsFiles(fullPath));
|
||||
} else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts") && !entry.name.endsWith(".test.ts")) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
private resolveImport(fromFile: string, importPath: string): string | null {
|
||||
if (!importPath.startsWith(".")) return null;
|
||||
const dir = path.dirname(fromFile);
|
||||
const candidates = [
|
||||
path.resolve(dir, importPath + ".ts"),
|
||||
path.resolve(dir, importPath + ".js"),
|
||||
path.resolve(dir, importPath, "index.ts"),
|
||||
path.resolve(dir, importPath, "index.js"),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -15,16 +15,26 @@ import {
|
||||
import { AgentName, ModelProfile } from "../types/config.js";
|
||||
import { Decision } from "../types/decisions.js";
|
||||
import { Escalation } from "../types/escalation.js";
|
||||
import { ToolRegistry, ToolCall, ToolResult } from "./tool-registry.js";
|
||||
import { ToolRegistry, ToolCall, ToolResult, ToolDefinition } from "./tool-registry.js";
|
||||
|
||||
const MAX_TOOL_ROUNDS = 50;
|
||||
|
||||
const PERSONA_TOOL_MAP: Record<string, string> = {
|
||||
read: "readFile",
|
||||
write: "writeFile",
|
||||
edit: "editFile",
|
||||
bash: "runBash",
|
||||
glob: "glob",
|
||||
grep: "grep",
|
||||
};
|
||||
|
||||
export abstract class OllamaBaseBackend implements IntelligenceBackend {
|
||||
abstract readonly name: string;
|
||||
readonly type: BackendType = "llm";
|
||||
|
||||
protected config: LLMBackendConfig;
|
||||
protected projectPath: string;
|
||||
protected filteredToolSchema: Array<Record<string, unknown>> | null = null;
|
||||
|
||||
constructor(config: LLMBackendConfig | undefined) {
|
||||
this.config = config || { base_url: "http://localhost:11434", model_profile: "balanced" };
|
||||
@@ -42,6 +52,9 @@ export abstract class OllamaBaseBackend implements IntelligenceBackend {
|
||||
const model = this.resolveModel();
|
||||
|
||||
const toolRegistry = new ToolRegistry(request.context.project_path);
|
||||
const allowedTools = this.parsePersonaTools(personaContent);
|
||||
const filteredDefinitions = this.filterToolDefinitions(toolRegistry.getDefinitions(), allowedTools);
|
||||
this.filteredToolSchema = this.definitionsToOpenAISchema(filteredDefinitions);
|
||||
|
||||
const messages: OllamaMessage[] = [];
|
||||
messages.push({
|
||||
@@ -62,7 +75,7 @@ export abstract class OllamaBaseBackend implements IntelligenceBackend {
|
||||
|
||||
while (round < MAX_TOOL_ROUNDS) {
|
||||
round++;
|
||||
const response = await this.callModel(messages, model, toolRegistry);
|
||||
const response = await this.callModelWithTools(messages, model, filteredDefinitions);
|
||||
|
||||
totalInputTokens += response.usage?.prompt_tokens || 0;
|
||||
totalOutputTokens += response.usage?.completion_tokens || 0;
|
||||
@@ -124,6 +137,65 @@ export abstract class OllamaBaseBackend implements IntelligenceBackend {
|
||||
}
|
||||
}
|
||||
|
||||
protected parsePersonaTools(personaContent: string): string[] | null {
|
||||
const frontmatterMatch = personaContent.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (!frontmatterMatch) return null;
|
||||
|
||||
const frontmatter = frontmatterMatch[1];
|
||||
const toolsMatch = frontmatter.match(/tools:\s*\n((?:\s+\w+:.+\n?)+)/);
|
||||
if (!toolsMatch) {
|
||||
const inlineMatch = frontmatter.match(/tools:\s*\[([^\]]+)\]/);
|
||||
if (inlineMatch) {
|
||||
return inlineMatch[1]
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
.map((t) => PERSONA_TOOL_MAP[t] || t);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const toolsBlock = toolsMatch[1];
|
||||
const toolNames: string[] = [];
|
||||
const lineRegex = /^\s+(\w+):/gm;
|
||||
let lineMatch;
|
||||
while ((lineMatch = lineRegex.exec(toolsBlock)) !== null) {
|
||||
const personaToolName = lineMatch[1];
|
||||
toolNames.push(PERSONA_TOOL_MAP[personaToolName] || personaToolName);
|
||||
}
|
||||
|
||||
return toolNames.length > 0 ? toolNames : null;
|
||||
}
|
||||
|
||||
protected filterToolDefinitions(definitions: ToolDefinition[], allowedTools: string[] | null): ToolDefinition[] {
|
||||
if (!allowedTools) return definitions;
|
||||
const allowedSet = new Set(allowedTools);
|
||||
return definitions.filter((def) => allowedSet.has(def.name));
|
||||
}
|
||||
|
||||
protected async callModelWithTools(
|
||||
messages: OllamaMessage[],
|
||||
model: string,
|
||||
toolDefinitions: ToolDefinition[]
|
||||
): Promise<OllamaChatResponse> {
|
||||
return this.callModel(messages, model, new ToolRegistry(this.projectPath));
|
||||
}
|
||||
|
||||
protected definitionsToOpenAISchema(definitions: ToolDefinition[]): Array<Record<string, unknown>> {
|
||||
return definitions.map((def) => ({
|
||||
type: "function",
|
||||
function: {
|
||||
name: def.name,
|
||||
description: def.description,
|
||||
parameters: def.parameters,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
protected getActiveToolSchema(toolRegistry: ToolRegistry): Array<Record<string, unknown>> {
|
||||
return this.filteredToolSchema || toolRegistry.getOpenAIToolSchema();
|
||||
}
|
||||
|
||||
protected abstract callModel(
|
||||
messages: OllamaMessage[],
|
||||
model: string,
|
||||
|
||||
@@ -61,7 +61,7 @@ export class OllamaCloudBackend extends OllamaBaseBackend {
|
||||
if (m.tool_calls) msg.tool_calls = m.tool_calls;
|
||||
return msg;
|
||||
}),
|
||||
tools: toolRegistry.getOpenAIToolSchema(),
|
||||
tools: this.getActiveToolSchema(toolRegistry),
|
||||
stream: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ export class OllamaLocalBackend extends OllamaBaseBackend {
|
||||
if (m.tool_calls) msg.tool_calls = m.tool_calls;
|
||||
return msg;
|
||||
}),
|
||||
tools: toolRegistry.getOpenAIToolSchema(),
|
||||
tools: this.getActiveToolSchema(toolRegistry),
|
||||
stream: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ import { PipelineState, createInitialPipelineState } from "../types/pipeline.js"
|
||||
import { resolveBackend } from "../backends/index.js";
|
||||
import { BackendUnavailableError } from "../backends/types.js";
|
||||
import { getAgent } from "../agents/index.js";
|
||||
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 { execSync } from "node:child_process";
|
||||
@@ -642,6 +644,83 @@ export function createRollbackCommand(): Command {
|
||||
});
|
||||
}
|
||||
|
||||
export function createProjectsCommand(): Command {
|
||||
const cmd = new Command("projects");
|
||||
cmd.description("Manage CIAgent projects in multi-project mode");
|
||||
|
||||
cmd.command("list")
|
||||
.description("List all registered projects")
|
||||
.action(() => {
|
||||
const projectPath = process.cwd();
|
||||
|
||||
if (!isCIAgentInitialized(projectPath)) {
|
||||
console.error("CIAgent project not initialized. Run 'ciagent init' first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config = loadConfig(projectPath);
|
||||
const ciFiles = new CIAgentFiles(projectPath);
|
||||
const projects = ciFiles.listProjects();
|
||||
const activeProject = config.active_project || ciFiles.getActiveProject();
|
||||
|
||||
if (projects.length === 0) {
|
||||
console.log("No projects registered.");
|
||||
console.log("Use 'ciagent projects add <slug> <name>' to add a project.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("─── CIAgent Projects ───\n");
|
||||
for (const project of projects) {
|
||||
const isActive = project.slug === activeProject;
|
||||
const marker = isActive ? " *" : "";
|
||||
console.log(` ${project.slug} — ${project.name}${marker}`);
|
||||
}
|
||||
console.log("\n * = active project");
|
||||
});
|
||||
|
||||
cmd.command("add <slug> <name>")
|
||||
.description("Add a new project")
|
||||
.action((slug: string, name: string) => {
|
||||
const projectPath = process.cwd();
|
||||
|
||||
if (!isCIAgentInitialized(projectPath)) {
|
||||
console.error("CIAgent project not initialized. Run 'ciagent init' first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const ciFiles = new CIAgentFiles(projectPath);
|
||||
ciFiles.addProject(slug, name);
|
||||
console.log(`✓ Project added: ${slug} (${name})`);
|
||||
});
|
||||
|
||||
cmd.command("set <slug>")
|
||||
.description("Set the active project")
|
||||
.action((slug: string) => {
|
||||
const projectPath = process.cwd();
|
||||
|
||||
if (!isCIAgentInitialized(projectPath)) {
|
||||
console.error("CIAgent project not initialized. Run 'ciagent init' first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const ciFiles = new CIAgentFiles(projectPath);
|
||||
const projects = ciFiles.listProjects();
|
||||
|
||||
if (!projects.some((p) => p.slug === slug)) {
|
||||
console.error(`Project "${slug}" not found. Registered projects: ${projects.map((p) => p.slug).join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
ciFiles.setActiveProject(slug);
|
||||
const config = loadConfig(projectPath);
|
||||
config.active_project = slug;
|
||||
saveConfig(projectPath, config);
|
||||
console.log(`✓ Active project set to: ${slug}`);
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
export function createShipCommand(): Command {
|
||||
return new Command("ship")
|
||||
.description("Auto-complete phase: verify, security, commit, tag")
|
||||
@@ -713,6 +792,35 @@ export function createShipCommand(): Command {
|
||||
});
|
||||
console.log(` ✓ Tagged: ${version.tag}`);
|
||||
|
||||
if (config.gitea && config.gitea.owner && config.gitea.repo) {
|
||||
const apiToken = process.env[config.gitea.api_token_env];
|
||||
if (apiToken) {
|
||||
try {
|
||||
const previousTag = getPreviousTag(projectPath, version.tag);
|
||||
const releaseNotes = generateReleaseNotes(projectPath, previousTag, version.tag);
|
||||
|
||||
const giteaClient = new GiteaClient({
|
||||
baseUrl: config.gitea.base_url,
|
||||
token: apiToken,
|
||||
owner: config.gitea.owner,
|
||||
repo: config.gitea.repo,
|
||||
});
|
||||
|
||||
const release = await giteaClient.createRelease({
|
||||
tag_name: version.tag,
|
||||
name: version.tag,
|
||||
body: releaseNotes,
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
});
|
||||
|
||||
console.log(` ✓ Release created: ${release.html_url}`);
|
||||
} catch (giteaErr) {
|
||||
console.warn(` ⚠ Gitea release failed: ${giteaErr instanceof Error ? giteaErr.message : String(giteaErr)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (config.git.auto_push) {
|
||||
execSync(`git push origin ${version.tag}`, { cwd: projectPath, stdio: "pipe" });
|
||||
console.log(` ✓ Pushed tag: ${version.tag}`);
|
||||
@@ -819,4 +927,20 @@ function resolveMergeTarget(projectPath: string, milestoneType: string): string
|
||||
} catch {}
|
||||
|
||||
return "main";
|
||||
}
|
||||
|
||||
function getPreviousTag(projectPath: string, currentTag: string): string | null {
|
||||
try {
|
||||
const tags = execSync("git tag -l --sort=-v:refname", { cwd: projectPath, encoding: "utf-8" })
|
||||
.split("\n")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const currentIdx = tags.indexOf(currentTag);
|
||||
if (currentIdx >= 0 && currentIdx + 1 < tags.length) {
|
||||
return tags[currentIdx + 1];
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return null;
|
||||
}
|
||||
+13
-1
@@ -2,6 +2,8 @@
|
||||
|
||||
import { Command } from "commander";
|
||||
import { VERSION } from "../version.js";
|
||||
import { CIAgentFiles } from "../core/ciagent-files.js";
|
||||
import { isCIAgentInitialized } from "../core/config.js";
|
||||
import {
|
||||
createInitCommand,
|
||||
createRunCommand,
|
||||
@@ -14,6 +16,7 @@ import {
|
||||
createClarifyCommand,
|
||||
createRollbackCommand,
|
||||
createShipCommand,
|
||||
createProjectsCommand,
|
||||
} from "./commands.js";
|
||||
|
||||
const program = new Command();
|
||||
@@ -22,6 +25,14 @@ program
|
||||
.name("ciagent")
|
||||
.description("CIAgent — Continuous Intelligence: autonomous AI-driven software engineering harness")
|
||||
.version(VERSION)
|
||||
.option("--project <slug>", "Specify which project to operate on")
|
||||
.hook("preAction", () => {
|
||||
const opts = program.opts();
|
||||
if (opts.project && isCIAgentInitialized(process.cwd())) {
|
||||
const ciFiles = new CIAgentFiles(process.cwd());
|
||||
ciFiles.setProjectSlug(opts.project);
|
||||
}
|
||||
})
|
||||
.addCommand(createInitCommand())
|
||||
.addCommand(createRunCommand())
|
||||
.addCommand(createQuickCommand())
|
||||
@@ -32,6 +43,7 @@ program
|
||||
.addCommand(createAuditCommand())
|
||||
.addCommand(createClarifyCommand())
|
||||
.addCommand(createRollbackCommand())
|
||||
.addCommand(createShipCommand());
|
||||
.addCommand(createShipCommand())
|
||||
.addCommand(createProjectsCommand());
|
||||
|
||||
program.parse();
|
||||
@@ -0,0 +1,170 @@
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
export interface GiteaReleaseConfig {
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
owner: string;
|
||||
repo: string;
|
||||
}
|
||||
|
||||
export interface GiteaRelease {
|
||||
id: number;
|
||||
tag_name: string;
|
||||
name: string;
|
||||
body: string;
|
||||
url: string;
|
||||
html_url: string;
|
||||
draft: boolean;
|
||||
prerelease: boolean;
|
||||
}
|
||||
|
||||
export class GiteaClient {
|
||||
private config: GiteaReleaseConfig;
|
||||
|
||||
constructor(config: GiteaReleaseConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
async createRelease(params: {
|
||||
tag_name: string;
|
||||
name: string;
|
||||
body: string;
|
||||
draft?: boolean;
|
||||
prerelease?: boolean;
|
||||
}): Promise<GiteaRelease> {
|
||||
const url = `${this.config.baseUrl}/api/v1/repos/${this.config.owner}/${this.config.repo}/releases`;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `token ${this.config.token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tag_name: params.tag_name,
|
||||
name: params.name,
|
||||
body: params.body,
|
||||
draft: params.draft ?? false,
|
||||
prerelease: params.prerelease ?? false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Gitea API error: ${response.status} ${text}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<GiteaRelease>;
|
||||
}
|
||||
|
||||
async listReleases(): Promise<GiteaRelease[]> {
|
||||
const url = `${this.config.baseUrl}/api/v1/repos/${this.config.owner}/${this.config.repo}/releases`;
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Authorization": `token ${this.config.token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Gitea API error: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<GiteaRelease[]>;
|
||||
}
|
||||
|
||||
async getReleaseByTag(tag: string): Promise<GiteaRelease | null> {
|
||||
const url = `${this.config.baseUrl}/api/v1/repos/${this.config.owner}/${this.config.repo}/releases/tags/${tag}`;
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Authorization": `token ${this.config.token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Gitea API error: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<GiteaRelease>;
|
||||
}
|
||||
}
|
||||
|
||||
export function generateReleaseNotes(projectPath: string, fromTag: string | null, toTag: string): string {
|
||||
let gitLogCmd: string;
|
||||
if (fromTag) {
|
||||
gitLogCmd = `git log ${fromTag}..${toTag} --oneline`;
|
||||
} else {
|
||||
gitLogCmd = `git log ${toTag} --oneline`;
|
||||
}
|
||||
|
||||
let logOutput: string;
|
||||
try {
|
||||
logOutput = execSync(gitLogCmd, { cwd: projectPath, encoding: "utf-8" }).trim();
|
||||
} catch {
|
||||
return `## What's Changed\n\nNo commits found between ${fromTag || "beginning"} and ${toTag}.\n`;
|
||||
}
|
||||
|
||||
if (!logOutput) {
|
||||
return `## What's Changed\n\nNo commits found between ${fromTag || "beginning"} and ${toTag}.\n`;
|
||||
}
|
||||
|
||||
const lines = logOutput.split("\n").filter(Boolean);
|
||||
|
||||
const featCommits: string[] = [];
|
||||
const fixCommits: string[] = [];
|
||||
const testCommits: string[] = [];
|
||||
const otherCommits: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const subject = line.replace(/^[a-f0-9]+\s+/, "");
|
||||
if (/^feat/i.test(subject)) {
|
||||
featCommits.push(subject);
|
||||
} else if (/^fix/i.test(subject)) {
|
||||
fixCommits.push(subject);
|
||||
} else if (/^test/i.test(subject)) {
|
||||
testCommits.push(subject);
|
||||
} else {
|
||||
otherCommits.push(subject);
|
||||
}
|
||||
}
|
||||
|
||||
const sections: string[] = [];
|
||||
|
||||
if (featCommits.length > 0) {
|
||||
sections.push("### New Features\n");
|
||||
for (const c of featCommits) {
|
||||
sections.push(`- ${c}`);
|
||||
}
|
||||
sections.push("");
|
||||
}
|
||||
|
||||
if (fixCommits.length > 0) {
|
||||
sections.push("### Bug Fixes\n");
|
||||
for (const c of fixCommits) {
|
||||
sections.push(`- ${c}`);
|
||||
}
|
||||
sections.push("");
|
||||
}
|
||||
|
||||
if (testCommits.length > 0) {
|
||||
sections.push("### Tests\n");
|
||||
for (const c of testCommits) {
|
||||
sections.push(`- ${c}`);
|
||||
}
|
||||
sections.push("");
|
||||
}
|
||||
|
||||
if (otherCommits.length > 0) {
|
||||
sections.push("### Other Changes\n");
|
||||
for (const c of otherCommits) {
|
||||
sections.push(`- ${c}`);
|
||||
}
|
||||
sections.push("");
|
||||
}
|
||||
|
||||
return `## What's Changed\n\n${sections.join("\n")}`;
|
||||
}
|
||||
@@ -8,5 +8,7 @@ export { GitContext } from "./git-context.js";
|
||||
export { GitBranch } from "./git-branch.js";
|
||||
export { CommitBuilder } from "./commit-builder.js";
|
||||
export { extractCIAgentBlock, parseCIAgentBlock, parseCommitMessage } from "./commit-parser.js";
|
||||
export { GiteaClient, generateReleaseNotes } from "./gitea.js";
|
||||
export type { GiteaReleaseConfig, GiteaRelease } from "./gitea.js";
|
||||
export type { CIAgentConfig } from "../types/config.js";
|
||||
export { DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
|
||||
+5
-1
@@ -8,12 +8,15 @@ export { GitContext } from "./core/git-context.js";
|
||||
export { GitBranch } from "./core/git-branch.js";
|
||||
export { CommitBuilder } from "./core/commit-builder.js";
|
||||
export { extractCIAgentBlock, parseCIAgentBlock, parseCommitMessage } from "./core/commit-parser.js";
|
||||
export { GiteaClient, generateReleaseNotes } from "./core/gitea.js";
|
||||
export { VerificationPipeline } from "./verification/index.js";
|
||||
export { StructuralVerification } from "./verification/structural.js";
|
||||
export { BehavioralVerification } from "./verification/behavioral.js";
|
||||
export { SecurityVerification } from "./verification/security.js";
|
||||
export { QualityVerification } from "./verification/quality.js";
|
||||
export { getAgent, getAvailableAgents } from "./agents/index.js";
|
||||
export type { PlannerResult } from "./agents/planner.js";
|
||||
export type { ExecutorResult } from "./agents/executor.js";
|
||||
export { initCIAgent, loadConfig, saveConfig, isCIAgentInitialized } from "./core/config.js";
|
||||
export { DEFAULT_CIAGENT_CONFIG } from "./types/config.js";
|
||||
export { confidenceToLevel, shouldEscalate } from "./types/decisions.js";
|
||||
@@ -28,7 +31,7 @@ export { OllamaLocalBackend } from "./backends/ollama-local.js";
|
||||
export { OllamaCloudBackend } from "./backends/ollama-cloud.js";
|
||||
export { ToolRegistry } from "./backends/tool-registry.js";
|
||||
|
||||
export type { CIAgentConfig, AutonomyLevel, ModelProfile } from "./types/config.js";
|
||||
export type { CIAgentConfig, AutonomyLevel, ModelProfile, GiteaConfig } from "./types/config.js";
|
||||
export type { Decision, DecisionCategory } from "./types/decisions.js";
|
||||
export type { Escalation, EscalationType } from "./types/escalation.js";
|
||||
export type { PipelineState, PhaseResult, OrchestratorResult } from "./types/pipeline.js";
|
||||
@@ -42,5 +45,6 @@ export type { CIAgentMetadata, ParsedCIAgentCommit, CommitType, CommitScope, Com
|
||||
export type { ProjectState, BranchInfo } from "./core/git-context.js";
|
||||
export type { PhaseBranchInfo, MilestoneBranchInfo, BranchCreateResult, BranchMergeResult } from "./core/git-branch.js";
|
||||
export type { ProjectMd, RoadmapMd, RequirementsMd, ArchitectureMd } from "./core/ciagent-files.js";
|
||||
export type { GiteaReleaseConfig, GiteaRelease } from "./core/gitea.js";
|
||||
export type { IntelligenceBackend, BackendRequest, BackendResult, BackendConfigSection, BackendUnavailableError, Artifact, TokenUsage } from "./backends/types.js";
|
||||
export type { ToolDefinition, ToolCall, ToolResult } from "./backends/tool-registry.js";
|
||||
@@ -66,6 +66,13 @@ export interface GitConfig {
|
||||
auto_push: boolean;
|
||||
}
|
||||
|
||||
export interface GiteaConfig {
|
||||
base_url: string;
|
||||
api_token_env: string;
|
||||
owner: string;
|
||||
repo: string;
|
||||
}
|
||||
|
||||
export interface ProjectEntry {
|
||||
slug: string;
|
||||
name: string;
|
||||
@@ -82,6 +89,7 @@ export interface CIAgentConfig {
|
||||
security: SecurityConfig;
|
||||
git: GitConfig;
|
||||
backend: BackendConfigSection;
|
||||
gitea?: GiteaConfig;
|
||||
}
|
||||
|
||||
export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = {
|
||||
@@ -136,4 +144,10 @@ export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = {
|
||||
},
|
||||
},
|
||||
},
|
||||
gitea: {
|
||||
base_url: "https://git.cloudinit.dev",
|
||||
api_token_env: "GITEA_TOKEN",
|
||||
owner: "",
|
||||
repo: "",
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user