Compare commits

...

3 Commits

Author SHA1 Message Date
Jon Chery bbabd2dc0a feat(P03): core agent flesh — VerifierAgent, ResearcherAgent, TesterAgent intrinsic logic 2026-05-29 18:08:38 +00:00
Jon Chery 99df4fe4e2 feat(P02): orchestrator enrichment — GitAgentContext, multi-phase, error recovery, timer cleanup, TEST stage
---ci---
phase: 2
milestone: v0.6
status: execute
decisions:
  - id: D-001
    decision: Pass GitAgentContext to agents instead of bare AgentContext
    rationale: Agents need git-native context (gitContext, gitBranch, ciFiles, milestone) to operate autonomously
    confidence: 0.95
  - id: D-002
    decision: Implement multi-phase iteration with totalPhases derived from ROADMAP.md
    rationale: Milestones can span multiple phases; orchestrator must advance through all of them
    confidence: 0.90
  - id: D-003
    decision: Add executeStageWithRecovery with retry + plan revision + escalation
    rationale: Robust error recovery requires multiple fallback levels before giving up
    confidence: 0.85
  - id: D-004
    decision: Add timer-to-escalation mapping in EscalationProtocol for proper cleanup
    rationale: resolveEscalation must clearTimeout for the corresponding timer to prevent resource leaks
    confidence: 0.90
  - id: D-005
    decision: Add dispose() to EscalationProtocol called in orchestrator finally block
    rationale: Ensures all timers are cleaned up on orchestrator exit regardless of outcome
    confidence: 0.95
  - id: D-006
    decision: Add mechanical TEST stage fallback running npm test via execSync
    rationale: When no backend is available, tests can still be run mechanically
    confidence: 0.85
---/ci---
2026-05-29 18:05:36 +00:00
Jon Chery 8527df24b3 fix(P01): rename ci-files.test.ts → ciagent-files.test.ts
---ci---
project: ci
phase: 1
milestone: v0.7
status: execute
requirements:
  covered: [RENAME-01, RENAME-02, RENAME-03, RENAME-04, RENAME-05, RENAME-06, RENAME-07, RENAME-08, RENAME-09, RENAME-10, RENAME-11, RENAME-12]
---/ci---

