8c975352b8
---ci---
phase: 1-5
milestone: v0.11
project: ci
status: execute
decisions:
- id: D-092
decision: Independent sessions via AgentSession (not shared state)
rationale: Aligns with git-native model; sessions communicate through commits and .ciagent/ files
confidence: 0.90
- id: D-093
decision: Personas as runtime configs (not new Agent classes)
rationale: Less code, more flexible. Persona md files define domain knowledge and framework opinions.
confidence: 0.88
- id: D-094
decision: Lead developer as task decomposer (not separate pipeline stage)
rationale: EXECUTE stays one stage. Lead decomposes before execution, each persona group runs.
confidence: 0.85
- id: D-095
decision: File-based git locking (not DB or IPC)
rationale: Git-native. .session-lock files are simple JSON with session ID, timestamp, project slug.
confidence: 0.87
- id: D-096
decision: Territory enforcement with warn/strict modes
rationale: Warn for teams learning boundaries. Strict for mature projects. Configurable per-project.
confidence: 0.82
- id: D-097
decision: Task decomposition by file patterns + requirement IDs
rationale: File patterns are deterministic; no LLM needed. Requirement IDs in PLAN.md already map to domains.
confidence: 0.88
requirements:
covered: [SESSION-01, SESSION-02, SESSION-03, SESSION-04, SESSION-05, PERSONA-01, PERSONA-02, PERSONA-03, PERSONA-04, PERSONA-05, PERSONA-06, PERSONA-07, PERSONA-08, PERSONA-09, PERSONA-10, PERSONA-11, CLI-01, CLI-02, CLI-03, CLI-04, INTEG-01, INTEG-02, INTEG-03, INTEG-04, INTEG-05]
---/ci---
985 lines
37 KiB
TypeScript
985 lines
37 KiB
TypeScript
import { BaseAgent, AgentContext, AgentResult } from "./base.js";
|
|
import { DecisionEngine } from "../core/decision-engine.js";
|
|
import { ClarifyPhase } from "../core/clarify.js";
|
|
import { EscalationProtocol, EscalationInput } from "../core/escalation.js";
|
|
import { GitContext, ProjectState } from "../core/git-context.js";
|
|
import { GitBranch } from "../core/git-branch.js";
|
|
import { CIAgentFiles } from "../core/ciagent-files.js";
|
|
import { CommitBuilder } from "../core/commit-builder.js";
|
|
import { CIAgentConfig, AgentName } from "../types/config.js";
|
|
import {
|
|
PipelineState,
|
|
PipelineStage,
|
|
PhaseResult,
|
|
OrchestratorResult,
|
|
createInitialPipelineState,
|
|
STAGE_ORDER,
|
|
} from "../types/pipeline.js";
|
|
import { Specification, parseSpecification } from "../types/specification.js";
|
|
import { loadConfig, saveConfig, isCIAgentInitialized, initCIAgent } from "../core/config.js";
|
|
import { getAgent } from "./index.js";
|
|
import { IntelligenceBackend, BackendUnavailableError } from "../backends/types.js";
|
|
import { registerEscalationProtocol } from "../cli/index.js";
|
|
import { SessionManager } from "../core/session-manager.js";
|
|
import { execSync } from "node:child_process";
|
|
|
|
export interface GitAgentContext extends AgentContext {
|
|
gitContext: GitContext;
|
|
gitBranch: GitBranch;
|
|
ciFiles: CIAgentFiles;
|
|
milestone: string;
|
|
}
|
|
|
|
export class OrchestratorAgent extends BaseAgent {
|
|
readonly name: AgentName = "orchestrator";
|
|
readonly description = "Top-level autonomous controller that coordinates the full CIAgent pipeline";
|
|
readonly workflow = "run";
|
|
|
|
private config: CIAgentConfig;
|
|
private pipelineState: PipelineState | null = null;
|
|
private decisionEngine: DecisionEngine | null = null;
|
|
private escalationProtocol: EscalationProtocol | null = null;
|
|
private gitContext: GitContext | null = null;
|
|
private gitBranch: GitBranch | null = null;
|
|
private ciFiles: CIAgentFiles | null = null;
|
|
private currentMilestone: string;
|
|
private phaseResults: PhaseResult[] = [];
|
|
private totalPhases: number = 1;
|
|
|
|
private static readonly STAGE_AGENT_MAP: Partial<Record<PipelineStage, AgentName[]>> = {
|
|
research: ["researcher"],
|
|
ideate: ["ideation-agent"],
|
|
plan: ["planner"],
|
|
execute: ["executor", "code-reviewer", "security-auditor"],
|
|
test: ["tester"],
|
|
verify: ["verifier"],
|
|
complete: ["doc-writer"],
|
|
};
|
|
|
|
constructor(config?: CIAgentConfig) {
|
|
super();
|
|
this.config = config || loadConfig(process.cwd());
|
|
this.currentMilestone = "v1.0";
|
|
}
|
|
|
|
async execute(context: AgentContext): Promise<AgentResult> {
|
|
const startTime = Date.now();
|
|
this.log("Starting CIAgent Orchestrator pipeline (git-native)");
|
|
|
|
try {
|
|
this.config = loadConfig(context.project_path);
|
|
|
|
const projectSlug = context.project_slug || "";
|
|
this.gitContext = new GitContext(context.project_path);
|
|
this.gitBranch = new GitBranch(context.project_path);
|
|
this.ciFiles = new CIAgentFiles(context.project_path, projectSlug || undefined);
|
|
this.ciFiles.ensureCIDir();
|
|
|
|
const projectState = this.gitContext.reconstructState();
|
|
this.currentMilestone = projectState.currentMilestone || "v1.0";
|
|
|
|
this.log(`Reconstructed state: phase=${projectState.currentPhase}, milestone=${projectState.currentMilestone}, stage=${projectState.currentStage}`);
|
|
|
|
this.pipelineState = createInitialPipelineState(context.project_path);
|
|
if (projectState.currentPhase > 0) {
|
|
this.pipelineState.current_phase = projectState.currentPhase;
|
|
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.escalationProtocol = new EscalationProtocol(this.config, context.project_path, this.currentMilestone);
|
|
registerEscalationProtocol(this.escalationProtocol);
|
|
|
|
while (this.pipelineState.current_phase <= this.totalPhases) {
|
|
this.log(`Processing phase ${this.pipelineState.current_phase} of ${this.totalPhases}`);
|
|
|
|
for (const stage of STAGE_ORDER) {
|
|
this.log(`Entering stage: ${stage}`);
|
|
this.pipelineState.current_stage = stage;
|
|
this.pipelineState.last_updated = new Date().toISOString();
|
|
|
|
const result = await this.executeStageWithRecovery(stage, context);
|
|
|
|
this.phaseResults.push(result);
|
|
this.recordPhaseResult(result);
|
|
|
|
if (!result.success && stage !== "complete") {
|
|
this.pipelineState.errors.push({
|
|
stage,
|
|
phase: this.pipelineState.current_phase,
|
|
message: result.error || "Stage failed",
|
|
timestamp: new Date().toISOString(),
|
|
retry_count: 0,
|
|
resolved: false,
|
|
});
|
|
|
|
if (stage === "specify" || stage === "clarify") {
|
|
return {
|
|
success: false,
|
|
output: `Pipeline failed at ${stage}: ${result.error}`,
|
|
artifacts_created: this.phaseResults.reduce(
|
|
(acc, r) => acc + r.artifacts_created.length,
|
|
0
|
|
),
|
|
decisions: this.phaseResults.reduce(
|
|
(acc, r) => acc + r.decisions_made,
|
|
0
|
|
),
|
|
escalations: this.phaseResults.reduce(
|
|
(acc, r) => acc + r.escalations_raised,
|
|
0
|
|
),
|
|
duration_ms: Date.now() - startTime,
|
|
error: result.error,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
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 completionReport = this.generateCompletionReport();
|
|
|
|
return {
|
|
success: true,
|
|
output: completionReport,
|
|
artifacts_created: this.phaseResults.reduce(
|
|
(acc, r) => acc + r.artifacts_created.length,
|
|
0
|
|
),
|
|
decisions: this.phaseResults.reduce(
|
|
(acc, r) => acc + r.decisions_made,
|
|
0
|
|
),
|
|
escalations: this.phaseResults.reduce(
|
|
(acc, r) => acc + r.escalations_raised,
|
|
0
|
|
),
|
|
duration_ms: totalDuration,
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
success: false,
|
|
output: `Orchestrator failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
artifacts_created: 0,
|
|
decisions: 0,
|
|
escalations: 0,
|
|
duration_ms: Date.now() - startTime,
|
|
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(
|
|
stage: PipelineStage,
|
|
context: AgentContext
|
|
): Promise<PhaseResult> {
|
|
const stageStart = Date.now();
|
|
const agentNames = OrchestratorAgent.STAGE_AGENT_MAP[stage];
|
|
|
|
if (agentNames && agentNames.length > 0 && context.backend) {
|
|
this.log(`Delegating ${stage} to ${agentNames.join(", ")} agent(s) via backend...`);
|
|
try {
|
|
let primaryResult: AgentResult | null = null;
|
|
const allArtifacts: string[] = [];
|
|
let totalDecisions = 0;
|
|
let totalEscalations = 0;
|
|
let lastError: string | undefined;
|
|
|
|
const primaryAgent = getAgent(agentNames[0]);
|
|
const gitContext = this.buildGitAgentContext(context);
|
|
const primaryAgentResult = await primaryAgent.execute(gitContext);
|
|
primaryResult = primaryAgentResult;
|
|
if (Array.isArray(primaryAgentResult.artifacts_created)) {
|
|
allArtifacts.push(...primaryAgentResult.artifacts_created);
|
|
}
|
|
totalDecisions += primaryAgentResult.decisions;
|
|
totalEscalations += primaryAgentResult.escalations;
|
|
|
|
if (!primaryAgentResult.success) {
|
|
this.warn(`Primary agent ${agentNames[0]} failed for ${stage}`);
|
|
return {
|
|
phase: this.pipelineState!.current_phase,
|
|
stage,
|
|
success: false,
|
|
artifacts_created: allArtifacts,
|
|
decisions_made: totalDecisions,
|
|
escalations_raised: totalEscalations,
|
|
duration_ms: Date.now() - stageStart,
|
|
error: primaryAgentResult.error || `Primary agent ${agentNames[0]} failed`,
|
|
};
|
|
}
|
|
|
|
if (agentNames.length > 1) {
|
|
if (this.config.parallelization?.enabled) {
|
|
const reviewFactories = agentNames.slice(1).map((reviewAgentName) => {
|
|
return () => {
|
|
const agent = getAgent(reviewAgentName);
|
|
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)}`,
|
|
};
|
|
return agent.execute(reviewContext);
|
|
};
|
|
});
|
|
|
|
const settled = await this.limitConcurrency(reviewFactories, this.config.parallelization?.max_concurrent_agents ?? 5);
|
|
for (let si = 0; si < settled.length; si++) {
|
|
const result = settled[si];
|
|
if (result.status === "fulfilled") {
|
|
const agentResult = result.value;
|
|
if (Array.isArray(agentResult.artifacts_created)) allArtifacts.push(...agentResult.artifacts_created);
|
|
totalDecisions += agentResult.decisions;
|
|
totalEscalations += agentResult.escalations;
|
|
if (!agentResult.success) {
|
|
this.warn(`Review agent reported issues: ${agentResult.error || "unspecified"}`);
|
|
lastError = agentResult.error;
|
|
}
|
|
} else {
|
|
this.warn(`Review agent failed: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`);
|
|
}
|
|
}
|
|
} else {
|
|
for (let i = 1; i < agentNames.length; i++) {
|
|
const reviewAgentName = agentNames[i];
|
|
try {
|
|
const reviewAgent = getAgent(reviewAgentName);
|
|
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 reviewAgent.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 ${reviewAgentName} reported issues for ${stage}: ${result.error || "unspecified"}`);
|
|
lastError = result.error;
|
|
}
|
|
} catch (err) {
|
|
this.warn(`Review agent ${reviewAgentName} 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) {
|
|
if (err instanceof BackendUnavailableError) {
|
|
this.warn(`Backend unavailable for ${stage}, falling back to mechanical execution`);
|
|
} else {
|
|
this.warn(`Agents failed for ${stage}: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
let decisionsMade = 0;
|
|
let escalationsRaised = 0;
|
|
const artifactsCreated: string[] = [];
|
|
|
|
switch (stage) {
|
|
case "specify": {
|
|
this.log("Loading specification from git context...");
|
|
let spec: Specification;
|
|
if (context.specification) {
|
|
spec = parseSpecification(context.specification);
|
|
|
|
const initCommit = CommitBuilder.buildInitCommit({
|
|
projectName: spec.objective.slice(0, 30),
|
|
phaseCount: 0,
|
|
milestone: this.currentMilestone,
|
|
project: context.project_slug || undefined,
|
|
specification: spec.raw_content,
|
|
requirements: spec.requirements,
|
|
constraints: spec.constraints,
|
|
outOfScope: spec.out_of_scope,
|
|
});
|
|
|
|
this.log("Init commit prepared with specification in ---ci--- block");
|
|
artifactsCreated.push(".ciagent/config.json");
|
|
|
|
if (this.config.git.auto_commit && this.gitContext!.isGitRepo()) {
|
|
try {
|
|
this.ciFiles!.writeProjectMd({
|
|
name: spec.objective.slice(0, 30),
|
|
coreValue: spec.objective,
|
|
requirements: { validated: [], active: spec.requirements, outOfScope: spec.out_of_scope },
|
|
constraints: spec.constraints.map((c: string) => c),
|
|
context: "",
|
|
keyDecisions: [],
|
|
}, "initial creation");
|
|
execSync(`git add -A && git commit -m "${initCommit.replace(/"/g, '\\"')}"`, {
|
|
cwd: context.project_path,
|
|
stdio: "pipe",
|
|
});
|
|
} catch (err) {
|
|
this.warn(`Specify commit failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
}
|
|
} else {
|
|
const projectMd = this.ciFiles!.readProjectMd();
|
|
if (!projectMd) {
|
|
return {
|
|
phase: 0,
|
|
stage: "specify",
|
|
success: false,
|
|
artifacts_created: [],
|
|
decisions_made: 0,
|
|
escalations_raised: 0,
|
|
duration_ms: Date.now() - stageStart,
|
|
error: "No specification provided and no PROJECT.md found",
|
|
};
|
|
}
|
|
}
|
|
this.pipelineState!.specification_loaded = true;
|
|
break;
|
|
}
|
|
|
|
case "clarify": {
|
|
this.log("Running Clarify phase...");
|
|
const projectMd = this.ciFiles!.readProjectMd();
|
|
if (!projectMd) {
|
|
return {
|
|
phase: 0,
|
|
stage: "clarify",
|
|
success: false,
|
|
artifacts_created: [],
|
|
decisions_made: 0,
|
|
escalations_raised: 0,
|
|
duration_ms: Date.now() - stageStart,
|
|
error: "No PROJECT.md to clarify",
|
|
};
|
|
}
|
|
|
|
if (this.config.autonomy.level === "full") {
|
|
this.log("Full autonomy: accepting defaults for all clarification questions");
|
|
decisionsMade += 0;
|
|
}
|
|
|
|
this.pipelineState!.clarify_completed = true;
|
|
break;
|
|
}
|
|
|
|
case "research": {
|
|
this.log("Researching project domain...");
|
|
this.decisionEngine!.setPhase(this.pipelineState!.current_phase);
|
|
|
|
const archMd = this.ciFiles!.readArchitectureMd();
|
|
if (!archMd) {
|
|
this.log("No ARCHITECTURE.md found — mechanical research cannot proceed without backend");
|
|
return {
|
|
phase: this.pipelineState!.current_phase,
|
|
stage: "research",
|
|
success: false,
|
|
artifacts_created: artifactsCreated,
|
|
decisions_made: decisionsMade,
|
|
escalations_raised: escalationsRaised,
|
|
duration_ms: Date.now() - stageStart,
|
|
error: "Research stage requires intelligence backend or existing ARCHITECTURE.md",
|
|
};
|
|
}
|
|
|
|
if (this.config.git.auto_commit && this.gitContext!.isGitRepo()) {
|
|
const researchCommit = CommitBuilder.buildResearchCommit(
|
|
this.pipelineState!.current_phase,
|
|
this.currentMilestone,
|
|
"initial domain research",
|
|
["Research completed. Key findings in .ciagent/ARCHITECTURE.md and .ciagent/PROJECT.md updates."]
|
|
);
|
|
try {
|
|
execSync(`git add -A && git commit -m "${researchCommit.replace(/"/g, '\\"')}" --allow-empty`, {
|
|
cwd: context.project_path,
|
|
stdio: "pipe",
|
|
});
|
|
} catch (err) {
|
|
this.warn(`Research commit failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
}
|
|
|
|
this.pipelineState!.research_completed = true;
|
|
artifactsCreated.push(".ciagent/ARCHITECTURE.md");
|
|
break;
|
|
}
|
|
|
|
case "ideate": {
|
|
this.log("Running ideation stage...");
|
|
const { IdeationEngine } = await import("../core/ideation.js");
|
|
const ideationEngine = new IdeationEngine(context.project_path, context.project_slug || undefined);
|
|
const ideas = ideationEngine.runMechanical();
|
|
|
|
const ideationConfig = this.config.ideation;
|
|
if (ideationConfig?.categories && ideationConfig.categories.length > 0) {
|
|
const categoryIdeas = ideationEngine.runMechanical(ideationConfig.categories);
|
|
const seenTitles = new Set(ideas.map((i) => i.title));
|
|
for (const idea of categoryIdeas) {
|
|
if (!seenTitles.has(idea.title)) {
|
|
ideas.push(idea);
|
|
seenTitles.add(idea.title);
|
|
}
|
|
}
|
|
}
|
|
|
|
ideas.sort((a, b) => b.confidence - a.confidence);
|
|
|
|
const maxIdeas = ideationConfig?.max_ideas || 20;
|
|
const trimmedIdeas = ideas.slice(0, maxIdeas);
|
|
|
|
if (this.config.git.auto_commit && this.gitContext!.isGitRepo()) {
|
|
const { accepted: savedIdeas, results } = ideationEngine.acceptIdeas(trimmedIdeas);
|
|
const savedCount = results.filter((r) => r.addedToRequirements || r.addedToRoadmap).length;
|
|
|
|
const ideationCommit = CommitBuilder.buildTaskCommit({
|
|
type: "decision",
|
|
phase: this.pipelineState!.current_phase,
|
|
milestone: this.currentMilestone,
|
|
project: context.project_slug || undefined,
|
|
plan: "ideation",
|
|
task: "ideation-results",
|
|
subject: `ideation results — ${trimmedIdeas.length} total, ${savedCount} accepted`,
|
|
status: "ideate",
|
|
decisions: savedIdeas.map((idea) => ({
|
|
id: idea.id,
|
|
decision: idea.title,
|
|
rationale: idea.rationale,
|
|
confidence: idea.confidence,
|
|
alternatives: idea.actions,
|
|
})),
|
|
});
|
|
|
|
try {
|
|
execSync(`git add -A && git commit -m "${ideationCommit.replace(/"/g, '\\"')}" --allow-empty`, {
|
|
cwd: context.project_path,
|
|
stdio: "pipe",
|
|
});
|
|
} catch (err) {
|
|
this.warn(`Ideation commit failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
|
|
artifactsCreated.push(".ciagent/REQUIREMENTS.md", ".ciagent/ROADMAP.md");
|
|
decisionsMade += savedCount;
|
|
}
|
|
|
|
this.pipelineState!.ideate_completed = true;
|
|
this.log(`Ideation stage complete: ${trimmedIdeas.length} ideas generated`);
|
|
break;
|
|
}
|
|
|
|
case "plan":
|
|
this.log("Planning phase execution...");
|
|
|
|
if (this.config.git.branching_strategy === "phase" && this.gitBranch && this.gitContext!.isGitRepo()) {
|
|
this.gitBranch.createPhaseBranch(this.pipelineState!.current_phase, "initial-phase");
|
|
}
|
|
|
|
this.pipelineState!.plan_completed = true;
|
|
break;
|
|
|
|
case "execute":
|
|
this.log("Executing implementation...");
|
|
if (!context.backend) {
|
|
this.log("No backend available — mechanical execution cannot implement code changes");
|
|
return {
|
|
phase: this.pipelineState!.current_phase,
|
|
stage: "execute",
|
|
success: false,
|
|
artifacts_created: artifactsCreated,
|
|
decisions_made: decisionsMade,
|
|
escalations_raised: escalationsRaised,
|
|
duration_ms: Date.now() - stageStart,
|
|
error: "Execute stage requires intelligence backend for code implementation",
|
|
};
|
|
}
|
|
this.pipelineState!.execute_completed = true;
|
|
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": {
|
|
this.log("Running verification...");
|
|
|
|
const { VerificationPipeline } = await import("../verification/index.js");
|
|
const verification = new VerificationPipeline(context.project_path);
|
|
const verifyResult = await verification.run(this.pipelineState!.current_phase || 1);
|
|
|
|
if (!verifyResult.all_passed) {
|
|
return {
|
|
phase: this.pipelineState!.current_phase,
|
|
stage: "verify",
|
|
success: false,
|
|
artifacts_created: artifactsCreated,
|
|
decisions_made: decisionsMade,
|
|
escalations_raised: escalationsRaised,
|
|
duration_ms: Date.now() - stageStart,
|
|
error: `Verification failed: ${verifyResult.escalations_needed.join("; ")}`,
|
|
};
|
|
}
|
|
|
|
this.pipelineState!.verify_completed = true;
|
|
|
|
if (this.config.git.auto_commit && this.gitContext!.isGitRepo()) {
|
|
const verifyCommit = CommitBuilder.buildVerifyCommit({
|
|
phase: this.pipelineState!.current_phase,
|
|
milestone: this.currentMilestone,
|
|
subject: "automated verification passed",
|
|
requirements: { covered: [], partial: [] },
|
|
});
|
|
try {
|
|
execSync(`git add -A && git commit -m "${verifyCommit.replace(/"/g, '\\"')}" --allow-empty`, {
|
|
cwd: context.project_path,
|
|
stdio: "pipe",
|
|
});
|
|
} catch (err) {
|
|
this.warn(`Verify commit failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
case "complete": {
|
|
this.log("Pipeline complete");
|
|
|
|
if (this.config.git.auto_commit && this.gitContext!.isGitRepo()) {
|
|
const completionCommit = CommitBuilder.buildPhaseCompletionCommit({
|
|
phase: this.pipelineState!.current_phase,
|
|
milestone: this.currentMilestone,
|
|
phaseName: "initial-phase",
|
|
tasksCompleted: 0,
|
|
tasksTotal: 0,
|
|
taskNames: [],
|
|
});
|
|
try {
|
|
execSync(`git add -A && git commit -m "${completionCommit.replace(/"/g, '\\"')}" --allow-empty`, {
|
|
cwd: context.project_path,
|
|
stdio: "pipe",
|
|
});
|
|
} catch (err) {
|
|
this.warn(`Completion commit failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
return {
|
|
phase: this.pipelineState!.current_phase,
|
|
stage,
|
|
success: true,
|
|
artifacts_created: artifactsCreated,
|
|
decisions_made: decisionsMade,
|
|
escalations_raised: escalationsRaised,
|
|
duration_ms: Date.now() - stageStart,
|
|
};
|
|
}
|
|
|
|
private async limitConcurrency<T>(
|
|
factories: Array<() => Promise<T>>,
|
|
maxConcurrency: number
|
|
): Promise<PromiseSettledResult<T>[]> {
|
|
if (factories.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
if (maxConcurrency <= 0 || maxConcurrency >= factories.length) {
|
|
return Promise.allSettled(factories.map((f) => f()));
|
|
}
|
|
|
|
const results: Array<PromiseSettledResult<T> | undefined> = new Array(factories.length).fill(undefined);
|
|
let nextIndex = 0;
|
|
|
|
const worker = async () => {
|
|
while (nextIndex < factories.length) {
|
|
const index = nextIndex++;
|
|
try {
|
|
const value = await factories[index]();
|
|
results[index] = { status: "fulfilled", value };
|
|
} catch (reason) {
|
|
results[index] = { status: "rejected", reason };
|
|
}
|
|
}
|
|
};
|
|
|
|
const workers = Array(Math.min(maxConcurrency, factories.length)).fill(null).map(() => worker());
|
|
await Promise.all(workers);
|
|
return results as PromiseSettledResult<T>[];
|
|
}
|
|
|
|
private generateCompletionReport(): string {
|
|
const lines: string[] = [
|
|
"# CIAgent Completion Report",
|
|
"",
|
|
`✓ Pipeline completed successfully (git-native)`,
|
|
"",
|
|
`Duration: ${(this.phaseResults.reduce((a, r) => a + r.duration_ms, 0) / 1000).toFixed(1)}s`,
|
|
`Decisions made: ${this.phaseResults.reduce((a, r) => a + r.decisions_made, 0)}`,
|
|
`Escalations raised: ${this.phaseResults.reduce((a, r) => a + r.escalations_raised, 0)}`,
|
|
"",
|
|
];
|
|
|
|
for (const result of this.phaseResults) {
|
|
const marker = result.success ? "✓" : "✗";
|
|
lines.push(
|
|
`${marker} ${result.stage} (phase ${result.phase}): ${result.duration_ms}ms`
|
|
);
|
|
}
|
|
|
|
lines.push("");
|
|
lines.push("Audit trail available via: git log --grep='decisions:'");
|
|
|
|
return lines.join("\n");
|
|
}
|
|
|
|
async runForProject(projectSlug: string, context: AgentContext): Promise<AgentResult> {
|
|
this.log(`Running pipeline for project: ${projectSlug}`);
|
|
|
|
this.ciFiles = new CIAgentFiles(context.project_path, projectSlug);
|
|
this.ciFiles.ensureCIDir();
|
|
this.ciFiles.setProjectSlug(projectSlug);
|
|
|
|
const projectContext: AgentContext = {
|
|
...context,
|
|
project_path: context.project_path,
|
|
};
|
|
|
|
const result = await this.execute(projectContext);
|
|
|
|
return {
|
|
...result,
|
|
output: result.output ? `[${projectSlug}] ${result.output}` : result.output,
|
|
};
|
|
}
|
|
|
|
async runForAllProjects(context: AgentContext): Promise<Record<string, AgentResult>> {
|
|
const config = loadConfig(context.project_path);
|
|
const ciFiles = new CIAgentFiles(context.project_path);
|
|
const projects = ciFiles.listProjects();
|
|
|
|
const activeProjects: string[] = config.active_projects?.length > 0
|
|
? config.active_projects
|
|
: projects.map((p) => p.slug);
|
|
|
|
if (activeProjects.length === 0) {
|
|
this.log("No active projects found; running for default project");
|
|
const result = await this.execute(context);
|
|
return { default: result };
|
|
}
|
|
|
|
this.log(`Running pipeline for ${activeProjects.length} project(s): ${activeProjects.join(", ")}`);
|
|
|
|
const useSessions = config.sessions?.max_concurrent_sessions !== undefined;
|
|
|
|
if (useSessions) {
|
|
return this.runWithSessionManager(context, activeProjects, config);
|
|
}
|
|
|
|
return this.runWithLegacyParallel(context, activeProjects, config);
|
|
}
|
|
|
|
private async runWithSessionManager(
|
|
context: AgentContext,
|
|
activeProjects: string[],
|
|
config: CIAgentConfig
|
|
): Promise<Record<string, AgentResult>> {
|
|
const sessionManager = new SessionManager(context.project_path, config);
|
|
const parallel = config.parallelization?.enabled && activeProjects.length > 1;
|
|
|
|
const contextFactory = (slug: string): AgentContext => ({
|
|
...context,
|
|
project_slug: slug,
|
|
});
|
|
|
|
return sessionManager.runAllSessions(activeProjects, contextFactory, parallel);
|
|
}
|
|
|
|
private async runWithLegacyParallel(
|
|
context: AgentContext,
|
|
activeProjects: string[],
|
|
config: CIAgentConfig
|
|
): Promise<Record<string, AgentResult>> {
|
|
const results: Record<string, AgentResult> = {};
|
|
const maxConcurrent = config.parallelization?.max_concurrent_projects ?? 3;
|
|
const parallel = config.parallelization?.enabled && activeProjects.length > 1;
|
|
|
|
if (parallel) {
|
|
const limitedConcurrency = Math.min(maxConcurrent, activeProjects.length);
|
|
const batches: string[][] = [];
|
|
for (let i = 0; i < activeProjects.length; i += limitedConcurrency) {
|
|
batches.push(activeProjects.slice(i, i + limitedConcurrency));
|
|
}
|
|
|
|
for (const batch of batches) {
|
|
const batchResults = await Promise.allSettled(
|
|
batch.map(async (slug): Promise<[string, AgentResult]> => {
|
|
const orchestrator = new OrchestratorAgent(config);
|
|
const result = await orchestrator.runForProject(slug, context);
|
|
return [slug, result];
|
|
})
|
|
);
|
|
|
|
for (const settled of batchResults) {
|
|
if (settled.status === "fulfilled") {
|
|
const [slug, result] = settled.value;
|
|
results[slug] = result;
|
|
} else {
|
|
this.warn(`Project pipeline failed: ${settled.reason instanceof Error ? settled.reason.message : String(settled.reason)}`);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
for (const slug of activeProjects) {
|
|
this.log(`Processing project: ${slug}`);
|
|
const orchestrator = new OrchestratorAgent(config);
|
|
orchestrator.ciFiles = new CIAgentFiles(context.project_path, slug);
|
|
orchestrator.ciFiles.ensureCIDir();
|
|
orchestrator.ciFiles.setProjectSlug(slug);
|
|
|
|
try {
|
|
const result = await orchestrator.runForProject(slug, context);
|
|
results[slug] = result;
|
|
} catch (err) {
|
|
this.warn(`Failed for project ${slug}: ${err instanceof Error ? err.message : String(err)}`);
|
|
results[slug] = {
|
|
success: false,
|
|
output: `Pipeline failed for project ${slug}`,
|
|
artifacts_created: 0,
|
|
decisions: 0,
|
|
escalations: 0,
|
|
duration_ms: 0,
|
|
error: err instanceof Error ? err.message : String(err),
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
} |