Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bbabd2dc0a | |||
| 99df4fe4e2 | |||
| 8527df24b3 |
+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";
|
||||||
|
|||||||
+238
-38
@@ -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,6 +42,7 @@ 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",
|
||||||
@@ -79,47 +81,66 @@ 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);
|
||||||
|
|
||||||
for (const stage of STAGE_ORDER) {
|
while (this.pipelineState.current_phase <= this.totalPhases) {
|
||||||
this.log(`Entering stage: ${stage}`);
|
this.log(`Processing phase ${this.pipelineState.current_phase} of ${this.totalPhases}`);
|
||||||
this.pipelineState.current_stage = stage;
|
|
||||||
this.pipelineState.last_updated = new Date().toISOString();
|
|
||||||
|
|
||||||
const result = await this.executeStage(stage, context);
|
for (const stage of STAGE_ORDER) {
|
||||||
|
this.log(`Entering stage: ${stage}`);
|
||||||
|
this.pipelineState.current_stage = stage;
|
||||||
|
this.pipelineState.last_updated = new Date().toISOString();
|
||||||
|
|
||||||
if (!result.success && stage !== "complete") {
|
const result = await this.executeStageWithRecovery(stage, context);
|
||||||
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") {
|
this.phaseResults.push(result);
|
||||||
return {
|
this.recordPhaseResult(result);
|
||||||
success: false,
|
|
||||||
output: `Pipeline failed at ${stage}: ${result.error}`,
|
if (!result.success && stage !== "complete") {
|
||||||
artifacts_created: this.phaseResults.reduce(
|
this.pipelineState.errors.push({
|
||||||
(acc, r) => acc + r.artifacts_created.length,
|
stage,
|
||||||
0
|
phase: this.pipelineState.current_phase,
|
||||||
),
|
message: result.error || "Stage failed",
|
||||||
decisions: this.phaseResults.reduce(
|
timestamp: new Date().toISOString(),
|
||||||
(acc, r) => acc + r.decisions_made,
|
retry_count: 0,
|
||||||
0
|
resolved: false,
|
||||||
),
|
});
|
||||||
escalations: this.phaseResults.reduce(
|
|
||||||
(acc, r) => acc + r.escalations_raised,
|
if (stage === "specify" || stage === "clarify") {
|
||||||
0
|
return {
|
||||||
),
|
success: false,
|
||||||
duration_ms: Date.now() - startTime,
|
output: `Pipeline failed at ${stage}: ${result.error}`,
|
||||||
error: 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 totalDuration = Date.now() - startTime;
|
||||||
@@ -152,9 +173,159 @@ 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
|
||||||
@@ -166,7 +337,8 @@ export class OrchestratorAgent extends BaseAgent {
|
|||||||
this.log(`Delegating ${stage} to ${agentName} agent via backend...`);
|
this.log(`Delegating ${stage} to ${agentName} agent via backend...`);
|
||||||
try {
|
try {
|
||||||
const agent = getAgent(agentName);
|
const agent = getAgent(agentName);
|
||||||
const result = await agent.execute(context);
|
const gitContext = this.buildGitAgentContext(context);
|
||||||
|
const result = await agent.execute(gitContext);
|
||||||
return {
|
return {
|
||||||
phase: this.pipelineState!.current_phase,
|
phase: this.pipelineState!.current_phase,
|
||||||
stage,
|
stage,
|
||||||
@@ -212,7 +384,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 +471,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 +513,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 +575,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 +600,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",
|
||||||
|
|||||||
+317
-9
@@ -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,306 @@ 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 {
|
||||||
|
success: false,
|
||||||
|
output: "Planning requires either .ciagent/REQUIREMENTS.md or .ciagent/ROADMAP.md. Initialize the project first.",
|
||||||
|
artifacts_created: [],
|
||||||
|
decisions: 0,
|
||||||
|
escalations: 0,
|
||||||
|
duration_ms: Date.now() - start,
|
||||||
|
error: "No requirements or roadmap found for mechanical planning",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let gitLogSummary = "";
|
||||||
|
try {
|
||||||
|
gitLogSummary = execSync("git log --max-count=20 --oneline", {
|
||||||
|
cwd: context.project_path,
|
||||||
|
encoding: "utf-8",
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
}).trim();
|
||||||
|
} catch {
|
||||||
|
gitLogSummary = "(no git history available)";
|
||||||
|
}
|
||||||
|
|
||||||
|
const phaseGoal = this.extractPhaseGoal(roadmap, context.phase);
|
||||||
|
const phaseRequirements = this.extractPhaseRequirements(requirements, context.phase);
|
||||||
|
const componentBoundaries = architecture ? architecture.components.map((c) => c.name) : [];
|
||||||
|
|
||||||
|
const plans = this.buildPlans(phaseRequirements, componentBoundaries, context.phase);
|
||||||
|
|
||||||
|
const planFileContent = this.formatPlanFile(context.phase, phaseGoal, plans);
|
||||||
|
|
||||||
|
const planFilePath = path.join(context.project_path, ".ciagent", "PLAN.md");
|
||||||
|
ensureDir(path.dirname(planFilePath));
|
||||||
|
writeFile(planFilePath, planFileContent);
|
||||||
|
|
||||||
|
const decisionCount = plans.length > 0 ? 1 : 0;
|
||||||
|
|
||||||
|
if (this.shouldCommit(context)) {
|
||||||
|
try {
|
||||||
|
const commitMessage = CommitBuilder.buildTaskCommit({
|
||||||
|
type: "docs",
|
||||||
|
phase: context.phase,
|
||||||
|
milestone: "v1.0",
|
||||||
|
plan: "01",
|
||||||
|
task: "01-01",
|
||||||
|
subject: `create ${plans.length} phase plans`,
|
||||||
|
status: "plan",
|
||||||
|
decisions: decisionCount > 0 ? [{
|
||||||
|
id: "D-001",
|
||||||
|
decision: `Decomposed phase ${context.phase} into ${plans.length} vertical-slice plans`,
|
||||||
|
rationale: "Requirements grouped by dependency analysis — independent requirements in wave 1, dependent in wave 2+",
|
||||||
|
confidence: 0.75,
|
||||||
|
alternatives: ["single monolithic plan", "per-requirement plans"],
|
||||||
|
}] : undefined,
|
||||||
|
});
|
||||||
|
execSync(`git add -A && git commit -m "${commitMessage.replace(/"/g, '\\"')}" --allow-empty`, {
|
||||||
|
cwd: context.project_path,
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
this.warn("Plan commit failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const waves = this.groupPlansByWave(plans);
|
||||||
|
const plannerResult: PlannerResult = {
|
||||||
|
success: true,
|
||||||
|
planCount: plans.length,
|
||||||
|
waves,
|
||||||
|
decisions: decisionCount,
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: true,
|
||||||
output: "Planning requires an intelligence backend. Configure one with: ci init --backend",
|
output: `Created ${plans.length} plan(s) across ${waves.length} wave(s) for phase ${context.phase}`,
|
||||||
artifacts_created: [],
|
artifacts_created: [".ciagent/PLAN.md"],
|
||||||
decisions: 0,
|
decisions: decisionCount,
|
||||||
escalations: 0,
|
escalations: 0,
|
||||||
duration_ms: Date.now() - start,
|
duration_ms: Date.now() - start,
|
||||||
error: "No intelligence backend available",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extractPhaseGoal(roadmap: RoadmapMd | null, phase: number): string {
|
||||||
|
if (!roadmap) return "No roadmap available";
|
||||||
|
const phaseEntry = roadmap.phases.find((p) => p.number === phase);
|
||||||
|
if (phaseEntry) return `${phaseEntry.name}: ${phaseEntry.description}`;
|
||||||
|
return `Phase ${phase} (no roadmap entry)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractPhaseRequirements(requirements: RequirementsMd | null, phase: number): Array<{ id: string; description: string; phase: number; status: string }> {
|
||||||
|
if (!requirements) return [];
|
||||||
|
return requirements.traceability
|
||||||
|
.filter((t) => t.phase === phase)
|
||||||
|
.map((t) => {
|
||||||
|
let description = t.requirement;
|
||||||
|
for (const cat of [...requirements.v1, ...requirements.v2]) {
|
||||||
|
const item = cat.items.find((i) => i.id === t.requirement);
|
||||||
|
if (item) {
|
||||||
|
description = `${t.requirement}: ${item.description}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { id: t.requirement, description, phase: t.phase, status: t.status };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildPlans(
|
||||||
|
phaseRequirements: Array<{ id: string; description: string; phase: number; status: string }>,
|
||||||
|
componentBoundaries: string[],
|
||||||
|
phase: number
|
||||||
|
): PlanEntry[] {
|
||||||
|
if (phaseRequirements.length === 0) {
|
||||||
|
return [{
|
||||||
|
name: `Phase ${phase} Core Implementation`,
|
||||||
|
wave: 1,
|
||||||
|
requirements: [],
|
||||||
|
dependsOn: [],
|
||||||
|
tasks: [`Implement phase ${phase} deliverables as specified in ROADMAP.md`],
|
||||||
|
mustHaves: [`Phase ${phase} deliverables exist and pass verification`],
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
const independentReqs = phaseRequirements.filter((r) => r.status !== "blocked");
|
||||||
|
const blockedReqs = phaseRequirements.filter((r) => r.status === "blocked");
|
||||||
|
|
||||||
|
const plans: PlanEntry[] = [];
|
||||||
|
|
||||||
|
if (independentReqs.length > 0) {
|
||||||
|
const taskChunks = this.chunkByComponent(independentReqs, componentBoundaries);
|
||||||
|
for (const chunk of taskChunks) {
|
||||||
|
plans.push({
|
||||||
|
name: this.inferPlanName(chunk, phase),
|
||||||
|
wave: 1,
|
||||||
|
requirements: chunk.map((r) => r.id),
|
||||||
|
dependsOn: [],
|
||||||
|
tasks: chunk.map((r) => `Implement ${r.id}: ${r.description.split(": ").slice(1).join(": ") || r.description}`),
|
||||||
|
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) => `Implement ${r.id}: ${r.description.split(": ").slice(1).join(": ") || r.description}`),
|
||||||
|
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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,8 @@ 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";
|
||||||
|
|||||||
Reference in New Issue
Block a user