All 12 RENAME requirements covered. 31 test suites, 370 tests passing.
2026-05-29 18:03:31 +00:00
13 changed files with 1438 additions and 80 deletions
+163 -7
View File
@@ -1,4 +1,21 @@
import { BaseAgent, AgentContext, AgentResult } from "./base.js";
import { execSync } from "node:child_process";
import * as fs from "node:fs";
import * as path from "node:path";
export interface ExecutorResult {
success: boolean;
tasksExecuted: number;
tasksCommitted: number;
testsPassing: boolean;
mustHavesChecked: { name: string; passed: boolean }[];
error?: string;
}
interface MustHaveItem {
name: string;
passed: boolean;
}
export class ExecutorAgent extends BaseAgent {
readonly name = "executor";
@@ -8,21 +25,160 @@ export class ExecutorAgent extends BaseAgent {
async execute(context: AgentContext): Promise<AgentResult> {
const start = Date.now();
this.log("Executing tasks...");
if (context.backend) {
const result = await this.executeViaBackend(
context,
`Execute implementation for stage ${context.stage}, phase ${context.phase}. Specification: ${context.specification}`
);
return { ...result, duration_ms: Date.now() - start };
const taskPrompt = await this.buildBackendTaskPrompt(context);
const backendResult = await this.executeViaBackend(context, taskPrompt);
const verification = await this.verifyExecution(context);
return {
...backendResult,
output: `${backendResult.output}\nVerification: tests=${verification.testsPassing ? "passing" : "failing"}, must-haves checked=${verification.mustHavesChecked.length}`,
duration_ms: Date.now() - start,
};
}
return {
success: false,
output: "Execution requires an intelligence backend. Configure one with: ci init --backend",
output: "Executor requires intelligence backend for code implementation",
artifacts_created: [],
decisions: 0,
escalations: 0,
duration_ms: Date.now() - start,
error: "No intelligence backend available",
error: "Executor requires intelligence backend for code implementation",
};
}
private async buildBackendTaskPrompt(context: AgentContext): Promise<string> {
const parts: string[] = [
`Execute implementation for stage ${context.stage}, phase ${context.phase}.`,
"",
"## Specification",
context.specification || "No specification provided",
];
const planContent = this.readPlanFile(context);
if (planContent) {
parts.push("", "## Plan", planContent);
}
const ciDir = path.join(context.project_path, ".ciagent");
const roadmapPath = path.join(ciDir, "ROADMAP.md");
const archPath = path.join(ciDir, "ARCHITECTURE.md");
if (fs.existsSync(roadmapPath)) {
try {
const roadmap = fs.readFileSync(roadmapPath, "utf-8");
parts.push("", "## Roadmap Context", roadmap.slice(0, 2000));
} catch {}
}
if (fs.existsSync(archPath)) {
try {
const arch = fs.readFileSync(archPath, "utf-8");
parts.push("", "## Architecture Boundaries", arch.slice(0, 2000));
} catch {}
}
parts.push("", "## Execution Rules");
parts.push("- Execute one task at a time");
parts.push("- Commit after each task with ---ci--- block");
parts.push("- Never pause for checkpoints");
parts.push("- Create automated verification for traditionally human tasks");
return parts.join("\n");
}
private readPlanFile(context: AgentContext): string | null {
const planPath = path.join(context.project_path, ".ciagent", "PLAN.md");
try {
if (fs.existsSync(planPath)) {
return fs.readFileSync(planPath, "utf-8");
}
} catch {}
return null;
}
private async verifyExecution(context: AgentContext): Promise<ExecutorResult> {
const mustHavesChecked: MustHaveItem[] = this.checkMustHaves(context);
let testsPassing = false;
let tasksExecuted = 0;
let tasksCommitted = 0;
try {
const logOutput = execSync("git log --max-count=20 --oneline", {
cwd: context.project_path,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
}).trim();
const commitLines = logOutput.split("\n").filter(Boolean);
tasksCommitted = commitLines.filter((l) => /feat|fix|test/.test(l)).length;
tasksExecuted = tasksCommitted;
} catch {}
try {
execSync("npm test", {
cwd: context.project_path,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
timeout: 120000,
});
testsPassing = true;
} catch {
testsPassing = false;
}
return {
success: mustHavesChecked.every((m) => m.passed) && testsPassing,
tasksExecuted,
tasksCommitted,
testsPassing,
mustHavesChecked,
};
}
private checkMustHaves(context: AgentContext): MustHaveItem[] {
const planPath = path.join(context.project_path, ".ciagent", "PLAN.md");
const results: MustHaveItem[] = [];
try {
if (!fs.existsSync(planPath)) return results;
const planContent = fs.readFileSync(planPath, "utf-8");
const mustHaveRegex = /-\s*\[x\]\s*(.+)/g;
let match;
while ((match = mustHaveRegex.exec(planContent)) !== null) {
const name = match[1].trim();
const passed = this.verifyMustHaveItem(name, context);
results.push({ name, passed });
}
} catch {}
return results;
}
private verifyMustHaveItem(item: string, context: AgentContext): boolean {
const fileMatch = item.match(/(?:exists|created?|present).*?[\s:]+([^\s]+\.(ts|js|json|md))/i);
if (fileMatch) {
const filePath = path.join(context.project_path, fileMatch[1]);
return fs.existsSync(filePath);
}
const testMatch = item.match(/(?:test|tests?)\s+(?:pass|passing)/i);
if (testMatch) {
try {
execSync("npm test", {
cwd: context.project_path,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
timeout: 120000,
});
return true;
} catch {
return false;
}
}
return true;
}
}
+5 -5
View File
@@ -1,9 +1,9 @@
export { BaseAgent, AgentContext, AgentResult, backendResultToAgentResult } from "./base.js";
export { OrchestratorAgent } from "./orchestrator.js";
export { PlannerAgent } from "./planner.js";
export { ExecutorAgent } from "./executor.js";
export { VerifierAgent } from "./verifier.js";
export { ResearcherAgent } from "./researcher.js";
export { PlannerAgent, PlannerResult } from "./planner.js";
export { ExecutorAgent, ExecutorResult } from "./executor.js";
export { VerifierAgent, VerifierResult } from "./verifier.js";
export { ResearcherAgent, ResearcherResult } from "./researcher.js";
export { ChallengerAgent } from "./challenger.js";
export { SecurityAuditorAgent } from "./security-auditor.js";
export { DebuggerAgent } from "./debugger.js";
@@ -17,7 +17,7 @@ export { ProjectResearcherAgent } from "./project-researcher.js";
export { ResearchSynthesizerAgent } from "./research-synthesizer.js";
export { SolutionWriterAgent } from "./solution-writer.js";
export { PhaseResearcherAgent } from "./phase-researcher.js";
export { TesterAgent } from "./tester.js";
export { TesterAgent, TesterResult } from "./tester.js";
import { AgentName } from "../types/config.js";
import { BaseAgent as BaseAgentType } from "./base.js";
+238 -38
View File
@@ -19,6 +19,7 @@ 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 { execSync } from "node:child_process";
export interface GitAgentContext extends AgentContext {
gitContext: GitContext;
@@ -41,6 +42,7 @@ export class OrchestratorAgent extends BaseAgent {
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",
@@ -79,47 +81,66 @@ export class OrchestratorAgent extends BaseAgent {
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);
for (const stage of STAGE_ORDER) {
this.log(`Entering stage: ${stage}`);
this.pipelineState.current_stage = stage;
this.pipelineState.last_updated = new Date().toISOString();
while (this.pipelineState.current_phase <= this.totalPhases) {
this.log(`Processing phase ${this.pipelineState.current_phase} of ${this.totalPhases}`);
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") {
this.pipelineState.errors.push({
stage,
phase: this.pipelineState.current_phase,
message: result.error || "Stage failed",
timestamp: new Date().toISOString(),
retry_count: 0,
resolved: false,
});
const result = await this.executeStageWithRecovery(stage, context);
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,
};
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;
@@ -152,9 +173,159 @@ export class OrchestratorAgent extends BaseAgent {
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
@@ -166,7 +337,8 @@ export class OrchestratorAgent extends BaseAgent {
this.log(`Delegating ${stage} to ${agentName} agent via backend...`);
try {
const agent = getAgent(agentName);
const result = await agent.execute(context);
const gitContext = this.buildGitAgentContext(context);
const result = await agent.execute(gitContext);
return {
phase: this.pipelineState!.current_phase,
stage,
@@ -212,7 +384,6 @@ export class OrchestratorAgent extends BaseAgent {
if (this.config.git.auto_commit && this.gitContext!.isGitRepo()) {
try {
const { execSync } = await import("node:child_process");
this.ciFiles!.writeProjectMd({
name: spec.objective.slice(0, 30),
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."]
);
try {
const { execSync } = await import("node:child_process");
execSync(`git add -A && git commit -m "${researchCommit.replace(/"/g, '\\"')}" --allow-empty`, {
cwd: context.project_path,
stdio: "pipe",
@@ -343,6 +513,38 @@ export class OrchestratorAgent extends BaseAgent {
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...");
@@ -373,7 +575,6 @@ export class OrchestratorAgent extends BaseAgent {
requirements: { covered: [], partial: [] },
});
try {
const { execSync } = await import("node:child_process");
execSync(`git add -A && git commit -m "${verifyCommit.replace(/"/g, '\\"')}" --allow-empty`, {
cwd: context.project_path,
stdio: "pipe",
@@ -399,7 +600,6 @@ export class OrchestratorAgent extends BaseAgent {
taskNames: [],
});
try {
const { execSync } = await import("node:child_process");
execSync(`git add -A && git commit -m "${completionCommit.replace(/"/g, '\\"')}" --allow-empty`, {
cwd: context.project_path,
stdio: "pipe",
+317 -9
View File
@@ -1,4 +1,27 @@
import { BaseAgent, AgentContext, AgentResult } from "./base.js";
import { CIAgentFiles, RequirementsMd, RoadmapMd, ArchitectureMd } from "../core/ciagent-files.js";
import { GitContext } from "../core/git-context.js";
import { CommitBuilder } from "../core/commit-builder.js";
import { writeFile, readFile, ensureDir } from "../utils/file.js";
import { execSync } from "node:child_process";
import * as path from "node:path";
export interface PlannerResult {
success: boolean;
planCount: number;
waves: { wave: number; plans: string[] }[];
decisions: number;
error?: string;
}
interface PlanEntry {
name: string;
wave: number;
requirements: string[];
dependsOn: string[];
tasks: string[];
mustHaves: string[];
}
export class PlannerAgent extends BaseAgent {
readonly name = "planner";
@@ -8,21 +31,306 @@ export class PlannerAgent extends BaseAgent {
async execute(context: AgentContext): Promise<AgentResult> {
const start = Date.now();
this.log("Creating phase plan...");
if (context.backend) {
const result = await this.executeViaBackend(
context,
`Create a phase plan for stage ${context.stage}, phase ${context.phase}. Specification: ${context.specification}`
);
const taskPrompt = await this.buildBackendTaskPrompt(context);
const result = await this.executeViaBackend(context, taskPrompt);
return { ...result, duration_ms: Date.now() - start };
}
return this.executeMechanical(context, start);
}
private async buildBackendTaskPrompt(context: AgentContext): Promise<string> {
const ciFiles = new CIAgentFiles(context.project_path);
const parts: string[] = [
`Create a phase plan for stage ${context.stage}, phase ${context.phase}.`,
"",
"## Project Context",
];
const roadmap = ciFiles.readRoadmapMd();
if (roadmap) {
const currentPhase = roadmap.phases.find((p) => p.number === context.phase);
if (currentPhase) {
parts.push("", "### Phase Goal", currentPhase.description);
parts.push("", "### Phase Requirements", currentPhase.requirements.join(", ") || "None specified");
parts.push("", "### Phase Dependencies", currentPhase.dependsOn.length > 0 ? currentPhase.dependsOn.map((d) => `Phase ${d}`).join(", ") : "None");
parts.push("", "### Success Criteria", ...currentPhase.successCriteria.map((sc) => `- ${sc}`));
}
}
const requirements = ciFiles.readRequirementsMd();
if (requirements) {
const phaseReqs = requirements.traceability.filter((t) => t.phase === context.phase);
if (phaseReqs.length > 0) {
parts.push("", "### Requirements for Phase", ...phaseReqs.map((t) => `- ${t.requirement} (${t.status})`));
}
}
const architecture = ciFiles.readArchitectureMd();
if (architecture) {
parts.push("", "### Architecture Boundaries", ...architecture.components.map((c) => `- ${c.name}: ${c.boundaries}`));
parts.push("", "### Build Order", ...architecture.buildOrder.map((bo) => `${bo}`));
}
parts.push("", "## Specification", context.specification || "No specification provided");
return parts.join("\n");
}
private executeMechanical(context: AgentContext, start: number): AgentResult {
const ciFiles = new CIAgentFiles(context.project_path);
ciFiles.ensureCIDir();
const requirements = ciFiles.readRequirementsMd();
const roadmap = ciFiles.readRoadmapMd();
const architecture = ciFiles.readArchitectureMd();
if (!requirements && !roadmap) {
return {
success: false,
output: "Planning requires either .ciagent/REQUIREMENTS.md or .ciagent/ROADMAP.md. Initialize the project first.",
artifacts_created: [],
decisions: 0,
escalations: 0,
duration_ms: Date.now() - start,
error: "No requirements or roadmap found for mechanical planning",
};
}
let gitLogSummary = "";
try {
gitLogSummary = execSync("git log --max-count=20 --oneline", {
cwd: context.project_path,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
}).trim();
} catch {
gitLogSummary = "(no git history available)";
}
const phaseGoal = this.extractPhaseGoal(roadmap, context.phase);
const phaseRequirements = this.extractPhaseRequirements(requirements, context.phase);
const componentBoundaries = architecture ? architecture.components.map((c) => c.name) : [];
const plans = this.buildPlans(phaseRequirements, componentBoundaries, context.phase);
const planFileContent = this.formatPlanFile(context.phase, phaseGoal, plans);
const planFilePath = path.join(context.project_path, ".ciagent", "PLAN.md");
ensureDir(path.dirname(planFilePath));
writeFile(planFilePath, planFileContent);
const decisionCount = plans.length > 0 ? 1 : 0;
if (this.shouldCommit(context)) {
try {
const commitMessage = CommitBuilder.buildTaskCommit({
type: "docs",
phase: context.phase,
milestone: "v1.0",
plan: "01",
task: "01-01",
subject: `create ${plans.length} phase plans`,
status: "plan",
decisions: decisionCount > 0 ? [{
id: "D-001",
decision: `Decomposed phase ${context.phase} into ${plans.length} vertical-slice plans`,
rationale: "Requirements grouped by dependency analysis — independent requirements in wave 1, dependent in wave 2+",
confidence: 0.75,
alternatives: ["single monolithic plan", "per-requirement plans"],
}] : undefined,
});
execSync(`git add -A && git commit -m "${commitMessage.replace(/"/g, '\\"')}" --allow-empty`, {
cwd: context.project_path,
stdio: "pipe",
});
} catch {
this.warn("Plan commit failed");
}
}
const waves = this.groupPlansByWave(plans);
const plannerResult: PlannerResult = {
success: true,
planCount: plans.length,
waves,
decisions: decisionCount,
};
return {
success: false,
output: "Planning requires an intelligence backend. Configure one with: ci init --backend",
artifacts_created: [],
decisions: 0,
success: true,
output: `Created ${plans.length} plan(s) across ${waves.length} wave(s) for phase ${context.phase}`,
artifacts_created: [".ciagent/PLAN.md"],
decisions: decisionCount,
escalations: 0,
duration_ms: Date.now() - start,
error: "No intelligence backend available",
};
}
private extractPhaseGoal(roadmap: RoadmapMd | null, phase: number): string {
if (!roadmap) return "No roadmap available";
const phaseEntry = roadmap.phases.find((p) => p.number === phase);
if (phaseEntry) return `${phaseEntry.name}: ${phaseEntry.description}`;
return `Phase ${phase} (no roadmap entry)`;
}
private extractPhaseRequirements(requirements: RequirementsMd | null, phase: number): Array<{ id: string; description: string; phase: number; status: string }> {
if (!requirements) return [];
return requirements.traceability
.filter((t) => t.phase === phase)
.map((t) => {
let description = t.requirement;
for (const cat of [...requirements.v1, ...requirements.v2]) {
const item = cat.items.find((i) => i.id === t.requirement);
if (item) {
description = `${t.requirement}: ${item.description}`;
break;
}
}
return { id: t.requirement, description, phase: t.phase, status: t.status };
});
}
private buildPlans(
phaseRequirements: Array<{ id: string; description: string; phase: number; status: string }>,
componentBoundaries: string[],
phase: number
): PlanEntry[] {
if (phaseRequirements.length === 0) {
return [{
name: `Phase ${phase} Core Implementation`,
wave: 1,
requirements: [],
dependsOn: [],
tasks: [`Implement phase ${phase} deliverables as specified in ROADMAP.md`],
mustHaves: [`Phase ${phase} deliverables exist and pass verification`],
}];
}
const independentReqs = phaseRequirements.filter((r) => r.status !== "blocked");
const blockedReqs = phaseRequirements.filter((r) => r.status === "blocked");
const plans: PlanEntry[] = [];
if (independentReqs.length > 0) {
const taskChunks = this.chunkByComponent(independentReqs, componentBoundaries);
for (const chunk of taskChunks) {
plans.push({
name: this.inferPlanName(chunk, phase),
wave: 1,
requirements: chunk.map((r) => r.id),
dependsOn: [],
tasks: chunk.map((r) => `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
View File
@@ -1,4 +1,20 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { BaseAgent, AgentContext, AgentResult } from "./base.js";
import { GitContext } from "../core/git-context.js";
import { CIAgentFiles, ArchitectureMd, ProjectMd } from "../core/ciagent-files.js";
import { CommitBuilder } from "../core/commit-builder.js";
import { CommitDecision } from "../types/commit-meta.js";
import { fileExists, readFile } from "../utils/file.js";
import { execSync } from "node:child_process";
export interface ResearcherResult {
success: boolean;
findingsCount: number;
decisionsLogged: number;
filesUpdated: string[];
error?: string;
}
export class ResearcherAgent extends BaseAgent {
readonly name = "researcher";
@@ -8,21 +24,239 @@ export class ResearcherAgent extends BaseAgent {
async execute(context: AgentContext): Promise<AgentResult> {
const start = Date.now();
this.log("Researching domain...");
if (context.backend) {
const result = await this.executeViaBackend(
context,
`Research the domain for: ${context.specification}`
`Research the domain for phase ${context.phase}. Specification: ${context.specification}. Read git history (last 50 commits), .ciagent/PROJECT.md, .ciagent/ARCHITECTURE.md, .ciagent/REQUIREMENTS.md. Scan src/ directory structure. Generate findings about module boundaries, risks, and approach. Update .ciagent/ARCHITECTURE.md with component boundary conclusions. Update .ciagent/PROJECT.md key decisions if warranted. Commit findings with CommitBuilder.buildResearchCommit().`
);
return { ...result, duration_ms: Date.now() - start };
}
const result = await this.runMechanicalResearch(context);
const output = JSON.stringify(result, null, 2);
return {
success: false,
output: "Research requires an intelligence backend. Configure one with: ci init --backend",
artifacts_created: [],
decisions: 0,
success: result.success,
output,
artifacts_created: result.filesUpdated,
decisions: result.decisionsLogged,
escalations: 0,
duration_ms: Date.now() - start,
error: "No intelligence backend available",
error: result.error,
};
}
private async runMechanicalResearch(context: AgentContext): Promise<ResearcherResult> {
try {
const gitContext = new GitContext(context.project_path);
const ciFiles = new CIAgentFiles(context.project_path);
const findings: string[] = [];
const decisions: CommitDecision[] = [];
const filesUpdated: string[] = [];
const commits = gitContext.getRecentCommits(50);
if (commits.length > 0) {
findings.push(`Analyzed ${commits.length} recent commits for project history`);
const researchCommits = commits.filter(c => c.ci?.status === "research");
if (researchCommits.length > 0) {
findings.push(`Found ${researchCommits.length} prior research commits`);
}
}
const projectMd = ciFiles.readProjectMd();
if (projectMd) {
findings.push(`Project: ${projectMd.name} — core value: ${projectMd.coreValue.slice(0, 80)}`);
findings.push(`Active requirements: ${projectMd.requirements.active.length}, validated: ${projectMd.requirements.validated.length}`);
} else {
findings.push("No PROJECT.md found — project context unavailable");
}
const archMd = ciFiles.readArchitectureMd();
if (archMd) {
findings.push(`Architecture: ${archMd.components.length} components, ${archMd.buildOrder.length} build steps`);
for (const comp of archMd.components) {
findings.push(` Component: ${comp.name} — boundaries: ${comp.boundaries.slice(0, 60)}, deps: ${comp.dependsOn.join(", ") || "none"}`);
}
} else {
findings.push("No ARCHITECTURE.md found — architecture analysis unavailable");
}
const reqsMd = ciFiles.readRequirementsMd();
if (reqsMd) {
const totalReqs = reqsMd.traceability.length;
const covered = reqsMd.traceability.filter(t => t.status === "complete").length;
const phaseReqs = reqsMd.traceability.filter(t => t.phase === context.phase);
findings.push(`Requirements: ${totalReqs} total, ${covered} complete, ${phaseReqs.length} for phase ${context.phase}`);
}
const srcDir = path.join(context.project_path, "src");
if (fs.existsSync(srcDir)) {
const moduleDirs = fs.readdirSync(srcDir, { withFileTypes: true })
.filter(d => d.isDirectory() && d.name !== "node_modules")
.map(d => d.name);
findings.push(`Source modules: ${moduleDirs.join(", ")}`);
const updatedArch = this.deriveArchitectureFromSource(srcDir, archMd, moduleDirs);
if (updatedArch) {
ciFiles.writeArchitectureMd(updatedArch);
filesUpdated.push(".ciagent/ARCHITECTURE.md");
findings.push("Updated ARCHITECTURE.md with source-derived component boundaries");
decisions.push({
id: `D-P${context.phase}-001`,
decision: "Updated component boundaries from source scan",
rationale: "Source directory structure reveals actual module boundaries",
confidence: 0.75,
alternatives: ["manual architecture review", "no update"],
});
}
}
if (projectMd && archMd) {
const updatedProject = this.maybeUpdateKeyDecisions(projectMd, findings);
if (updatedProject) {
ciFiles.writeProjectMd(updatedProject, "research findings update");
filesUpdated.push(".ciagent/PROJECT.md");
findings.push("Updated PROJECT.md key decisions from research");
decisions.push({
id: `D-P${context.phase}-002`,
decision: "Logged research-based decisions to PROJECT.md",
rationale: "Research findings warrant recording as key decisions",
confidence: 0.70,
alternatives: ["defer decision logging", "log after execution"],
});
}
}
this.commitFindings(context, findings, decisions);
return {
success: true,
findingsCount: findings.length,
decisionsLogged: decisions.length,
filesUpdated,
};
} catch (err) {
return {
success: false,
findingsCount: 0,
decisionsLogged: 0,
filesUpdated: [],
error: `Research failed: ${err instanceof Error ? err.message : String(err)}`,
};
}
}
private deriveArchitectureFromSource(srcDir: string, existing: ArchitectureMd | null, moduleDirs: string[]): ArchitectureMd | null {
const newComponents = moduleDirs.map(dir => {
const dirPath = path.join(srcDir, dir);
const fileCount = this.countTsFiles(dirPath);
const existingComp = existing?.components.find(c => c.name.toLowerCase() === dir.toLowerCase());
return {
name: existingComp?.name || this.capitalize(dir),
description: existingComp?.description || `${dir} module with ${fileCount} source files`,
boundaries: existingComp?.boundaries || `src/${dir}/ — ${fileCount} files, internal module`,
dependsOn: existingComp?.dependsOn || [],
};
});
if (existing) {
const existingNames = new Set(existing.components.map(c => c.name.toLowerCase()));
const hasNew = newComponents.some(c => !existingNames.has(c.name.toLowerCase()));
if (!hasNew) {
return {
...existing,
components: existing.components.map(comp => {
const updated = newComponents.find(n => n.name.toLowerCase() === comp.name.toLowerCase());
return updated || comp;
}),
};
}
const merged = [...existing.components];
for (const nc of newComponents) {
if (!existingNames.has(nc.name.toLowerCase())) {
merged.push(nc);
}
}
return { ...existing, components: merged };
}
return {
overview: "Architecture derived from source directory scan",
components: newComponents,
dataFlow: "Modules communicate via typed interfaces and shared utilities",
buildOrder: moduleDirs.map(d => `Build ${d} module`),
};
}
private maybeUpdateKeyDecisions(projectMd: ProjectMd, findings: string[]): ProjectMd | null {
const researchDecisions = findings
.filter(f => f.includes("Updated") || f.includes("Found") || f.includes("derived"))
.map(f => ({
decision: f.slice(0, 50),
rationale: "Derived from mechanical source analysis",
outcome: "logged by researcher",
}));
if (researchDecisions.length === 0) return null;
const existingDecisions = projectMd.keyDecisions || [];
const existingDecisionTexts = new Set(existingDecisions.map(d => d.decision));
const novelDecisions = researchDecisions.filter(d => !existingDecisionTexts.has(d.decision));
if (novelDecisions.length === 0) return null;
return {
...projectMd,
keyDecisions: [...existingDecisions, ...novelDecisions],
};
}
private commitFindings(context: AgentContext, findings: string[], decisions: CommitDecision[]): void {
try {
const gitContext = new GitContext(context.project_path);
const projectState = gitContext.reconstructState();
const milestone = projectState.currentMilestone || "v1.0";
const commitMsg = CommitBuilder.buildResearchCommit(
context.phase,
milestone,
`phase ${context.phase} domain research`,
findings,
decisions.length > 0 ? decisions : undefined,
);
if (fileExists(path.join(context.project_path, ".git"))) {
execSync(`git add -A && git commit -m "${commitMsg.replace(/"/g, '\\"')}" --allow-empty`, {
cwd: context.project_path,
stdio: "pipe",
});
}
} catch (err) {
this.warn(`Research commit failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
private countTsFiles(dir: string): number {
if (!fs.existsSync(dir)) return 0;
let count = 0;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && entry.name !== "node_modules") {
count += this.countTsFiles(path.join(dir, entry.name));
} else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts") && !entry.name.endsWith(".test.ts")) {
count++;
}
}
return count;
}
private capitalize(s: string): string {
return s.split("-").map(p => p.charAt(0).toUpperCase() + p.slice(1)).join("-");
}
}
+159 -6
View File
@@ -1,28 +1,181 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { BaseAgent, AgentContext, AgentResult } from "./base.js";
import { execSync } from "node:child_process";
export interface TesterResult {
success: boolean;
integrationTestsFound: number;
integrationTestsPassed: number;
e2eTestsFound: number;
e2eTestsPassed: number;
overallPassed: boolean;
error?: string;
}
export class TesterAgent extends BaseAgent {
readonly name = "tester";
readonly description = "Runs automated tests and validates test coverage.";
readonly description = "Runs integration, e2e, functional tests. Validates non-unit test coverage.";
readonly workflow = "test";
async execute(context: AgentContext): Promise<AgentResult> {
const start = Date.now();
this.log("Running automated tests...");
if (context.backend) {
const result = await this.executeViaBackend(
context,
`Run automated tests for: ${context.specification}`
`Run integration, e2e, and functional tests for phase ${context.phase}. Specification: ${context.specification}. Detect *.integration.test.ts, *.e2e.test.ts, *.functional.test.ts files. Run npm test. Parse output for pass/fail counts per category. Report structured TesterResult. Do NOT write any test files — only detect and run existing ones.`
);
return { ...result, duration_ms: Date.now() - start };
}
const result = await this.runMechanicalTests(context);
const output = JSON.stringify(result, null, 2);
return {
success: false,
output: "Testing requires an intelligence backend.",
success: result.success,
output,
artifacts_created: [],
decisions: 0,
escalations: 0,
escalations: result.overallPassed ? 0 : 1,
duration_ms: Date.now() - start,
error: "No intelligence backend available",
error: result.error,
};
}
private async runMechanicalTests(context: AgentContext): Promise<TesterResult> {
try {
const srcDir = path.join(context.project_path, "src");
const integrationFiles = fs.existsSync(srcDir) ? this.findTestFiles(srcDir, /\.integration\.test\.ts$/) : [];
const e2eFiles = fs.existsSync(srcDir) ? this.findTestFiles(srcDir, /\.e2e\.test\.ts$/) : [];
const functionalFiles = fs.existsSync(srcDir) ? this.findTestFiles(srcDir, /\.functional\.test\.ts$/) : [];
const integrationTestsFound = integrationFiles.length;
const e2eTestsFound = e2eFiles.length + functionalFiles.length;
let overallPassed = false;
let integrationTestsPassed = 0;
let e2eTestsPassed = 0;
try {
const testOutput = execSync("npm test 2>&1", {
cwd: context.project_path,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
timeout: 120000,
});
overallPassed = true;
const passCounts = this.parseTestOutput(testOutput);
integrationTestsPassed = integrationTestsFound > 0 ? integrationTestsFound : 0;
e2eTestsPassed = e2eTestsFound > 0 ? e2eTestsFound : 0;
if (integrationTestsFound > 0) {
integrationTestsPassed = this.estimateCategoryPassed(testOutput, "integration");
}
if (e2eTestsFound > 0) {
e2eTestsPassed = this.estimateCategoryPassed(testOutput, "e2e");
}
} catch (err) {
const output = err instanceof Error && "stdout" in err
? (err as unknown as { stdout: string }).stdout || ""
: "";
const stderr = err instanceof Error && "stderr" in err
? (err as unknown as { stderr: string }).stderr || ""
: "";
const combined = `${output}\n${stderr}`;
overallPassed = false;
const passCounts = this.parseTestOutput(combined);
if (integrationTestsFound > 0) {
integrationTestsPassed = this.estimateCategoryPassed(combined, "integration");
}
if (e2eTestsFound > 0) {
e2eTestsPassed = this.estimateCategoryPassed(combined, "e2e");
}
return {
success: false,
integrationTestsFound,
integrationTestsPassed,
e2eTestsFound,
e2eTestsPassed,
overallPassed: false,
error: `npm test failed: ${err instanceof Error ? err.message : String(err)}`,
};
}
return {
success: overallPassed,
integrationTestsFound,
integrationTestsPassed,
e2eTestsFound,
e2eTestsPassed,
overallPassed,
};
} catch (err) {
return {
success: false,
integrationTestsFound: 0,
integrationTestsPassed: 0,
e2eTestsFound: 0,
e2eTestsPassed: 0,
overallPassed: false,
error: `Test execution failed: ${err instanceof Error ? err.message : String(err)}`,
};
}
}
private findTestFiles(dir: string, pattern: RegExp): string[] {
const files: string[] = [];
if (!fs.existsSync(dir)) return files;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory() && entry.name !== "node_modules") {
files.push(...this.findTestFiles(fullPath, pattern));
} else if (pattern.test(entry.name)) {
files.push(fullPath);
}
}
return files;
}
private parseTestOutput(output: string): { total: number; passed: number; failed: number } {
const jestSummary = output.match(/Tests:\s+(\d+)\s+passed(?:,\s+(\d+)\s+failed)?/);
if (jestSummary) {
const passed = parseInt(jestSummary[1], 10) || 0;
const failed = parseInt(jestSummary[2], 10) || 0;
return { total: passed + failed, passed, failed };
}
const jestAlt = output.match(/(\d+)\s+passing/);
const jestAltFail = output.match(/(\d+)\s+failing/);
if (jestAlt) {
const passed = parseInt(jestAlt[1], 10) || 0;
const failed = jestAltFail ? parseInt(jestAltFail[1], 10) || 0 : 0;
return { total: passed + failed, passed, failed };
}
return { total: 0, passed: 0, failed: 0 };
}
private estimateCategoryPassed(output: string, category: string): number {
const categoryPattern = category === "integration"
? /\.integration\.test\.ts/g
: /\.e2e\.test\.ts|\.functional\.test\.ts/g;
const mentions = (output.match(categoryPattern) || []).length;
if (mentions > 0) {
const failPattern = /FAIL|failed|error/i;
const lines = output.split("\n").filter(l => categoryPattern.test(l));
const failed = lines.filter(l => failPattern.test(l)).length;
return Math.max(mentions - failed, 0);
}
return 0;
}
}
+217 -5
View File
@@ -1,4 +1,22 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { BaseAgent, AgentContext, AgentResult } from "./base.js";
import { VerificationPipeline } from "../verification/index.js";
import { CommitBuilder, VerifyCommitInput } from "../core/commit-builder.js";
import { GitContext } from "../core/git-context.js";
import { CIAgentFiles } from "../core/ciagent-files.js";
import { fileExists } from "../utils/file.js";
import { execSync } from "node:child_process";
export interface VerifierResult {
success: boolean;
mustHaveScore: number;
requirementsCovered: string[];
requirementsPartial: string[];
integrationChecks: { import: string; resolved: boolean }[];
layers: { name: string; passed: boolean }[];
error?: string;
}
export class VerifierAgent extends BaseAgent {
readonly name = "verifier";
@@ -8,21 +26,215 @@ export class VerifierAgent extends BaseAgent {
async execute(context: AgentContext): Promise<AgentResult> {
const start = Date.now();
this.log("Verifying phase output...");
if (context.backend) {
const result = await this.executeViaBackend(
context,
`Verify phase ${context.phase} output. Specification: ${context.specification}`
`Verify phase ${context.phase} output against must-haves, requirement coverage, and integration links. Specification: ${context.specification}. Check all .ciagent/ reference files. Run the 4-layer verification pipeline (structural, behavioral, security, quality). Verify imports resolve. Report structured VerifierResult.`
);
return { ...result, duration_ms: Date.now() - start };
}
const result = await this.runMechanicalVerification(context);
const output = JSON.stringify(result, null, 2);
return {
success: false,
output: "Verification requires an intelligence backend. Configure one with: ci init --backend",
success: result.success,
output,
artifacts_created: [],
decisions: 0,
escalations: 0,
escalations: result.success ? 0 : 1,
duration_ms: Date.now() - start,
error: "No intelligence backend available",
error: result.error,
};
}
private async runMechanicalVerification(context: AgentContext): Promise<VerifierResult> {
try {
const pipeline = new VerificationPipeline(context.project_path);
const pipelineResult = await pipeline.run(context.phase);
const layers: { name: string; passed: boolean }[] = [
{ name: pipelineResult.structural.name, passed: pipelineResult.structural.passed },
{ name: pipelineResult.behavioral.name, passed: pipelineResult.behavioral.passed },
{ name: pipelineResult.security.name, passed: pipelineResult.security.passed },
{ name: pipelineResult.quality.name, passed: pipelineResult.quality.passed },
];
const gitContext = new GitContext(context.project_path);
const ciFiles = new CIAgentFiles(context.project_path);
const mustHaveScore = this.checkMustHaves(context, gitContext, ciFiles);
const reqCoverage = this.checkRequirementCoverage(gitContext, ciFiles);
const integrationChecks = this.checkIntegrationLinks(context.project_path);
const allPassed = pipelineResult.all_passed &&
mustHaveScore >= 1.0 &&
reqCoverage.partial.length === 0;
const result: VerifierResult = {
success: allPassed,
mustHaveScore,
requirementsCovered: reqCoverage.covered,
requirementsPartial: reqCoverage.partial,
integrationChecks,
layers,
};
if (!allPassed) {
result.error = `Verification gaps: mustHaveScore=${mustHaveScore}, partialReqs=${reqCoverage.partial.join(",")}, layerFailures=${layers.filter(l => !l.passed).map(l => l.name).join(",")}`;
}
this.commitVerificationResult(context, result, ciFiles);
return result;
} catch (err) {
return {
success: false,
mustHaveScore: 0,
requirementsCovered: [],
requirementsPartial: [],
integrationChecks: [],
layers: [],
error: `Verification failed: ${err instanceof Error ? err.message : String(err)}`,
};
}
}
private checkMustHaves(context: AgentContext, gitContext: GitContext, ciFiles: CIAgentFiles): number {
const roadmap = ciFiles.readRoadmapMd();
if (!roadmap) return 0;
const currentPhase = roadmap.phases.find(p => p.number === context.phase);
if (!currentPhase) return 0;
const successCriteria = currentPhase.successCriteria;
if (successCriteria.length === 0) return 1;
let passing = 0;
for (const criterion of successCriteria) {
const fileHint = criterion.match(/(?:file|exists|present|created|written)[:\s]+([^\s,;]+)/i);
if (fileHint) {
const candidate = path.join(context.project_path, fileHint[1]);
if (fileExists(candidate)) {
passing++;
continue;
}
}
if (fileExists(path.join(context.project_path, ".ciagent"))) {
passing++;
}
}
return Math.min(passing / successCriteria.length, 1);
}
private checkRequirementCoverage(gitContext: GitContext, ciFiles: CIAgentFiles): { covered: string[]; partial: string[] } {
const gitCoverage = gitContext.getRequirementsCoverage();
const reqsMd = ciFiles.readRequirementsMd();
if (!reqsMd || reqsMd.traceability.length === 0) {
return { covered: gitCoverage.covered, partial: gitCoverage.partial };
}
const covered = new Set(gitCoverage.covered);
const partial = new Set(gitCoverage.partial);
for (const t of reqsMd.traceability) {
if (t.status === "complete") {
covered.add(t.requirement);
partial.delete(t.requirement);
} else if (t.status === "in_progress" || t.status === "blocked") {
partial.add(t.requirement);
}
}
return {
covered: [...covered].sort(),
partial: [...partial].sort(),
};
}
private checkIntegrationLinks(projectPath: string): { import: string; resolved: boolean }[] {
const checks: { import: string; resolved: boolean }[] = [];
const srcDir = path.join(projectPath, "src");
if (!fs.existsSync(srcDir)) return checks;
const tsFiles = this.collectTsFiles(srcDir);
const importPattern = /import\s+.*from\s+['"](\.\/[^'"]+)['"]/g;
for (const file of tsFiles) {
const content = fs.readFileSync(file, "utf-8");
let match: RegExpExecArray | null;
while ((match = importPattern.exec(content)) !== null) {
const importPath = match[1];
const resolved = this.resolveImport(file, importPath);
if (importPath.startsWith(".")) {
checks.push({ import: `${path.relative(projectPath, file)}:${importPath}`, resolved: resolved !== null });
}
}
}
return checks;
}
private commitVerificationResult(context: AgentContext, result: VerifierResult, ciFiles: CIAgentFiles): void {
try {
const projectState = new GitContext(context.project_path).reconstructState();
const milestone = projectState.currentMilestone || "v1.0";
const verifyInput: VerifyCommitInput = {
phase: context.phase,
milestone,
subject: result.success ? "passed" : "gaps_found",
requirements: {
covered: result.requirementsCovered,
partial: result.requirementsPartial,
},
lessons: result.success ? ["All verification checks passed"] : [`Must-have score: ${result.mustHaveScore}`, `Layer failures: ${result.layers.filter(l => !l.passed).map(l => l.name).join(", ")}`],
};
const commitMsg = CommitBuilder.buildVerifyCommit(verifyInput);
if (fileExists(path.join(context.project_path, ".git"))) {
execSync(`git add -A && git commit -m "${commitMsg.replace(/"/g, '\\"')}" --allow-empty`, {
cwd: context.project_path,
stdio: "pipe",
});
}
} catch (err) {
this.warn(`Verification commit failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
private collectTsFiles(dir: string): string[] {
const files: string[] = [];
if (!fs.existsSync(dir)) return files;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory() && entry.name !== "node_modules") {
files.push(...this.collectTsFiles(fullPath));
} else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts") && !entry.name.endsWith(".test.ts")) {
files.push(fullPath);
}
}
return files;
}
private resolveImport(fromFile: string, importPath: string): string | null {
if (!importPath.startsWith(".")) return null;
const dir = path.dirname(fromFile);
const candidates = [
path.resolve(dir, importPath + ".ts"),
path.resolve(dir, importPath + ".js"),
path.resolve(dir, importPath, "index.ts"),
path.resolve(dir, importPath, "index.js"),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) return candidate;
}
return null;
}
}
+74 -2
View File
@@ -15,16 +15,26 @@ import {
import { AgentName, ModelProfile } from "../types/config.js";
import { Decision } from "../types/decisions.js";
import { Escalation } from "../types/escalation.js";
import { ToolRegistry, ToolCall, ToolResult } from "./tool-registry.js";
import { ToolRegistry, ToolCall, ToolResult, ToolDefinition } from "./tool-registry.js";
const MAX_TOOL_ROUNDS = 50;
const PERSONA_TOOL_MAP: Record<string, string> = {
read: "readFile",
write: "writeFile",
edit: "editFile",
bash: "runBash",
glob: "glob",
grep: "grep",
};
export abstract class OllamaBaseBackend implements IntelligenceBackend {
abstract readonly name: string;
readonly type: BackendType = "llm";
protected config: LLMBackendConfig;
protected projectPath: string;
protected filteredToolSchema: Array<Record<string, unknown>> | null = null;
constructor(config: LLMBackendConfig | undefined) {
this.config = config || { base_url: "http://localhost:11434", model_profile: "balanced" };
@@ -42,6 +52,9 @@ export abstract class OllamaBaseBackend implements IntelligenceBackend {
const model = this.resolveModel();
const toolRegistry = new ToolRegistry(request.context.project_path);
const allowedTools = this.parsePersonaTools(personaContent);
const filteredDefinitions = this.filterToolDefinitions(toolRegistry.getDefinitions(), allowedTools);
this.filteredToolSchema = this.definitionsToOpenAISchema(filteredDefinitions);
const messages: OllamaMessage[] = [];
messages.push({
@@ -62,7 +75,7 @@ export abstract class OllamaBaseBackend implements IntelligenceBackend {
while (round < MAX_TOOL_ROUNDS) {
round++;
const response = await this.callModel(messages, model, toolRegistry);
const response = await this.callModelWithTools(messages, model, filteredDefinitions);
totalInputTokens += response.usage?.prompt_tokens || 0;
totalOutputTokens += response.usage?.completion_tokens || 0;
@@ -124,6 +137,65 @@ export abstract class OllamaBaseBackend implements IntelligenceBackend {
}
}
protected parsePersonaTools(personaContent: string): string[] | null {
const frontmatterMatch = personaContent.match(/^---\n([\s\S]*?)\n---/);
if (!frontmatterMatch) return null;
const frontmatter = frontmatterMatch[1];
const toolsMatch = frontmatter.match(/tools:\s*\n((?:\s+\w+:.+\n?)+)/);
if (!toolsMatch) {
const inlineMatch = frontmatter.match(/tools:\s*\[([^\]]+)\]/);
if (inlineMatch) {
return inlineMatch[1]
.split(",")
.map((t) => t.trim())
.filter(Boolean)
.map((t) => PERSONA_TOOL_MAP[t] || t);
}
return null;
}
const toolsBlock = toolsMatch[1];
const toolNames: string[] = [];
const lineRegex = /^\s+(\w+):/gm;
let lineMatch;
while ((lineMatch = lineRegex.exec(toolsBlock)) !== null) {
const personaToolName = lineMatch[1];
toolNames.push(PERSONA_TOOL_MAP[personaToolName] || personaToolName);
}
return toolNames.length > 0 ? toolNames : null;
}
protected filterToolDefinitions(definitions: ToolDefinition[], allowedTools: string[] | null): ToolDefinition[] {
if (!allowedTools) return definitions;
const allowedSet = new Set(allowedTools);
return definitions.filter((def) => allowedSet.has(def.name));
}
protected async callModelWithTools(
messages: OllamaMessage[],
model: string,
toolDefinitions: ToolDefinition[]
): Promise<OllamaChatResponse> {
return this.callModel(messages, model, new ToolRegistry(this.projectPath));
}
protected definitionsToOpenAISchema(definitions: ToolDefinition[]): Array<Record<string, unknown>> {
return definitions.map((def) => ({
type: "function",
function: {
name: def.name,
description: def.description,
parameters: def.parameters,
},
}));
}
protected getActiveToolSchema(toolRegistry: ToolRegistry): Array<Record<string, unknown>> {
return this.filteredToolSchema || toolRegistry.getOpenAIToolSchema();
}
protected abstract callModel(
messages: OllamaMessage[],
model: string,
+1 -1
View File
@@ -61,7 +61,7 @@ export class OllamaCloudBackend extends OllamaBaseBackend {
if (m.tool_calls) msg.tool_calls = m.tool_calls;
return msg;
}),
tools: toolRegistry.getOpenAIToolSchema(),
tools: this.getActiveToolSchema(toolRegistry),
stream: false,
};
+1 -1
View File
@@ -48,7 +48,7 @@ export class OllamaLocalBackend extends OllamaBaseBackend {
if (m.tool_calls) msg.tool_calls = m.tool_calls;
return msg;
}),
tools: toolRegistry.getOpenAIToolSchema(),
tools: this.getActiveToolSchema(toolRegistry),
stream: false,
};
+21
View File
@@ -29,6 +29,7 @@ export class EscalationProtocol {
private pendingEscalations: Map<string, Escalation>;
private timeoutCallback: (escalation: Escalation, chosenOption: string) => void;
private timers: NodeJS.Timeout[];
private timerEscalationMap: Map<NodeJS.Timeout, string>;
constructor(
config: CIAgentConfig,
@@ -43,6 +44,7 @@ export class EscalationProtocol {
this.pendingEscalations = new Map();
this.timeoutCallback = timeoutCallback;
this.timers = [];
this.timerEscalationMap = new Map();
}
setMilestone(milestone: string): void {
@@ -102,6 +104,16 @@ export class EscalationProtocol {
const escalation = this.pendingEscalations.get(escalationId);
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.resolved_at = new Date().toISOString();
escalation.resolution_detail = `Chose option: ${chosenOptionId}`;
@@ -139,11 +151,16 @@ export class EscalationProtocol {
clearAllTimers(): void {
for (const timer of this.timers) {
clearTimeout(timer);
this.timerEscalationMap.delete(timer);
}
this.timers = [];
this.pendingEscalations.clear();
}
dispose(): void {
this.clearAllTimers();
}
formatEscalation(escalation: Escalation): string {
const lines: string[] = [
`⚠️ ESCALATION [${escalation.id}]`,
@@ -200,9 +217,13 @@ export class EscalationProtocol {
escalation.resolved_at = new Date().toISOString();
escalation.resolution_detail = `Auto-proceeded with default: ${escalation.default_option_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);
}
}, timeout);
this.timers.push(timer);
this.timerEscalationMap.set(timer, escalation.id);
}
}
+2
View File
@@ -14,6 +14,8 @@ export { BehavioralVerification } from "./verification/behavioral.js";
export { SecurityVerification } from "./verification/security.js";
export { QualityVerification } from "./verification/quality.js";
export { getAgent, getAvailableAgents } from "./agents/index.js";
export type { PlannerResult } from "./agents/planner.js";
export type { ExecutorResult } from "./agents/executor.js";
export { initCIAgent, loadConfig, saveConfig, isCIAgentInitialized } from "./core/config.js";
export { DEFAULT_CIAGENT_CONFIG } from "./types/config.js";
export { confidenceToLevel, shouldEscalate } from "./types/decisions.js";