Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e8c6c5c917 | |||
| 4de1f65c10 | |||
| 6902c37ced | |||
| bbabd2dc0a | |||
| 99df4fe4e2 | |||
| 8527df24b3 |
+13
-1
@@ -19,7 +19,7 @@
|
|||||||
"dev": "ts-node src/cli.ts",
|
"dev": "ts-node src/cli.ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"prepublishOnly": "npm run build",
|
"prepublishOnly": "npm run build && npm test",
|
||||||
"install-opencode": "node scripts/postinstall.js"
|
"install-opencode": "node scripts/postinstall.js"
|
||||||
},
|
},
|
||||||
"keywords": ["ciagent", "autonomous", "ai", "software-engineering", "agent", "multi-project"],
|
"keywords": ["ciagent", "autonomous", "ai", "software-engineering", "agent", "multi-project"],
|
||||||
@@ -27,6 +27,18 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"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": {
|
"dependencies": {
|
||||||
"commander": "^12.1.0",
|
"commander": "^12.1.0",
|
||||||
"zod": "^3.23.0"
|
"zod": "^3.23.0"
|
||||||
|
|||||||
+163
-7
@@ -1,4 +1,21 @@
|
|||||||
import { BaseAgent, AgentContext, AgentResult } from "./base.js";
|
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 {
|
export class ExecutorAgent extends BaseAgent {
|
||||||
readonly name = "executor";
|
readonly name = "executor";
|
||||||
@@ -8,21 +25,160 @@ export class ExecutorAgent extends BaseAgent {
|
|||||||
async execute(context: AgentContext): Promise<AgentResult> {
|
async execute(context: AgentContext): Promise<AgentResult> {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
this.log("Executing tasks...");
|
this.log("Executing tasks...");
|
||||||
|
|
||||||
if (context.backend) {
|
if (context.backend) {
|
||||||
const result = await this.executeViaBackend(
|
const taskPrompt = await this.buildBackendTaskPrompt(context);
|
||||||
context,
|
const backendResult = await this.executeViaBackend(context, taskPrompt);
|
||||||
`Execute implementation for stage ${context.stage}, phase ${context.phase}. Specification: ${context.specification}`
|
|
||||||
);
|
const verification = await this.verifyExecution(context);
|
||||||
return { ...result, duration_ms: Date.now() - start };
|
|
||||||
|
return {
|
||||||
|
...backendResult,
|
||||||
|
output: `${backendResult.output}\nVerification: tests=${verification.testsPassing ? "passing" : "failing"}, must-haves checked=${verification.mustHavesChecked.length}`,
|
||||||
|
duration_ms: Date.now() - start,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
output: "Execution requires an intelligence backend. Configure one with: ci init --backend",
|
output: "Executor requires intelligence backend for code implementation",
|
||||||
artifacts_created: [],
|
artifacts_created: [],
|
||||||
decisions: 0,
|
decisions: 0,
|
||||||
escalations: 0,
|
escalations: 0,
|
||||||
duration_ms: Date.now() - start,
|
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 { BaseAgent, AgentContext, AgentResult, backendResultToAgentResult } from "./base.js";
|
||||||
export { OrchestratorAgent } from "./orchestrator.js";
|
export { OrchestratorAgent } from "./orchestrator.js";
|
||||||
export { PlannerAgent } from "./planner.js";
|
export { PlannerAgent, PlannerResult } from "./planner.js";
|
||||||
export { ExecutorAgent } from "./executor.js";
|
export { ExecutorAgent, ExecutorResult } from "./executor.js";
|
||||||
export { VerifierAgent } from "./verifier.js";
|
export { VerifierAgent, VerifierResult } from "./verifier.js";
|
||||||
export { ResearcherAgent } from "./researcher.js";
|
export { ResearcherAgent, ResearcherResult } from "./researcher.js";
|
||||||
export { ChallengerAgent } from "./challenger.js";
|
export { ChallengerAgent } from "./challenger.js";
|
||||||
export { SecurityAuditorAgent } from "./security-auditor.js";
|
export { SecurityAuditorAgent } from "./security-auditor.js";
|
||||||
export { DebuggerAgent } from "./debugger.js";
|
export { DebuggerAgent } from "./debugger.js";
|
||||||
@@ -17,7 +17,7 @@ export { ProjectResearcherAgent } from "./project-researcher.js";
|
|||||||
export { ResearchSynthesizerAgent } from "./research-synthesizer.js";
|
export { ResearchSynthesizerAgent } from "./research-synthesizer.js";
|
||||||
export { SolutionWriterAgent } from "./solution-writer.js";
|
export { SolutionWriterAgent } from "./solution-writer.js";
|
||||||
export { PhaseResearcherAgent } from "./phase-researcher.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 { AgentName } from "../types/config.js";
|
||||||
import { BaseAgent as BaseAgentType } from "./base.js";
|
import { BaseAgent as BaseAgentType } from "./base.js";
|
||||||
|
|||||||
+299
-21
@@ -19,6 +19,7 @@ import { Specification, parseSpecification } from "../types/specification.js";
|
|||||||
import { loadConfig, saveConfig, isCIAgentInitialized, initCIAgent } from "../core/config.js";
|
import { loadConfig, saveConfig, isCIAgentInitialized, initCIAgent } from "../core/config.js";
|
||||||
import { getAgent } from "./index.js";
|
import { getAgent } from "./index.js";
|
||||||
import { IntelligenceBackend, BackendUnavailableError } from "../backends/types.js";
|
import { IntelligenceBackend, BackendUnavailableError } from "../backends/types.js";
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
|
||||||
export interface GitAgentContext extends AgentContext {
|
export interface GitAgentContext extends AgentContext {
|
||||||
gitContext: GitContext;
|
gitContext: GitContext;
|
||||||
@@ -41,13 +42,15 @@ export class OrchestratorAgent extends BaseAgent {
|
|||||||
private ciFiles: CIAgentFiles | null = null;
|
private ciFiles: CIAgentFiles | null = null;
|
||||||
private currentMilestone: string;
|
private currentMilestone: string;
|
||||||
private phaseResults: PhaseResult[] = [];
|
private phaseResults: PhaseResult[] = [];
|
||||||
|
private totalPhases: number = 1;
|
||||||
|
|
||||||
private static readonly STAGE_AGENT_MAP: Partial<Record<PipelineStage, AgentName>> = {
|
private static readonly STAGE_AGENT_MAP: Partial<Record<PipelineStage, AgentName[]>> = {
|
||||||
research: "researcher",
|
research: ["researcher"],
|
||||||
plan: "planner",
|
plan: ["planner"],
|
||||||
execute: "executor",
|
execute: ["executor", "code-reviewer", "security-auditor"],
|
||||||
test: "tester",
|
test: ["tester"],
|
||||||
verify: "verifier",
|
verify: ["verifier"],
|
||||||
|
complete: ["doc-writer"],
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(config?: CIAgentConfig) {
|
constructor(config?: CIAgentConfig) {
|
||||||
@@ -79,15 +82,24 @@ export class OrchestratorAgent extends BaseAgent {
|
|||||||
this.pipelineState.current_stage = projectState.currentStage;
|
this.pipelineState.current_stage = projectState.currentStage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.totalPhases = this.deriveTotalPhases();
|
||||||
|
this.log(`Total phases in milestone: ${this.totalPhases}`);
|
||||||
|
|
||||||
this.decisionEngine = new DecisionEngine(this.config, context.project_path, this.currentMilestone);
|
this.decisionEngine = new DecisionEngine(this.config, context.project_path, this.currentMilestone);
|
||||||
this.escalationProtocol = new EscalationProtocol(this.config, context.project_path, this.currentMilestone);
|
this.escalationProtocol = new EscalationProtocol(this.config, context.project_path, this.currentMilestone);
|
||||||
|
|
||||||
|
while (this.pipelineState.current_phase <= this.totalPhases) {
|
||||||
|
this.log(`Processing phase ${this.pipelineState.current_phase} of ${this.totalPhases}`);
|
||||||
|
|
||||||
for (const stage of STAGE_ORDER) {
|
for (const stage of STAGE_ORDER) {
|
||||||
this.log(`Entering stage: ${stage}`);
|
this.log(`Entering stage: ${stage}`);
|
||||||
this.pipelineState.current_stage = stage;
|
this.pipelineState.current_stage = stage;
|
||||||
this.pipelineState.last_updated = new Date().toISOString();
|
this.pipelineState.last_updated = new Date().toISOString();
|
||||||
|
|
||||||
const result = await this.executeStage(stage, context);
|
const result = await this.executeStageWithRecovery(stage, context);
|
||||||
|
|
||||||
|
this.phaseResults.push(result);
|
||||||
|
this.recordPhaseResult(result);
|
||||||
|
|
||||||
if (!result.success && stage !== "complete") {
|
if (!result.success && stage !== "complete") {
|
||||||
this.pipelineState.errors.push({
|
this.pipelineState.errors.push({
|
||||||
@@ -122,6 +134,16 @@ export class OrchestratorAgent extends BaseAgent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.pipelineState.current_phase < this.totalPhases) {
|
||||||
|
this.performPhaseBoundaryCheckpoint(context);
|
||||||
|
this.pipelineState.current_phase++;
|
||||||
|
this.pipelineState.current_stage = "specify";
|
||||||
|
this.log(`Advancing to phase ${this.pipelineState.current_phase}`);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const totalDuration = Date.now() - startTime;
|
const totalDuration = Date.now() - startTime;
|
||||||
const completionReport = this.generateCompletionReport();
|
const completionReport = this.generateCompletionReport();
|
||||||
|
|
||||||
@@ -152,36 +174,240 @@ export class OrchestratorAgent extends BaseAgent {
|
|||||||
duration_ms: Date.now() - startTime,
|
duration_ms: Date.now() - startTime,
|
||||||
error: err instanceof Error ? err.message : String(err),
|
error: err instanceof Error ? err.message : String(err),
|
||||||
};
|
};
|
||||||
|
} finally {
|
||||||
|
this.escalationProtocol?.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildGitAgentContext(context: AgentContext): GitAgentContext {
|
||||||
|
return {
|
||||||
|
...context,
|
||||||
|
gitContext: this.gitContext!,
|
||||||
|
gitBranch: this.gitBranch!,
|
||||||
|
ciFiles: this.ciFiles!,
|
||||||
|
milestone: this.currentMilestone,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordPhaseResult(result: PhaseResult): void {
|
||||||
|
for (const artifact of result.artifacts_created) {
|
||||||
|
this.log(`Artifact created: ${artifact}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.decisions_made > 0 && this.decisionEngine) {
|
||||||
|
this.decisionEngine.makeHighConfidenceDecision(
|
||||||
|
`Agent reported ${result.decisions_made} decision(s) during ${result.stage}`,
|
||||||
|
`Decisions recorded from ${result.stage} stage execution`,
|
||||||
|
"general",
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.escalations_raised > 0 && this.escalationProtocol) {
|
||||||
|
this.escalationProtocol.escalate({
|
||||||
|
type: "low_confidence_decision",
|
||||||
|
phase: String(this.pipelineState!.current_phase),
|
||||||
|
description: `Agent reported ${result.escalations_raised} escalation(s) during ${result.stage}`,
|
||||||
|
context: `Stage ${result.stage} raised escalations during execution`,
|
||||||
|
options: [
|
||||||
|
{ id: "proceed", label: "Proceed", description: "Continue pipeline execution", recommended: true },
|
||||||
|
{ id: "halt", label: "Halt", description: "Stop pipeline and await manual review", recommended: false },
|
||||||
|
],
|
||||||
|
default_option_id: "proceed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private deriveTotalPhases(): number {
|
||||||
|
if (!this.ciFiles) return 1;
|
||||||
|
const roadmap = this.ciFiles.readRoadmapMd();
|
||||||
|
if (!roadmap || roadmap.phases.length === 0) return 1;
|
||||||
|
return roadmap.phases.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private performPhaseBoundaryCheckpoint(context: AgentContext): void {
|
||||||
|
this.log(`Phase boundary checkpoint for phase ${this.pipelineState!.current_phase}`);
|
||||||
|
|
||||||
|
if (this.config.git.auto_commit && this.gitContext!.isGitRepo()) {
|
||||||
|
try {
|
||||||
|
const message = `chore(P${String(this.pipelineState!.current_phase).padStart(2, "0")}): phase boundary checkpoint\n\n---ci---\nphase: ${this.pipelineState!.current_phase}\nmilestone: ${this.currentMilestone}\nstatus: complete\n---/ci---`;
|
||||||
|
execSync(`git add -A && git commit -m "${message.replace(/"/g, '\\"')}" --allow-empty`, {
|
||||||
|
cwd: context.project_path,
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.warn(`Phase boundary commit failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.ciFiles) {
|
||||||
|
this.ciFiles.updatePhaseStatus(this.pipelineState!.current_phase, "complete");
|
||||||
|
|
||||||
|
const reqs = this.ciFiles.readRequirementsMd();
|
||||||
|
if (reqs) {
|
||||||
|
for (const t of reqs.traceability) {
|
||||||
|
if (t.phase === this.pipelineState!.current_phase && t.status === "in_progress") {
|
||||||
|
this.ciFiles.updateRequirementStatus(t.requirement, "complete");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.gitContext) {
|
||||||
|
const verifiedState = this.gitContext.reconstructState();
|
||||||
|
this.log(`Verified state: phase=${verifiedState.currentPhase}, milestone=${verifiedState.currentMilestone}, stage=${verifiedState.currentStage}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async executeStageWithRecovery(
|
||||||
|
stage: PipelineStage,
|
||||||
|
context: AgentContext
|
||||||
|
): Promise<PhaseResult> {
|
||||||
|
try {
|
||||||
|
const result = await this.executeStage(stage, context);
|
||||||
|
if (result.success) return result;
|
||||||
|
} catch (err) {
|
||||||
|
this.warn(`First attempt failed for ${stage}: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.log(`Retrying stage ${stage}...`);
|
||||||
|
try {
|
||||||
|
const result = await this.executeStage(stage, context);
|
||||||
|
if (result.success) return result;
|
||||||
|
} catch (err) {
|
||||||
|
this.warn(`Retry failed for ${stage}: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.backend) {
|
||||||
|
this.log(`Attempting plan revision for failed stage ${stage}...`);
|
||||||
|
try {
|
||||||
|
const planner = getAgent("planner");
|
||||||
|
const gitContext = this.buildGitAgentContext(context);
|
||||||
|
const planResult = await planner.execute({
|
||||||
|
...gitContext,
|
||||||
|
specification: `Plan revision needed: stage ${stage} failed twice. Original error context: phase ${this.pipelineState!.current_phase}`,
|
||||||
|
});
|
||||||
|
if (planResult.success) {
|
||||||
|
this.log(`Plan revision succeeded, retrying ${stage} with revised plan...`);
|
||||||
|
try {
|
||||||
|
const result = await this.executeStage(stage, context);
|
||||||
|
if (result.success) return result;
|
||||||
|
} catch (err) {
|
||||||
|
this.warn(`Post-revision retry failed for ${stage}: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.warn(`Plan revision failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.escalationProtocol) {
|
||||||
|
this.escalationProtocol.escalate({
|
||||||
|
type: "verification_failure",
|
||||||
|
phase: String(this.pipelineState!.current_phase),
|
||||||
|
description: `Stage ${stage} failed after retry and plan revision attempts`,
|
||||||
|
context: `All recovery attempts exhausted for stage ${stage} in phase ${this.pipelineState!.current_phase}`,
|
||||||
|
options: [
|
||||||
|
{ id: "skip", label: "Skip stage", description: "Continue pipeline skipping this stage", recommended: true },
|
||||||
|
{ id: "abort", label: "Abort pipeline", description: "Stop the entire pipeline", recommended: false },
|
||||||
|
],
|
||||||
|
default_option_id: "skip",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
phase: this.pipelineState!.current_phase,
|
||||||
|
stage,
|
||||||
|
success: false,
|
||||||
|
artifacts_created: [],
|
||||||
|
decisions_made: 0,
|
||||||
|
escalations_raised: 1,
|
||||||
|
duration_ms: 0,
|
||||||
|
error: `Stage ${stage} failed after recovery attempts`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async executeStage(
|
private async executeStage(
|
||||||
stage: PipelineStage,
|
stage: PipelineStage,
|
||||||
context: AgentContext
|
context: AgentContext
|
||||||
): Promise<PhaseResult> {
|
): Promise<PhaseResult> {
|
||||||
const stageStart = Date.now();
|
const stageStart = Date.now();
|
||||||
const agentName = OrchestratorAgent.STAGE_AGENT_MAP[stage];
|
const agentNames = OrchestratorAgent.STAGE_AGENT_MAP[stage];
|
||||||
|
|
||||||
if (agentName && context.backend) {
|
if (agentNames && agentNames.length > 0 && context.backend) {
|
||||||
this.log(`Delegating ${stage} to ${agentName} agent via backend...`);
|
this.log(`Delegating ${stage} to ${agentNames.join(", ")} agent(s) via backend...`);
|
||||||
try {
|
try {
|
||||||
|
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 agent = getAgent(agentName);
|
||||||
const result = await agent.execute(context);
|
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 {
|
return {
|
||||||
phase: this.pipelineState!.current_phase,
|
phase: this.pipelineState!.current_phase,
|
||||||
stage,
|
stage,
|
||||||
success: result.success,
|
success: false,
|
||||||
artifacts_created: Array.isArray(result.artifacts_created) ? result.artifacts_created : [],
|
artifacts_created: allArtifacts,
|
||||||
decisions_made: result.decisions,
|
decisions_made: totalDecisions,
|
||||||
escalations_raised: result.escalations,
|
escalations_raised: totalEscalations,
|
||||||
duration_ms: Date.now() - stageStart,
|
duration_ms: Date.now() - stageStart,
|
||||||
error: result.error,
|
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: primaryResult?.success ?? false,
|
||||||
|
artifacts_created: allArtifacts,
|
||||||
|
decisions_made: totalDecisions,
|
||||||
|
escalations_raised: totalEscalations,
|
||||||
|
duration_ms: Date.now() - stageStart,
|
||||||
|
error: lastError,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof BackendUnavailableError) {
|
if (err instanceof BackendUnavailableError) {
|
||||||
this.warn(`Backend unavailable for ${stage}, falling back to mechanical execution`);
|
this.warn(`Backend unavailable for ${stage}, falling back to mechanical execution`);
|
||||||
} else {
|
} 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)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,7 +438,6 @@ export class OrchestratorAgent extends BaseAgent {
|
|||||||
|
|
||||||
if (this.config.git.auto_commit && this.gitContext!.isGitRepo()) {
|
if (this.config.git.auto_commit && this.gitContext!.isGitRepo()) {
|
||||||
try {
|
try {
|
||||||
const { execSync } = await import("node:child_process");
|
|
||||||
this.ciFiles!.writeProjectMd({
|
this.ciFiles!.writeProjectMd({
|
||||||
name: spec.objective.slice(0, 30),
|
name: spec.objective.slice(0, 30),
|
||||||
coreValue: spec.objective,
|
coreValue: spec.objective,
|
||||||
@@ -300,7 +525,6 @@ export class OrchestratorAgent extends BaseAgent {
|
|||||||
["Research completed. Key findings in .ciagent/ARCHITECTURE.md and .ciagent/PROJECT.md updates."]
|
["Research completed. Key findings in .ciagent/ARCHITECTURE.md and .ciagent/PROJECT.md updates."]
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
const { execSync } = await import("node:child_process");
|
|
||||||
execSync(`git add -A && git commit -m "${researchCommit.replace(/"/g, '\\"')}" --allow-empty`, {
|
execSync(`git add -A && git commit -m "${researchCommit.replace(/"/g, '\\"')}" --allow-empty`, {
|
||||||
cwd: context.project_path,
|
cwd: context.project_path,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
@@ -343,6 +567,38 @@ export class OrchestratorAgent extends BaseAgent {
|
|||||||
this.pipelineState!.execute_completed = true;
|
this.pipelineState!.execute_completed = true;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "test": {
|
||||||
|
this.log("Running tests...");
|
||||||
|
if (!context.backend) {
|
||||||
|
this.log("No backend available — running mechanical test fallback via npm test");
|
||||||
|
try {
|
||||||
|
const testOutput = execSync("npm test", {
|
||||||
|
cwd: context.project_path,
|
||||||
|
encoding: "utf-8",
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
timeout: 120000,
|
||||||
|
});
|
||||||
|
this.log("npm test passed");
|
||||||
|
this.pipelineState!.test_completed = true;
|
||||||
|
artifactsCreated.push("test-results");
|
||||||
|
} catch (err) {
|
||||||
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
this.warn(`npm test failed: ${errMsg}`);
|
||||||
|
return {
|
||||||
|
phase: this.pipelineState!.current_phase,
|
||||||
|
stage: "test",
|
||||||
|
success: false,
|
||||||
|
artifacts_created: artifactsCreated,
|
||||||
|
decisions_made: decisionsMade,
|
||||||
|
escalations_raised: escalationsRaised,
|
||||||
|
duration_ms: Date.now() - stageStart,
|
||||||
|
error: `Test stage failed: ${errMsg}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "verify": {
|
case "verify": {
|
||||||
this.log("Running verification...");
|
this.log("Running verification...");
|
||||||
|
|
||||||
@@ -373,7 +629,6 @@ export class OrchestratorAgent extends BaseAgent {
|
|||||||
requirements: { covered: [], partial: [] },
|
requirements: { covered: [], partial: [] },
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const { execSync } = await import("node:child_process");
|
|
||||||
execSync(`git add -A && git commit -m "${verifyCommit.replace(/"/g, '\\"')}" --allow-empty`, {
|
execSync(`git add -A && git commit -m "${verifyCommit.replace(/"/g, '\\"')}" --allow-empty`, {
|
||||||
cwd: context.project_path,
|
cwd: context.project_path,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
@@ -399,7 +654,6 @@ export class OrchestratorAgent extends BaseAgent {
|
|||||||
taskNames: [],
|
taskNames: [],
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const { execSync } = await import("node:child_process");
|
|
||||||
execSync(`git add -A && git commit -m "${completionCommit.replace(/"/g, '\\"')}" --allow-empty`, {
|
execSync(`git add -A && git commit -m "${completionCommit.replace(/"/g, '\\"')}" --allow-empty`, {
|
||||||
cwd: context.project_path,
|
cwd: context.project_path,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
@@ -409,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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+320
-6
@@ -1,4 +1,27 @@
|
|||||||
import { BaseAgent, AgentContext, AgentResult } from "./base.js";
|
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 {
|
export class PlannerAgent extends BaseAgent {
|
||||||
readonly name = "planner";
|
readonly name = "planner";
|
||||||
@@ -8,21 +31,312 @@ export class PlannerAgent extends BaseAgent {
|
|||||||
async execute(context: AgentContext): Promise<AgentResult> {
|
async execute(context: AgentContext): Promise<AgentResult> {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
this.log("Creating phase plan...");
|
this.log("Creating phase plan...");
|
||||||
|
|
||||||
if (context.backend) {
|
if (context.backend) {
|
||||||
const result = await this.executeViaBackend(
|
const taskPrompt = await this.buildBackendTaskPrompt(context);
|
||||||
context,
|
const result = await this.executeViaBackend(context, taskPrompt);
|
||||||
`Create a phase plan for stage ${context.stage}, phase ${context.phase}. Specification: ${context.specification}`
|
|
||||||
);
|
|
||||||
return { ...result, duration_ms: Date.now() - start };
|
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 {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
output: "Planning requires an intelligence backend. Configure one with: ci init --backend",
|
output: "Planning requires either .ciagent/REQUIREMENTS.md or .ciagent/ROADMAP.md. Initialize the project first.",
|
||||||
artifacts_created: [],
|
artifacts_created: [],
|
||||||
decisions: 0,
|
decisions: 0,
|
||||||
escalations: 0,
|
escalations: 0,
|
||||||
duration_ms: Date.now() - start,
|
duration_ms: Date.now() - start,
|
||||||
error: "No intelligence backend available",
|
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: 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 { 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 {
|
export class ResearcherAgent extends BaseAgent {
|
||||||
readonly name = "researcher";
|
readonly name = "researcher";
|
||||||
@@ -8,21 +24,239 @@ export class ResearcherAgent extends BaseAgent {
|
|||||||
async execute(context: AgentContext): Promise<AgentResult> {
|
async execute(context: AgentContext): Promise<AgentResult> {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
this.log("Researching domain...");
|
this.log("Researching domain...");
|
||||||
|
|
||||||
if (context.backend) {
|
if (context.backend) {
|
||||||
const result = await this.executeViaBackend(
|
const result = await this.executeViaBackend(
|
||||||
context,
|
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 };
|
return { ...result, duration_ms: Date.now() - start };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result = await this.runMechanicalResearch(context);
|
||||||
|
const output = JSON.stringify(result, null, 2);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: result.success,
|
||||||
output: "Research requires an intelligence backend. Configure one with: ci init --backend",
|
output,
|
||||||
artifacts_created: [],
|
artifacts_created: result.filesUpdated,
|
||||||
decisions: 0,
|
decisions: result.decisionsLogged,
|
||||||
escalations: 0,
|
escalations: 0,
|
||||||
duration_ms: Date.now() - start,
|
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 { 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 {
|
export class TesterAgent extends BaseAgent {
|
||||||
readonly name = "tester";
|
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";
|
readonly workflow = "test";
|
||||||
|
|
||||||
async execute(context: AgentContext): Promise<AgentResult> {
|
async execute(context: AgentContext): Promise<AgentResult> {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
this.log("Running automated tests...");
|
this.log("Running automated tests...");
|
||||||
|
|
||||||
if (context.backend) {
|
if (context.backend) {
|
||||||
const result = await this.executeViaBackend(
|
const result = await this.executeViaBackend(
|
||||||
context,
|
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 };
|
return { ...result, duration_ms: Date.now() - start };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result = await this.runMechanicalTests(context);
|
||||||
|
const output = JSON.stringify(result, null, 2);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: result.success,
|
||||||
output: "Testing requires an intelligence backend.",
|
output,
|
||||||
artifacts_created: [],
|
artifacts_created: [],
|
||||||
decisions: 0,
|
decisions: 0,
|
||||||
escalations: 0,
|
escalations: result.overallPassed ? 0 : 1,
|
||||||
duration_ms: Date.now() - start,
|
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 { 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 {
|
export class VerifierAgent extends BaseAgent {
|
||||||
readonly name = "verifier";
|
readonly name = "verifier";
|
||||||
@@ -8,21 +26,215 @@ export class VerifierAgent extends BaseAgent {
|
|||||||
async execute(context: AgentContext): Promise<AgentResult> {
|
async execute(context: AgentContext): Promise<AgentResult> {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
this.log("Verifying phase output...");
|
this.log("Verifying phase output...");
|
||||||
|
|
||||||
if (context.backend) {
|
if (context.backend) {
|
||||||
const result = await this.executeViaBackend(
|
const result = await this.executeViaBackend(
|
||||||
context,
|
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 };
|
return { ...result, duration_ms: Date.now() - start };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result = await this.runMechanicalVerification(context);
|
||||||
|
const output = JSON.stringify(result, null, 2);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: result.success,
|
||||||
output: "Verification requires an intelligence backend. Configure one with: ci init --backend",
|
output,
|
||||||
artifacts_created: [],
|
artifacts_created: [],
|
||||||
decisions: 0,
|
decisions: 0,
|
||||||
escalations: 0,
|
escalations: result.success ? 0 : 1,
|
||||||
duration_ms: Date.now() - start,
|
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 { AgentName, ModelProfile } from "../types/config.js";
|
||||||
import { Decision } from "../types/decisions.js";
|
import { Decision } from "../types/decisions.js";
|
||||||
import { Escalation } from "../types/escalation.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 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 {
|
export abstract class OllamaBaseBackend implements IntelligenceBackend {
|
||||||
abstract readonly name: string;
|
abstract readonly name: string;
|
||||||
readonly type: BackendType = "llm";
|
readonly type: BackendType = "llm";
|
||||||
|
|
||||||
protected config: LLMBackendConfig;
|
protected config: LLMBackendConfig;
|
||||||
protected projectPath: string;
|
protected projectPath: string;
|
||||||
|
protected filteredToolSchema: Array<Record<string, unknown>> | null = null;
|
||||||
|
|
||||||
constructor(config: LLMBackendConfig | undefined) {
|
constructor(config: LLMBackendConfig | undefined) {
|
||||||
this.config = config || { base_url: "http://localhost:11434", model_profile: "balanced" };
|
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 model = this.resolveModel();
|
||||||
|
|
||||||
const toolRegistry = new ToolRegistry(request.context.project_path);
|
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[] = [];
|
const messages: OllamaMessage[] = [];
|
||||||
messages.push({
|
messages.push({
|
||||||
@@ -62,7 +75,7 @@ export abstract class OllamaBaseBackend implements IntelligenceBackend {
|
|||||||
|
|
||||||
while (round < MAX_TOOL_ROUNDS) {
|
while (round < MAX_TOOL_ROUNDS) {
|
||||||
round++;
|
round++;
|
||||||
const response = await this.callModel(messages, model, toolRegistry);
|
const response = await this.callModelWithTools(messages, model, filteredDefinitions);
|
||||||
|
|
||||||
totalInputTokens += response.usage?.prompt_tokens || 0;
|
totalInputTokens += response.usage?.prompt_tokens || 0;
|
||||||
totalOutputTokens += response.usage?.completion_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(
|
protected abstract callModel(
|
||||||
messages: OllamaMessage[],
|
messages: OllamaMessage[],
|
||||||
model: string,
|
model: string,
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export class OllamaCloudBackend extends OllamaBaseBackend {
|
|||||||
if (m.tool_calls) msg.tool_calls = m.tool_calls;
|
if (m.tool_calls) msg.tool_calls = m.tool_calls;
|
||||||
return msg;
|
return msg;
|
||||||
}),
|
}),
|
||||||
tools: toolRegistry.getOpenAIToolSchema(),
|
tools: this.getActiveToolSchema(toolRegistry),
|
||||||
stream: false,
|
stream: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export class OllamaLocalBackend extends OllamaBaseBackend {
|
|||||||
if (m.tool_calls) msg.tool_calls = m.tool_calls;
|
if (m.tool_calls) msg.tool_calls = m.tool_calls;
|
||||||
return msg;
|
return msg;
|
||||||
}),
|
}),
|
||||||
tools: toolRegistry.getOpenAIToolSchema(),
|
tools: this.getActiveToolSchema(toolRegistry),
|
||||||
stream: false,
|
stream: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { PipelineState, createInitialPipelineState } from "../types/pipeline.js"
|
|||||||
import { resolveBackend } from "../backends/index.js";
|
import { resolveBackend } from "../backends/index.js";
|
||||||
import { BackendUnavailableError } from "../backends/types.js";
|
import { BackendUnavailableError } from "../backends/types.js";
|
||||||
import { getAgent } from "../agents/index.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 fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { execSync } from "node:child_process";
|
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 {
|
export function createShipCommand(): Command {
|
||||||
return new Command("ship")
|
return new Command("ship")
|
||||||
.description("Auto-complete phase: verify, security, commit, tag")
|
.description("Auto-complete phase: verify, security, commit, tag")
|
||||||
@@ -713,6 +792,35 @@ export function createShipCommand(): Command {
|
|||||||
});
|
});
|
||||||
console.log(` ✓ Tagged: ${version.tag}`);
|
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) {
|
if (config.git.auto_push) {
|
||||||
execSync(`git push origin ${version.tag}`, { cwd: projectPath, stdio: "pipe" });
|
execSync(`git push origin ${version.tag}`, { cwd: projectPath, stdio: "pipe" });
|
||||||
console.log(` ✓ Pushed tag: ${version.tag}`);
|
console.log(` ✓ Pushed tag: ${version.tag}`);
|
||||||
@@ -820,3 +928,19 @@ function resolveMergeTarget(projectPath: string, milestoneType: string): string
|
|||||||
|
|
||||||
return "main";
|
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 { Command } from "commander";
|
||||||
import { VERSION } from "../version.js";
|
import { VERSION } from "../version.js";
|
||||||
|
import { CIAgentFiles } from "../core/ciagent-files.js";
|
||||||
|
import { isCIAgentInitialized } from "../core/config.js";
|
||||||
import {
|
import {
|
||||||
createInitCommand,
|
createInitCommand,
|
||||||
createRunCommand,
|
createRunCommand,
|
||||||
@@ -14,6 +16,7 @@ import {
|
|||||||
createClarifyCommand,
|
createClarifyCommand,
|
||||||
createRollbackCommand,
|
createRollbackCommand,
|
||||||
createShipCommand,
|
createShipCommand,
|
||||||
|
createProjectsCommand,
|
||||||
} from "./commands.js";
|
} from "./commands.js";
|
||||||
|
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
@@ -22,6 +25,14 @@ program
|
|||||||
.name("ciagent")
|
.name("ciagent")
|
||||||
.description("CIAgent — Continuous Intelligence: autonomous AI-driven software engineering harness")
|
.description("CIAgent — Continuous Intelligence: autonomous AI-driven software engineering harness")
|
||||||
.version(VERSION)
|
.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(createInitCommand())
|
||||||
.addCommand(createRunCommand())
|
.addCommand(createRunCommand())
|
||||||
.addCommand(createQuickCommand())
|
.addCommand(createQuickCommand())
|
||||||
@@ -32,6 +43,7 @@ program
|
|||||||
.addCommand(createAuditCommand())
|
.addCommand(createAuditCommand())
|
||||||
.addCommand(createClarifyCommand())
|
.addCommand(createClarifyCommand())
|
||||||
.addCommand(createRollbackCommand())
|
.addCommand(createRollbackCommand())
|
||||||
.addCommand(createShipCommand());
|
.addCommand(createShipCommand())
|
||||||
|
.addCommand(createProjectsCommand());
|
||||||
|
|
||||||
program.parse();
|
program.parse();
|
||||||
@@ -29,6 +29,7 @@ export class EscalationProtocol {
|
|||||||
private pendingEscalations: Map<string, Escalation>;
|
private pendingEscalations: Map<string, Escalation>;
|
||||||
private timeoutCallback: (escalation: Escalation, chosenOption: string) => void;
|
private timeoutCallback: (escalation: Escalation, chosenOption: string) => void;
|
||||||
private timers: NodeJS.Timeout[];
|
private timers: NodeJS.Timeout[];
|
||||||
|
private timerEscalationMap: Map<NodeJS.Timeout, string>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
config: CIAgentConfig,
|
config: CIAgentConfig,
|
||||||
@@ -43,6 +44,7 @@ export class EscalationProtocol {
|
|||||||
this.pendingEscalations = new Map();
|
this.pendingEscalations = new Map();
|
||||||
this.timeoutCallback = timeoutCallback;
|
this.timeoutCallback = timeoutCallback;
|
||||||
this.timers = [];
|
this.timers = [];
|
||||||
|
this.timerEscalationMap = new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
setMilestone(milestone: string): void {
|
setMilestone(milestone: string): void {
|
||||||
@@ -102,6 +104,16 @@ export class EscalationProtocol {
|
|||||||
const escalation = this.pendingEscalations.get(escalationId);
|
const escalation = this.pendingEscalations.get(escalationId);
|
||||||
if (!escalation) return null;
|
if (!escalation) return null;
|
||||||
|
|
||||||
|
for (let i = this.timers.length - 1; i >= 0; i--) {
|
||||||
|
const timer = this.timers[i];
|
||||||
|
const mappedId = this.timerEscalationMap.get(timer);
|
||||||
|
if (mappedId === escalationId) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
this.timerEscalationMap.delete(timer);
|
||||||
|
this.timers.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
escalation.resolution = resolution;
|
escalation.resolution = resolution;
|
||||||
escalation.resolved_at = new Date().toISOString();
|
escalation.resolved_at = new Date().toISOString();
|
||||||
escalation.resolution_detail = `Chose option: ${chosenOptionId}`;
|
escalation.resolution_detail = `Chose option: ${chosenOptionId}`;
|
||||||
@@ -139,11 +151,16 @@ export class EscalationProtocol {
|
|||||||
clearAllTimers(): void {
|
clearAllTimers(): void {
|
||||||
for (const timer of this.timers) {
|
for (const timer of this.timers) {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
|
this.timerEscalationMap.delete(timer);
|
||||||
}
|
}
|
||||||
this.timers = [];
|
this.timers = [];
|
||||||
this.pendingEscalations.clear();
|
this.pendingEscalations.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.clearAllTimers();
|
||||||
|
}
|
||||||
|
|
||||||
formatEscalation(escalation: Escalation): string {
|
formatEscalation(escalation: Escalation): string {
|
||||||
const lines: string[] = [
|
const lines: string[] = [
|
||||||
`⚠️ ESCALATION [${escalation.id}]`,
|
`⚠️ ESCALATION [${escalation.id}]`,
|
||||||
@@ -200,9 +217,13 @@ export class EscalationProtocol {
|
|||||||
escalation.resolved_at = new Date().toISOString();
|
escalation.resolved_at = new Date().toISOString();
|
||||||
escalation.resolution_detail = `Auto-proceeded with default: ${escalation.default_option_id}`;
|
escalation.resolution_detail = `Auto-proceeded with default: ${escalation.default_option_id}`;
|
||||||
this.pendingEscalations.delete(escalation.id);
|
this.pendingEscalations.delete(escalation.id);
|
||||||
|
this.timerEscalationMap.delete(timer);
|
||||||
|
const idx = this.timers.indexOf(timer);
|
||||||
|
if (idx >= 0) this.timers.splice(idx, 1);
|
||||||
this.timeoutCallback(escalation, escalation.default_option_id);
|
this.timeoutCallback(escalation, escalation.default_option_id);
|
||||||
}
|
}
|
||||||
}, timeout);
|
}, timeout);
|
||||||
this.timers.push(timer);
|
this.timers.push(timer);
|
||||||
|
this.timerEscalationMap.set(timer, escalation.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 { GitBranch } from "./git-branch.js";
|
||||||
export { CommitBuilder } from "./commit-builder.js";
|
export { CommitBuilder } from "./commit-builder.js";
|
||||||
export { extractCIAgentBlock, parseCIAgentBlock, parseCommitMessage } from "./commit-parser.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 type { CIAgentConfig } from "../types/config.js";
|
||||||
export { DEFAULT_CIAGENT_CONFIG } 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 { GitBranch } from "./core/git-branch.js";
|
||||||
export { CommitBuilder } from "./core/commit-builder.js";
|
export { CommitBuilder } from "./core/commit-builder.js";
|
||||||
export { extractCIAgentBlock, parseCIAgentBlock, parseCommitMessage } from "./core/commit-parser.js";
|
export { extractCIAgentBlock, parseCIAgentBlock, parseCommitMessage } from "./core/commit-parser.js";
|
||||||
|
export { GiteaClient, generateReleaseNotes } from "./core/gitea.js";
|
||||||
export { VerificationPipeline } from "./verification/index.js";
|
export { VerificationPipeline } from "./verification/index.js";
|
||||||
export { StructuralVerification } from "./verification/structural.js";
|
export { StructuralVerification } from "./verification/structural.js";
|
||||||
export { BehavioralVerification } from "./verification/behavioral.js";
|
export { BehavioralVerification } from "./verification/behavioral.js";
|
||||||
export { SecurityVerification } from "./verification/security.js";
|
export { SecurityVerification } from "./verification/security.js";
|
||||||
export { QualityVerification } from "./verification/quality.js";
|
export { QualityVerification } from "./verification/quality.js";
|
||||||
export { getAgent, getAvailableAgents } from "./agents/index.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 { initCIAgent, loadConfig, saveConfig, isCIAgentInitialized } from "./core/config.js";
|
||||||
export { DEFAULT_CIAGENT_CONFIG } from "./types/config.js";
|
export { DEFAULT_CIAGENT_CONFIG } from "./types/config.js";
|
||||||
export { confidenceToLevel, shouldEscalate } from "./types/decisions.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 { OllamaCloudBackend } from "./backends/ollama-cloud.js";
|
||||||
export { ToolRegistry } from "./backends/tool-registry.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 { Decision, DecisionCategory } from "./types/decisions.js";
|
||||||
export type { Escalation, EscalationType } from "./types/escalation.js";
|
export type { Escalation, EscalationType } from "./types/escalation.js";
|
||||||
export type { PipelineState, PhaseResult, OrchestratorResult } from "./types/pipeline.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 { ProjectState, BranchInfo } from "./core/git-context.js";
|
||||||
export type { PhaseBranchInfo, MilestoneBranchInfo, BranchCreateResult, BranchMergeResult } from "./core/git-branch.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 { 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 { IntelligenceBackend, BackendRequest, BackendResult, BackendConfigSection, BackendUnavailableError, Artifact, TokenUsage } from "./backends/types.js";
|
||||||
export type { ToolDefinition, ToolCall, ToolResult } from "./backends/tool-registry.js";
|
export type { ToolDefinition, ToolCall, ToolResult } from "./backends/tool-registry.js";
|
||||||
@@ -66,6 +66,13 @@ export interface GitConfig {
|
|||||||
auto_push: boolean;
|
auto_push: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GiteaConfig {
|
||||||
|
base_url: string;
|
||||||
|
api_token_env: string;
|
||||||
|
owner: string;
|
||||||
|
repo: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProjectEntry {
|
export interface ProjectEntry {
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -82,6 +89,7 @@ export interface CIAgentConfig {
|
|||||||
security: SecurityConfig;
|
security: SecurityConfig;
|
||||||
git: GitConfig;
|
git: GitConfig;
|
||||||
backend: BackendConfigSection;
|
backend: BackendConfigSection;
|
||||||
|
gitea?: GiteaConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = {
|
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