diff --git a/AGENTS.md b/AGENTS.md index e7a26c7..ddb8198 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,15 @@ CI (Continuous Intelligence) is a fully autonomous AI-driven software engineerin ``` src/ - agents/ # 18 agent implementations (all extend BaseAgent) + agents/ # 18 agent implementations (persona loaders delegating to backends) + backends/ # Intelligence backend layer + types.ts # IntelligenceBackend, BackendRequest, BackendResult, BackendConfigSection + tool-registry.ts # CI-owned tool implementations (readFile, writeFile, editFile, runBash, glob, grep) + ollama-base.ts # Abstract base for Ollama backends (shared tool loop, prompt construction) + ollama-local.ts # OllamaLocalBackend (localhost:11434) + ollama-cloud.ts # OllamaCloudBackend (remote endpoint, auth, rate limiting) + opencode.ts # OpencodeBackend (shells out to opencode --non-interactive) + index.ts # Backend registry + auto-detection cli/ # Commander.js CLI (commands.ts, index.ts) core/ # Core engine components artifacts.ts # Legacy .planning/ artifact management (retained for backward compat) @@ -32,7 +40,7 @@ src/ git-context.ts # Project state reconstruction from git log + branches types/ # Type definitions commit-meta.ts # CiMetadata, CommitDecision, CommitEscalation, ParsedCiCommit - config.ts # CIConfig, AutonomyLevel, ModelProfile, DEFAULT_CI_CONFIG + config.ts # CIConfig, AutonomyLevel, ModelProfile, DEFAULT_CI_CONFIG (includes backend) decisions.ts # Decision, ConfidenceLevel, DecisionCategory escalation.ts # Escalation, EscalationType, EscalationResolution clarify.ts # ClarifyQuestion, ClarifyResult @@ -45,7 +53,7 @@ src/ security.ts # Layer 3: STRIDE threat analysis (stub) quality.ts # Layer 4: multi-persona code review (stub) index.ts # Public API exports - version.ts # VERSION = "0.2.0" + version.ts # VERSION = "0.3.0" templates/ # Template files (config.json, DECISIONS.md, specification.md) ``` @@ -62,7 +70,7 @@ templates/ # Template files (config.json, DECISIONS.md, specification.md - **Language**: TypeScript with ES2022 target, Node16 modules - **Module resolution**: Node16 style with `.js` extensions in imports -- **Agent pattern**: All agents extend `BaseAgent` with `name`, `description`, and `execute(context: AgentContext): Promise` +- **Agent pattern**: All agents extend `BaseAgent` with `name` (AgentName), `description`, `workflow`, and `execute(context: AgentContext): Promise`. Agents delegate to `context.backend` when available, fail honestly when not. - **No runtime validation library**: Uses plain TypeScript types, not Zod schemas (Zod is a dependency but types are hand-defined) - **File I/O**: Use `src/utils/file.ts` helpers (`writeFile`, `readFile`, `ensureDir`, `readJSON`, `writeJSON`) instead of raw `fs` calls in agent/business logic - **Config**: `CIConfig` type and `DEFAULT_CI_CONFIG` in `src/types/config.ts` — always merge partial configs with defaults @@ -77,7 +85,26 @@ templates/ # Template files (config.json, DECISIONS.md, specification.md SPECIFY → CLARIFY → RESEARCH → PLAN → EXECUTE → VERIFY → COMPLETE ``` -Each stage is executed by `OrchestratorAgent.executeStage()`. The orchestrator iterates through `STAGE_ORDER` and collects `PhaseResult` for each. +Each stage is executed by `OrchestratorAgent.executeStage()`. The orchestrator delegates intelligent stages (research, plan, execute, verify) to specialized agents via `context.backend` when available, falling back to mechanical execution when no backend is configured. Mechanical stages (specify, clarify, complete) are always handled by the orchestrator directly. + +## Intelligence Backend Architecture + +``` +IntelligenceBackend (unified interface) +├── LLMBackend (CI runs tool loop, provides tools, constructs prompts) +│ ├── OllamaLocalBackend (localhost:11434, no auth) +│ ├── OllamaCloudBackend (remote endpoint, API key, rate limits) +│ └── (future: OpenAI, Anthropic, Gemini, etc.) +└── AgentBackend (agent runs own tool loop, CI sends request) + ├── OpencodeBackend (opencode --non-interactive) + └── (future: Codex, Claude Code, Hermes, etc.) +``` + +- **LLM backends**: CI constructs system prompts from persona.md + workflow.md, defines tool schemas, runs the tool-call loop via `ToolRegistry`, and parses structured JSON output +- **Agent backends**: CI serializes `BackendRequest`, invokes the agent, and parses JSON `BackendResult` from stdout +- **Auto-detection** (provider: "auto"): tries opencode → ollama-local → ollama-cloud → fails with instructions +- **Per-command override**: `ci run --backend ollama-local` forces a specific backend +- **Config**: `backend` section in `.ci/config.json` with provider, fallback, agent_backends, llm_backends ## Agent Modification Rules (from PRD) @@ -174,5 +201,6 @@ Each stage is executed by `OrchestratorAgent.executeStage()`. The orchestrator i - **Reconstruction test**: An agent with only commit message access can reconstruct project state (phase, decisions, requirements coverage, lessons, escalations) - **Verification layers**: All 4 layers implemented — structural, behavioral, security (STRIDE), quality - **CLI**: All 11 commands wired up (`init`, `run`, `quick`, `debug`, `verify`, `review`, `status`, `audit`, `clarify`, `rollback`, `ship`) -- **Agent implementations**: Stub agents return success immediately. Real LLM-based agent implementations are needed for research, planning, execution, verification, etc. +- **Agent implementations**: Persona loaders that delegate to active backend. Fail honestly when no backend is available (no more fake success). +- **Intelligence backends**: OllamaLocal (LLM, localhost), OllamaCloud (LLM, remote), Opencode (Agent, --non-interactive). Auto-detection: opencode → ollama-local → ollama-cloud. - **Tests**: 25 test suites, 218 tests covering types, config, decision-engine, escalation, clarify, commit-parser, commit-builder, git-context, git-branch, ci-files, all 4 verification layers, file utils \ No newline at end of file diff --git a/src/agents/base.ts b/src/agents/base.ts index 65e7259..c809834 100644 --- a/src/agents/base.ts +++ b/src/agents/base.ts @@ -1,3 +1,6 @@ +import { IntelligenceBackend, BackendRequest, BackendResult, BackendUnavailableError, emptyBackendResult } from "../backends/types.js"; +import { AgentName, AutonomyLevel } from "../types/config.js"; + export interface AgentResult { success: boolean; output: string; @@ -14,14 +17,43 @@ export interface AgentContext { stage: string; specification: string; config_path: string; + backend?: IntelligenceBackend; +} + +export function backendResultToAgentResult(result: BackendResult): AgentResult { + return { + success: result.success, + output: result.output, + artifacts_created: result.artifacts.map((a) => a.path), + decisions: result.decisions.length, + escalations: result.escalations.length, + duration_ms: 0, + error: result.error, + }; } export abstract class BaseAgent { - abstract readonly name: string; + abstract readonly name: AgentName; abstract readonly description: string; + abstract readonly workflow: string; abstract execute(context: AgentContext): Promise; + protected async executeViaBackend(context: AgentContext, task: string): Promise { + if (!context.backend) { + throw new BackendUnavailableError("none", this.name); + } + const request: BackendRequest = { + persona: this.name, + workflow: this.workflow, + task, + context, + autonomy: "full", + }; + const result = await context.backend.execute(request); + return backendResultToAgentResult(result); + } + protected log(message: string): void { console.log(`[${this.name}] ${message}`); } diff --git a/src/agents/challenger.ts b/src/agents/challenger.ts index fdca29b..78fe057 100644 --- a/src/agents/challenger.ts +++ b/src/agents/challenger.ts @@ -3,17 +3,26 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js"; export class ChallengerAgent extends BaseAgent { readonly name = "challenger"; readonly description = "Stress-tests plans with binding verdicts. Only escalates when confidence < 0.60."; + readonly workflow = "plan"; async execute(context: AgentContext): Promise { - this.log("Challenging plan..."); const start = Date.now(); + this.log("Challenging plan..."); + if (context.backend) { + const result = await this.executeViaBackend( + context, + `Stress-test the plan for phase ${context.phase}. Specification: ${context.specification}` + ); + return { ...result, duration_ms: Date.now() - start }; + } return { - success: true, - output: "Plan challenge complete — verdict: proceed", + success: false, + output: "Plan challenge requires an intelligence backend. Configure one with: ci init --backend", artifacts_created: [], decisions: 0, escalations: 0, duration_ms: Date.now() - start, + error: "No intelligence backend available", }; } } \ No newline at end of file diff --git a/src/agents/code-reviewer.ts b/src/agents/code-reviewer.ts index 20ccc39..45c85c6 100644 --- a/src/agents/code-reviewer.ts +++ b/src/agents/code-reviewer.ts @@ -3,17 +3,26 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js"; export class CodeReviewerAgent extends BaseAgent { readonly name = "code-reviewer"; readonly description = "Multi-persona code review. Auto-applies P0 fixes. Flags P1+ for post-hoc review."; + readonly workflow = "review"; async execute(context: AgentContext): Promise { - this.log("Running code review..."); const start = Date.now(); + this.log("Running code review..."); + if (context.backend) { + const result = await this.executeViaBackend( + context, + `Perform multi-persona code review for phase ${context.phase}. Specification: ${context.specification}` + ); + return { ...result, duration_ms: Date.now() - start }; + } return { - success: true, - output: "Code review complete — P0 fixes applied, P1+ flagged for review", - artifacts_created: ["CODE-REVIEW.md"], + success: false, + output: "Code review requires an intelligence backend. Configure one with: ci init --backend", + artifacts_created: [], decisions: 0, escalations: 0, duration_ms: Date.now() - start, + error: "No intelligence backend available", }; } } \ No newline at end of file diff --git a/src/agents/debugger.ts b/src/agents/debugger.ts index c755856..08e25c8 100644 --- a/src/agents/debugger.ts +++ b/src/agents/debugger.ts @@ -3,17 +3,26 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js"; export class DebuggerAgent extends BaseAgent { readonly name = "debugger"; readonly description = "Autonomous debugging. Auto-fixes when root cause confidence > 0.60, escalates otherwise."; + readonly workflow = "debug"; async execute(context: AgentContext): Promise { - this.log("Running autonomous debug..."); const start = Date.now(); + this.log("Running autonomous debug..."); + if (context.backend) { + const result = await this.executeViaBackend( + context, + `Debug the following issue: ${context.specification}` + ); + return { ...result, duration_ms: Date.now() - start }; + } return { - success: true, - output: "Debug complete — issue identified and resolved", - artifacts_created: ["DEBUG.md"], + success: false, + output: "Debugging requires an intelligence backend. Configure one with: ci init --backend", + artifacts_created: [], decisions: 0, escalations: 0, duration_ms: Date.now() - start, + error: "No intelligence backend available", }; } } \ No newline at end of file diff --git a/src/agents/doc-verifier.ts b/src/agents/doc-verifier.ts index 8d5b950..e2fbd9e 100644 --- a/src/agents/doc-verifier.ts +++ b/src/agents/doc-verifier.ts @@ -3,17 +3,26 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js"; export class DocVerifierAgent extends BaseAgent { readonly name = "doc-verifier"; readonly description = "Verifies documentation matches live codebase."; + readonly workflow = "verify"; async execute(context: AgentContext): Promise { - this.log("Verifying documentation..."); const start = Date.now(); + this.log("Verifying documentation..."); + if (context.backend) { + const result = await this.executeViaBackend( + context, + `Verify documentation matches codebase for phase ${context.phase}.` + ); + return { ...result, duration_ms: Date.now() - start }; + } return { - success: true, - output: "Documentation verification complete", + success: false, + output: "Documentation verification requires an intelligence backend.", artifacts_created: [], decisions: 0, escalations: 0, duration_ms: Date.now() - start, + error: "No intelligence backend available", }; } } \ No newline at end of file diff --git a/src/agents/doc-writer.ts b/src/agents/doc-writer.ts index db91fa2..4b45d88 100644 --- a/src/agents/doc-writer.ts +++ b/src/agents/doc-writer.ts @@ -3,17 +3,26 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js"; export class DocWriterAgent extends BaseAgent { readonly name = "doc-writer"; readonly description = "Autonomous documentation writer. No behavioral changes from Learnship."; + readonly workflow = "execute"; async execute(context: AgentContext): Promise { - this.log("Writing documentation..."); const start = Date.now(); + this.log("Writing documentation..."); + if (context.backend) { + const result = await this.executeViaBackend( + context, + `Write documentation for phase ${context.phase}. Specification: ${context.specification}` + ); + return { ...result, duration_ms: Date.now() - start }; + } return { - success: true, - output: "Documentation written", - artifacts_created: ["DOCS.md"], + success: false, + output: "Documentation writing requires an intelligence backend.", + artifacts_created: [], decisions: 0, escalations: 0, duration_ms: Date.now() - start, + error: "No intelligence backend available", }; } } \ No newline at end of file diff --git a/src/agents/executor.ts b/src/agents/executor.ts index f3653a5..92bb2db 100644 --- a/src/agents/executor.ts +++ b/src/agents/executor.ts @@ -3,17 +3,26 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js"; export class ExecutorAgent extends BaseAgent { readonly name = "executor"; readonly description = "Executes plan tasks autonomously. Never pauses for checkpoints."; + readonly workflow = "execute"; async execute(context: AgentContext): Promise { - this.log("Executing tasks..."); 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 }; + } return { - success: true, - output: "Tasks executed", + success: false, + output: "Execution requires an intelligence backend. Configure one with: ci init --backend", artifacts_created: [], decisions: 0, escalations: 0, duration_ms: Date.now() - start, + error: "No intelligence backend available", }; } } \ No newline at end of file diff --git a/src/agents/ideation-agent.ts b/src/agents/ideation-agent.ts index 36a0856..56a093d 100644 --- a/src/agents/ideation-agent.ts +++ b/src/agents/ideation-agent.ts @@ -3,17 +3,26 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js"; export class IdeationAgent extends BaseAgent { readonly name = "ideation-agent"; readonly description = "Generates improvement ideas. Output feeds directly into planning pipeline."; + readonly workflow = "research"; async execute(context: AgentContext): Promise { - this.log("Generating improvement ideas..."); const start = Date.now(); + this.log("Generating improvement ideas..."); + if (context.backend) { + const result = await this.executeViaBackend( + context, + `Generate improvement ideas for: ${context.specification}` + ); + return { ...result, duration_ms: Date.now() - start }; + } return { - success: true, - output: "Ideation complete", - artifacts_created: ["IDEAS.md"], + success: false, + output: "Ideation requires an intelligence backend.", + artifacts_created: [], decisions: 0, escalations: 0, duration_ms: Date.now() - start, + error: "No intelligence backend available", }; } } \ No newline at end of file diff --git a/src/agents/index.ts b/src/agents/index.ts index de551ba..1e593ab 100644 --- a/src/agents/index.ts +++ b/src/agents/index.ts @@ -1,4 +1,4 @@ -export { BaseAgent } from "./base.js"; +export { BaseAgent, AgentContext, AgentResult, backendResultToAgentResult } from "./base.js"; export { OrchestratorAgent } from "./orchestrator.js"; export { PlannerAgent } from "./planner.js"; export { ExecutorAgent } from "./executor.js"; diff --git a/src/agents/orchestrator.ts b/src/agents/orchestrator.ts index 404c398..32d7255 100644 --- a/src/agents/orchestrator.ts +++ b/src/agents/orchestrator.ts @@ -6,7 +6,7 @@ import { GitContext, ProjectState } from "../core/git-context.js"; import { GitBranch } from "../core/git-branch.js"; import { CiFiles } from "../core/ci-files.js"; import { CommitBuilder } from "../core/commit-builder.js"; -import { CIConfig } from "../types/config.js"; +import { CIConfig, AgentName } from "../types/config.js"; import { PipelineState, PipelineStage, @@ -17,6 +17,8 @@ import { } from "../types/pipeline.js"; import { Specification, parseSpecification } from "../types/specification.js"; import { loadConfig, saveConfig, isCIInitialized, initCI } from "../core/config.js"; +import { getAgent } from "./index.js"; +import { IntelligenceBackend, BackendUnavailableError } from "../backends/types.js"; export interface GitAgentContext extends AgentContext { gitContext: GitContext; @@ -26,8 +28,9 @@ export interface GitAgentContext extends AgentContext { } export class OrchestratorAgent extends BaseAgent { - readonly name = "orchestrator"; + readonly name: AgentName = "orchestrator"; readonly description = "Top-level autonomous controller that coordinates the full CI pipeline"; + readonly workflow = "run"; private config: CIConfig; private pipelineState: PipelineState | null = null; @@ -39,6 +42,13 @@ export class OrchestratorAgent extends BaseAgent { private currentMilestone: string; private phaseResults: PhaseResult[] = []; + private static readonly STAGE_AGENT_MAP: Partial> = { + research: "researcher", + plan: "planner", + execute: "executor", + verify: "verifier", + }; + constructor(config?: CIConfig) { super(); this.config = config || loadConfig(process.cwd()); @@ -149,6 +159,32 @@ export class OrchestratorAgent extends BaseAgent { context: AgentContext ): Promise { const stageStart = Date.now(); + const agentName = OrchestratorAgent.STAGE_AGENT_MAP[stage]; + + if (agentName && context.backend) { + this.log(`Delegating ${stage} to ${agentName} agent via backend...`); + try { + const agent = getAgent(agentName); + const result = await agent.execute(context); + return { + phase: this.pipelineState!.current_phase, + stage, + success: result.success, + artifacts_created: Array.isArray(result.artifacts_created) ? result.artifacts_created : [], + decisions_made: result.decisions, + escalations_raised: result.escalations, + duration_ms: Date.now() - stageStart, + error: result.error, + }; + } catch (err) { + if (err instanceof BackendUnavailableError) { + this.warn(`Backend unavailable for ${stage}, falling back to mechanical execution`); + } else { + this.warn(`Agent ${agentName} failed for ${stage}: ${err instanceof Error ? err.message : String(err)}`); + } + } + } + let decisionsMade = 0; let escalationsRaised = 0; const artifactsCreated: string[] = []; diff --git a/src/agents/phase-researcher.ts b/src/agents/phase-researcher.ts index a8690d3..52f9197 100644 --- a/src/agents/phase-researcher.ts +++ b/src/agents/phase-researcher.ts @@ -3,17 +3,26 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js"; export class PhaseResearcherAgent extends BaseAgent { readonly name = "phase-researcher"; readonly description = "Researches how to implement a specific phase well."; + readonly workflow = "research"; async execute(context: AgentContext): Promise { - this.log("Researching phase implementation..."); const start = Date.now(); + this.log("Researching phase implementation..."); + if (context.backend) { + const result = await this.executeViaBackend( + context, + `Research how to implement phase ${context.phase} well. Specification: ${context.specification}` + ); + return { ...result, duration_ms: Date.now() - start }; + } return { - success: true, - output: "Phase research complete", - artifacts_created: ["RESEARCH.md"], + success: false, + output: "Phase research requires an intelligence backend.", + artifacts_created: [], decisions: 0, escalations: 0, duration_ms: Date.now() - start, + error: "No intelligence backend available", }; } } \ No newline at end of file diff --git a/src/agents/plan-checker.ts b/src/agents/plan-checker.ts index b6bddc1..ee4023c 100644 --- a/src/agents/plan-checker.ts +++ b/src/agents/plan-checker.ts @@ -3,17 +3,26 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js"; export class PlanCheckerAgent extends BaseAgent { readonly name = "plan-checker"; readonly description = "Verifies plan quality. On ISSUES FOUND, triggers automatic plan revision (up to 3 iterations)."; + readonly workflow = "plan"; async execute(context: AgentContext): Promise { - this.log("Checking plan quality..."); const start = Date.now(); + this.log("Checking plan quality..."); + if (context.backend) { + const result = await this.executeViaBackend( + context, + `Verify plan quality for phase ${context.phase}. Specification: ${context.specification}` + ); + return { ...result, duration_ms: Date.now() - start }; + } return { - success: true, - output: "Plan check passed", + success: false, + output: "Plan checking requires an intelligence backend.", artifacts_created: [], decisions: 0, escalations: 0, duration_ms: Date.now() - start, + error: "No intelligence backend available", }; } } \ No newline at end of file diff --git a/src/agents/planner.ts b/src/agents/planner.ts index 03f64a4..86470e1 100644 --- a/src/agents/planner.ts +++ b/src/agents/planner.ts @@ -3,17 +3,26 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js"; export class PlannerAgent extends BaseAgent { readonly name = "planner"; readonly description = "Creates phase plans with tasks. Never sets autonomous:false — decomposes into verifiable subtasks."; + readonly workflow = "plan"; async execute(context: AgentContext): Promise { - this.log("Creating phase plan..."); 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}` + ); + return { ...result, duration_ms: Date.now() - start }; + } return { - success: true, - output: "Plan created with verifiable subtasks", - artifacts_created: ["PLAN.md"], - decisions: 1, + success: false, + output: "Planning requires an intelligence backend. Configure one with: ci init --backend", + artifacts_created: [], + decisions: 0, escalations: 0, duration_ms: Date.now() - start, + error: "No intelligence backend available", }; } } \ No newline at end of file diff --git a/src/agents/project-researcher.ts b/src/agents/project-researcher.ts index 1cd08e4..5a01675 100644 --- a/src/agents/project-researcher.ts +++ b/src/agents/project-researcher.ts @@ -3,17 +3,26 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js"; export class ProjectResearcherAgent extends BaseAgent { readonly name = "project-researcher"; readonly description = "Researches the domain ecosystem for a new project."; + readonly workflow = "research"; async execute(context: AgentContext): Promise { - this.log("Researching project domain ecosystem..."); const start = Date.now(); + this.log("Researching project domain ecosystem..."); + if (context.backend) { + const result = await this.executeViaBackend( + context, + `Research the domain ecosystem for: ${context.specification}` + ); + return { ...result, duration_ms: Date.now() - start }; + } return { - success: true, - output: "Project research complete", - artifacts_created: ["RESEARCH.md"], + success: false, + output: "Project research requires an intelligence backend.", + artifacts_created: [], decisions: 0, escalations: 0, duration_ms: Date.now() - start, + error: "No intelligence backend available", }; } } \ No newline at end of file diff --git a/src/agents/research-synthesizer.ts b/src/agents/research-synthesizer.ts index 60180f0..96cb34b 100644 --- a/src/agents/research-synthesizer.ts +++ b/src/agents/research-synthesizer.ts @@ -3,17 +3,26 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js"; export class ResearchSynthesizerAgent extends BaseAgent { readonly name = "research-synthesizer"; readonly description = "Synthesizes research files into a cohesive summary for roadmap creation."; + readonly workflow = "research"; async execute(context: AgentContext): Promise { - this.log("Synthesizing research..."); const start = Date.now(); + this.log("Synthesizing research..."); + if (context.backend) { + const result = await this.executeViaBackend( + context, + `Synthesize research findings into a summary for: ${context.specification}` + ); + return { ...result, duration_ms: Date.now() - start }; + } return { - success: true, - output: "Research synthesis complete", - artifacts_created: ["SUMMARY.md"], + success: false, + output: "Research synthesis requires an intelligence backend.", + artifacts_created: [], decisions: 0, escalations: 0, duration_ms: Date.now() - start, + error: "No intelligence backend available", }; } } \ No newline at end of file diff --git a/src/agents/researcher.ts b/src/agents/researcher.ts index ef4e18c..5e2559e 100644 --- a/src/agents/researcher.ts +++ b/src/agents/researcher.ts @@ -3,17 +3,26 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js"; export class ResearcherAgent extends BaseAgent { readonly name = "researcher"; readonly description = "Researches project domain. Logs assumptions instead of asking for validation."; + readonly workflow = "research"; async execute(context: AgentContext): Promise { - this.log("Researching domain..."); const start = Date.now(); + this.log("Researching domain..."); + if (context.backend) { + const result = await this.executeViaBackend( + context, + `Research the domain for: ${context.specification}` + ); + return { ...result, duration_ms: Date.now() - start }; + } return { - success: true, - output: "Research complete", - artifacts_created: ["RESEARCH.md"], - decisions: 1, + success: false, + output: "Research requires an intelligence backend. Configure one with: ci init --backend", + artifacts_created: [], + decisions: 0, escalations: 0, duration_ms: Date.now() - start, + error: "No intelligence backend available", }; } } \ No newline at end of file diff --git a/src/agents/roadmapper.ts b/src/agents/roadmapper.ts index b7195ed..8e6e265 100644 --- a/src/agents/roadmapper.ts +++ b/src/agents/roadmapper.ts @@ -3,17 +3,26 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js"; export class RoadmapperAgent extends BaseAgent { readonly name = "roadmapper"; readonly description = "Creates and maintains project roadmaps."; + readonly workflow = "plan"; async execute(context: AgentContext): Promise { - this.log("Creating roadmap..."); const start = Date.now(); + this.log("Creating roadmap..."); + if (context.backend) { + const result = await this.executeViaBackend( + context, + `Create project roadmap for: ${context.specification}` + ); + return { ...result, duration_ms: Date.now() - start }; + } return { - success: true, - output: "Roadmap created", - artifacts_created: ["ROADMAP.md"], - decisions: 1, + success: false, + output: "Roadmap creation requires an intelligence backend.", + artifacts_created: [], + decisions: 0, escalations: 0, duration_ms: Date.now() - start, + error: "No intelligence backend available", }; } } \ No newline at end of file diff --git a/src/agents/security-auditor.ts b/src/agents/security-auditor.ts index 429aee4..f5addd0 100644 --- a/src/agents/security-auditor.ts +++ b/src/agents/security-auditor.ts @@ -3,17 +3,26 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js"; export class SecurityAuditorAgent extends BaseAgent { readonly name = "security-auditor"; readonly description = "Auto-dispositions threats: low=accept, medium=mitigate, high=escalate."; + readonly workflow = "verify"; async execute(context: AgentContext): Promise { - this.log("Running security audit..."); const start = Date.now(); + this.log("Running security audit..."); + if (context.backend) { + const result = await this.executeViaBackend( + context, + `Perform security audit for phase ${context.phase}. Specification: ${context.specification}` + ); + return { ...result, duration_ms: Date.now() - start }; + } return { - success: true, - output: "Security audit complete", - artifacts_created: ["SECURITY.md"], - decisions: 1, + success: false, + output: "Security auditing requires an intelligence backend. Configure one with: ci init --backend", + artifacts_created: [], + decisions: 0, escalations: 0, duration_ms: Date.now() - start, + error: "No intelligence backend available", }; } } \ No newline at end of file diff --git a/src/agents/solution-writer.ts b/src/agents/solution-writer.ts index a6cf880..49cc0a2 100644 --- a/src/agents/solution-writer.ts +++ b/src/agents/solution-writer.ts @@ -3,17 +3,26 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js"; export class SolutionWriterAgent extends BaseAgent { readonly name = "solution-writer"; readonly description = "Produces structured solution documents for .planning/solutions/."; + readonly workflow = "execute"; async execute(context: AgentContext): Promise { - this.log("Writing solution document..."); const start = Date.now(); + this.log("Writing solution document..."); + if (context.backend) { + const result = await this.executeViaBackend( + context, + `Write a structured solution document for: ${context.specification}` + ); + return { ...result, duration_ms: Date.now() - start }; + } return { - success: true, - output: "Solution document written", - artifacts_created: ["SOLUTION.md"], + success: false, + output: "Solution writing requires an intelligence backend.", + artifacts_created: [], decisions: 0, escalations: 0, duration_ms: Date.now() - start, + error: "No intelligence backend available", }; } } \ No newline at end of file diff --git a/src/agents/verifier.ts b/src/agents/verifier.ts index 0908904..bfb712b 100644 --- a/src/agents/verifier.ts +++ b/src/agents/verifier.ts @@ -3,17 +3,26 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js"; export class VerifierAgent extends BaseAgent { readonly name = "verifier"; readonly description = "Verifies phase outputs. Generates automated tests instead of requesting human UAT."; + readonly workflow = "verify"; async execute(context: AgentContext): Promise { - this.log("Verifying phase output..."); 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}` + ); + return { ...result, duration_ms: Date.now() - start }; + } return { - success: true, - output: "Verification complete — all checks passed", - artifacts_created: ["VERIFICATION.md"], + success: false, + output: "Verification requires an intelligence backend. Configure one with: ci init --backend", + artifacts_created: [], decisions: 0, escalations: 0, duration_ms: Date.now() - start, + error: "No intelligence backend available", }; } } \ No newline at end of file diff --git a/src/backends/index.ts b/src/backends/index.ts new file mode 100644 index 0000000..150e406 --- /dev/null +++ b/src/backends/index.ts @@ -0,0 +1,55 @@ +import { IntelligenceBackend, BackendConfigSection, BackendUnavailableError } from "./types.js"; +import { OpencodeBackend } from "./opencode.js"; +import { OllamaLocalBackend } from "./ollama-local.js"; +import { OllamaCloudBackend } from "./ollama-cloud.js"; + +const AUTO_DETECT_ORDER: Array<"opencode" | "ollama-local" | "ollama-cloud"> = [ + "opencode", + "ollama-local", + "ollama-cloud", +]; + +export function createBackend( + name: string, + config: BackendConfigSection +): IntelligenceBackend { + switch (name) { + case "opencode": + return new OpencodeBackend(config.agent_backends.opencode); + case "ollama-local": + return new OllamaLocalBackend(config.llm_backends["ollama-local"]); + case "ollama-cloud": + return new OllamaCloudBackend(config.llm_backends["ollama-cloud"]); + default: + throw new BackendUnavailableError(name); + } +} + +export async function resolveBackend( + config: BackendConfigSection +): Promise { + if (config.provider !== "auto") { + const backend = createBackend(config.provider, config); + if (!(await backend.isAvailable())) { + throw new BackendUnavailableError(config.provider); + } + return backend; + } + + for (const name of AUTO_DETECT_ORDER) { + try { + const backend = createBackend(name, config); + if (await backend.isAvailable()) { + return backend; + } + } catch {} + } + + throw new BackendUnavailableError("auto"); +} + +export { IntelligenceBackend, BackendConfigSection, BackendUnavailableError } from "./types.js"; +export { ToolRegistry, ToolDefinition, ToolCall, ToolResult } from "./tool-registry.js"; +export { OpencodeBackend } from "./opencode.js"; +export { OllamaLocalBackend } from "./ollama-local.js"; +export { OllamaCloudBackend } from "./ollama-cloud.js"; \ No newline at end of file diff --git a/src/backends/ollama-base.ts b/src/backends/ollama-base.ts new file mode 100644 index 0000000..a06fe3a --- /dev/null +++ b/src/backends/ollama-base.ts @@ -0,0 +1,317 @@ +import { execSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import { + IntelligenceBackend, + BackendRequest, + BackendResult, + BackendType, + LLMBackendConfig, + TokenUsage, + Artifact, + emptyTokenUsage, + emptyBackendResult, +} from "./types.js"; +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"; + +const MAX_TOOL_ROUNDS = 50; + +export abstract class OllamaBaseBackend implements IntelligenceBackend { + abstract readonly name: string; + readonly type: BackendType = "llm"; + + protected config: LLMBackendConfig; + protected projectPath: string; + + constructor(config: LLMBackendConfig | undefined) { + this.config = config || { base_url: "http://localhost:11434", model_profile: "balanced" }; + this.projectPath = process.cwd(); + } + + abstract isAvailable(): Promise; + + async execute(request: BackendRequest): Promise { + const startTime = Date.now(); + + try { + const personaContent = this.loadPersona(request.persona); + const workflowContent = this.loadWorkflow(request.workflow); + const model = this.resolveModel(); + + const toolRegistry = new ToolRegistry(request.context.project_path); + + const messages: OllamaMessage[] = []; + messages.push({ + role: "system", + content: this.buildSystemPrompt(personaContent, workflowContent, request), + }); + messages.push({ + role: "user", + content: request.task, + }); + + let totalInputTokens = 0; + let totalOutputTokens = 0; + let round = 0; + const allArtifacts: Artifact[] = []; + const allDecisions: Decision[] = []; + const allEscalations: Escalation[] = []; + + while (round < MAX_TOOL_ROUNDS) { + round++; + const response = await this.callModel(messages, model, toolRegistry); + + totalInputTokens += response.usage?.prompt_tokens || 0; + totalOutputTokens += response.usage?.completion_tokens || 0; + + const assistantContent = response.choices?.[0]?.message?.content || ""; + const toolCalls = response.choices?.[0]?.message?.tool_calls; + + messages.push({ + role: "assistant", + content: assistantContent, + tool_calls: toolCalls, + }); + + if (!toolCalls || toolCalls.length === 0) { + return this.parseFinalResponse(assistantContent, allArtifacts, allDecisions, allEscalations, { + input_tokens: totalInputTokens, + output_tokens: totalOutputTokens, + total_tokens: totalInputTokens + totalOutputTokens, + estimated_cost_usd: 0, + }); + } + + for (const toolCall of toolCalls) { + const call: ToolCall = { + name: toolCall.function.name, + arguments: JSON.parse(toolCall.function.arguments), + }; + const result = toolRegistry.execute(call); + messages.push({ + role: "tool", + name: call.name, + content: result.content, + }); + + if (call.name === "writeFile" && !result.isError) { + allArtifacts.push({ + path: String(call.arguments.path), + content: String(call.arguments.content), + operation: "create", + }); + } + } + } + + const finalContent = messages + .filter((m) => m.role === "assistant" && m.content) + .map((m) => m.content) + .join("\n"); + + return this.parseFinalResponse( + `Tool loop reached maximum rounds (${MAX_TOOL_ROUNDS}). Partial progress:\n${finalContent}`, + allArtifacts, + allDecisions, + allEscalations, + { input_tokens: totalInputTokens, output_tokens: totalOutputTokens, total_tokens: totalInputTokens + totalOutputTokens, estimated_cost_usd: 0 } + ); + } catch (err) { + return emptyBackendResult(`Backend execution failed: ${err instanceof Error ? err.message : String(err)}`); + } + } + + protected abstract callModel( + messages: OllamaMessage[], + model: string, + toolRegistry: ToolRegistry + ): Promise; + + protected abstract resolveModel(): string; + + protected buildSystemPrompt(persona: string, workflow: string, request: BackendRequest): string { + const parts = [persona]; + if (workflow) { + parts.push("", "## Workflow Instructions", workflow); + } + parts.push( + "", + "## Execution Context", + `Autonomy level: ${request.autonomy}`, + `Project path: ${request.context.project_path}`, + `Phase: ${request.context.phase}`, + `Stage: ${request.context.stage}`, + "", + "## Output Format", + "When you have completed your task, output a JSON object with this structure:", + "```json", + '{', + ' "success": true,', + ' "output": "Summary of what was accomplished",', + ' "artifacts": [{"path": "file/path", "content": "...", "operation": "create"}],', + ' "decisions": [{"id": "D-NNN", "decision": "what", "rationale": "why", "confidence": 0.85, "category": "general", "alternatives_considered": [], "learnship_equivalent": "", "human_override": null, "timestamp": ""}],', + ' "escalations": []', + '}', + "```" + ); + return parts.join("\n"); + } + + protected loadPersona(persona: AgentName): string { + const candidates = [ + path.join(os.homedir(), ".config", "opencode", "agents", `ci-${persona}.md`), + path.join(process.cwd(), "opencode", "agents", `ci-${persona}.md`), + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return fs.readFileSync(candidate, "utf-8"); + } + } + return `You are the CI ${persona} agent. Execute the requested task thoroughly and autonomously.`; + } + + protected loadWorkflow(workflow: string): string { + const candidates = [ + path.join(os.homedir(), ".config", "opencode", "ci", "workflows", `${workflow}.md`), + path.join(process.cwd(), "opencode", "workflows", `${workflow}.md`), + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return fs.readFileSync(candidate, "utf-8"); + } + } + return ""; + } + + protected parseFinalResponse( + content: string, + artifacts: Artifact[], + decisions: Decision[], + escalations: Escalation[], + usage: TokenUsage + ): BackendResult { + const jsonMatch = content.match(/\{[\s\S]*"success"[\s\S]*\}/); + if (jsonMatch) { + try { + const parsed = JSON.parse(jsonMatch[0]); + return { + success: parsed.success ?? true, + output: parsed.output || content, + artifacts: parsed.artifacts?.length ? this.parseArtifacts(parsed.artifacts) : artifacts, + decisions: parsed.decisions?.length ? this.parseDecisions(parsed.decisions) : decisions, + escalations: parsed.escalations?.length ? this.parseEscalations(parsed.escalations) : escalations, + usage, + }; + } catch {} + } + + return { + success: true, + output: content, + artifacts, + decisions, + escalations, + usage, + }; + } + + private parseArtifacts(raw: unknown[]): Artifact[] { + return raw.filter((a): a is Record => !!a).map((a) => ({ + path: String(a.path || ""), + content: String(a.content || ""), + operation: (a.operation as Artifact["operation"]) || "create", + })); + } + + private parseDecisions(raw: unknown[]): Decision[] { + return raw.filter((d): d is Record => !!d).map((d) => ({ + id: String(d.id || "D-000"), + decision: String(d.decision || ""), + rationale: String(d.rationale || ""), + confidence: Number(d.confidence || 0.5), + category: (d.category as Decision["category"]) || "general", + alternatives_considered: Array.isArray(d.alternatives_considered) + ? d.alternatives_considered.map((a: unknown) => + typeof a === "string" + ? { option: a, rejected_reason: "" } + : (a as { option: string; rejected_reason: string }) + ) + : [], + learnship_equivalent: String(d.learnship_equivalent || ""), + human_override: d.human_override ? String(d.human_override) : null, + timestamp: String(d.timestamp || new Date().toISOString()), + })); + } + + private parseEscalations(raw: unknown[]): Escalation[] { + return raw.filter((e): e is Record => !!e).map((e) => ({ + id: String(e.id || "E-000"), + timestamp: String(e.timestamp || new Date().toISOString()), + type: (e.type as Escalation["type"]) || "specification_ambiguity", + phase: String(e.phase || ""), + description: String(e.description || ""), + context: String(e.context || ""), + options: Array.isArray(e.options) ? e.options : [], + default_option_id: String(e.default_option_id || ""), + resolution: (e.resolution as Escalation["resolution"]) || "pending", + audit_file: String(e.audit_file || ""), + })); + } + + protected modelProfileToModel(profile: ModelProfile, availableModels: string[]): string { + if (availableModels.length === 0) return "llama3.1"; + + const sorted = [...availableModels].sort((a, b) => a.length - b.length); + switch (profile) { + case "speed": + return sorted[0]; + case "quality": + return sorted[sorted.length - 1]; + case "balanced": + default: + return sorted[Math.floor(sorted.length / 2)] || sorted[0]; + } + } + + protected async fetchAvailableModels(): Promise { + try { + const response = await fetch(`${this.config.base_url}/api/tags`); + if (!response.ok) return []; + const data = await response.json() as { models?: Array<{ name: string }> }; + return (data.models || []).map((m) => m.name); + } catch { + return []; + } + } +} + +interface OllamaMessage { + role: "system" | "user" | "assistant" | "tool"; + content: string; + name?: string; + tool_calls?: Array<{ + function: { name: string; arguments: string }; + }>; +} + +interface OllamaChatResponse { + choices?: Array<{ + message: { + content: string; + tool_calls?: Array<{ + function: { name: string; arguments: string }; + }>; + }; + }>; + usage?: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }; +} + +export { OllamaMessage, OllamaChatResponse }; \ No newline at end of file diff --git a/src/backends/ollama-cloud.ts b/src/backends/ollama-cloud.ts new file mode 100644 index 0000000..28ef92d --- /dev/null +++ b/src/backends/ollama-cloud.ts @@ -0,0 +1,139 @@ +import { OllamaBaseBackend, OllamaMessage, OllamaChatResponse } from "./ollama-base.js"; +import { OllamaCloudConfig, emptyBackendResult } from "./types.js"; +import { ToolRegistry } from "./tool-registry.js"; + +const MAX_RETRIES = 3; +const BASE_BACKOFF_MS = 1000; + +export class OllamaCloudBackend extends OllamaBaseBackend { + readonly name = "ollama-cloud"; + + private cloudConfig: OllamaCloudConfig; + private apiKey: string | null; + + constructor(config?: OllamaCloudConfig) { + super(config); + this.cloudConfig = config || { + base_url: "", + api_key_env: "OLLAMA_CLOUD_API_KEY", + model_profile: "quality", + timeout_ms: 60000, + }; + this.apiKey = this.resolveApiKey(); + } + + async isAvailable(): Promise { + if (!this.cloudConfig.base_url) return false; + if (!this.apiKey) return false; + + try { + const response = await fetch(`${this.cloudConfig.base_url}/v1/models`, { + headers: this.getAuthHeaders(), + signal: AbortSignal.timeout(10000), + }); + return response.ok; + } catch { + return false; + } + } + + protected resolveModel(): string { + if (this.cloudConfig.model) return this.cloudConfig.model; + return "llama3.1:70b"; + } + + protected async callModel( + messages: OllamaMessage[], + model: string, + toolRegistry: ToolRegistry + ): Promise { + if (!this.apiKey) { + throw new Error(`API key not found. Set ${this.cloudConfig.api_key_env} environment variable.`); + } + + const url = `${this.cloudConfig.base_url}/v1/chat/completions`; + + const body: Record = { + model, + messages: messages.map((m) => { + const msg: Record = { role: m.role, content: m.content }; + if (m.name) msg.name = m.name; + if (m.tool_calls) msg.tool_calls = m.tool_calls; + return msg; + }), + tools: toolRegistry.getOpenAIToolSchema(), + stream: false, + }; + + return this.callWithRetry(url, body); + } + + private async callWithRetry( + url: string, + body: Record, + attempt: number = 0 + ): Promise { + const timeout = this.cloudConfig.timeout_ms || 60000; + + try { + const response = await fetch(url, { + method: "POST", + headers: { + ...this.getAuthHeaders(), + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(timeout), + }); + + if (response.status === 429 && attempt < MAX_RETRIES) { + const retryAfter = response.headers.get("Retry-After"); + const delay = retryAfter + ? parseInt(retryAfter) * 1000 + : BASE_BACKOFF_MS * Math.pow(2, attempt); + + await this.sleep(delay); + return this.callWithRetry(url, body, attempt + 1); + } + + if (response.status === 401 || response.status === 403) { + throw new Error(`Authentication failed. Check ${this.cloudConfig.api_key_env} environment variable.`); + } + + if (response.status === 402) { + throw new Error("Quota exceeded. Check your Ollama Cloud billing status."); + } + + if (!response.ok) { + const errorText = await response.text().catch(() => "unknown error"); + throw new Error(`Ollama Cloud API error (${response.status}): ${errorText}`); + } + + return (await response.json()) as OllamaChatResponse; + } catch (err) { + if (err instanceof TypeError && err.message.includes("fetch")) { + if (attempt < MAX_RETRIES) { + await this.sleep(BASE_BACKOFF_MS * Math.pow(2, attempt)); + return this.callWithRetry(url, body, attempt + 1); + } + } + throw err; + } + } + + private getAuthHeaders(): Record { + const headers: Record = {}; + if (this.apiKey) { + headers["Authorization"] = `Bearer ${this.apiKey}`; + } + return headers; + } + + private resolveApiKey(): string | null { + return process.env[this.cloudConfig.api_key_env] || null; + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} \ No newline at end of file diff --git a/src/backends/ollama-local.ts b/src/backends/ollama-local.ts new file mode 100644 index 0000000..595da62 --- /dev/null +++ b/src/backends/ollama-local.ts @@ -0,0 +1,81 @@ +import { OllamaBaseBackend, OllamaMessage, OllamaChatResponse } from "./ollama-base.js"; +import { OllamaLocalConfig } from "./types.js"; +import { ToolRegistry } from "./tool-registry.js"; + +export class OllamaLocalBackend extends OllamaBaseBackend { + readonly name = "ollama-local"; + + private localConfig: OllamaLocalConfig; + + constructor(config?: OllamaLocalConfig) { + super(config); + this.localConfig = config || { base_url: "http://localhost:11434", model_profile: "balanced" }; + } + + async isAvailable(): Promise { + try { + const response = await fetch(`${this.localConfig.base_url}/api/tags`, { + signal: AbortSignal.timeout(5000), + }); + return response.ok; + } catch { + return false; + } + } + + protected resolveModel(): string { + if (this.localConfig.model) return this.localConfig.model; + const models = this.fetchAvailableModelsSync(); + return this.modelProfileToModel(this.localConfig.model_profile, models); + } + + protected async callModel( + messages: OllamaMessage[], + model: string, + toolRegistry: ToolRegistry + ): Promise { + const url = `${this.localConfig.base_url}/v1/chat/completions`; + + const body: Record = { + model, + messages: messages.map((m) => { + const msg: Record = { role: m.role, content: m.content }; + if (m.name) msg.name = m.name; + if (m.tool_calls) msg.tool_calls = m.tool_calls; + return msg; + }), + tools: toolRegistry.getOpenAIToolSchema(), + stream: false, + }; + + const timeout = this.localConfig.timeout_ms || 10000; + + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(timeout), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => "unknown error"); + throw new Error(`Ollama local API error (${response.status}): ${errorText}`); + } + + return (await response.json()) as OllamaChatResponse; + } + + private fetchAvailableModelsSync(): string[] { + try { + const { execSync } = require("node:child_process"); + const result = execSync(`curl -s ${this.localConfig.base_url}/api/tags`, { + encoding: "utf-8", + timeout: 5000, + }); + const data = JSON.parse(result) as { models?: Array<{ name: string }> }; + return (data.models || []).map((m) => m.name); + } catch { + return []; + } + } +} \ No newline at end of file diff --git a/src/backends/opencode.ts b/src/backends/opencode.ts new file mode 100644 index 0000000..a25f123 --- /dev/null +++ b/src/backends/opencode.ts @@ -0,0 +1,183 @@ +import { execSync, spawn } from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import { + IntelligenceBackend, + BackendRequest, + BackendResult, + BackendType, + OpencodeBackendConfig, + emptyTokenUsage, + emptyBackendResult, +} from "./types.js"; + +export class OpencodeBackend implements IntelligenceBackend { + readonly name = "opencode"; + readonly type: BackendType = "agent"; + + private config: OpencodeBackendConfig; + + constructor(config?: OpencodeBackendConfig) { + this.config = config || { enabled: true }; + } + + async isAvailable(): Promise { + const executable = this.config.executable || "opencode"; + try { + const result = execSync(`${executable} --version`, { + encoding: "utf-8", + timeout: 5000, + stdio: "pipe", + }); + return !!result; + } catch { + return false; + } + } + + async execute(request: BackendRequest): Promise { + const executable = this.config.executable || "opencode"; + const startTime = Date.now(); + + try { + const serializedRequest = this.serializeRequest(request); + const tempFile = path.join( + os.tmpdir(), + `ci-request-${request.persona}-${Date.now()}.json` + ); + + fs.writeFileSync(tempFile, serializedRequest, "utf-8"); + + const command = `${executable} --non-interactive "/ci-${request.workflow} ${request.task}"`; + const contextEnv = { + ...process.env, + CI_BACKEND_REQUEST: tempFile, + CI_PROJECT_PATH: request.context.project_path, + CI_PHASE: String(request.context.phase), + CI_STAGE: request.context.stage, + CI_AUTONOMY: request.autonomy, + }; + + const result = execSync(command, { + cwd: request.context.project_path, + encoding: "utf-8", + timeout: 600000, + env: contextEnv, + stdio: ["pipe", "pipe", "pipe"], + maxBuffer: 10 * 1024 * 1024, + }); + + try { + fs.unlinkSync(tempFile); + } catch {} + + return this.parseResult(result, Date.now() - startTime); + } catch (err) { + const execErr = err as { stderr?: string; status?: number }; + + try { + const tempFile = path.join( + os.tmpdir(), + `ci-request-${request.persona}-${startTime}.json` + ); + if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile); + } catch {} + + if (execErr.stderr) { + return emptyBackendResult( + `opencode execution failed (exit ${execErr.status || "unknown"}): ${execErr.stderr}` + ); + } + + return emptyBackendResult( + `opencode backend error: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + + private serializeRequest(request: BackendRequest): string { + return JSON.stringify({ + persona: request.persona, + workflow: request.workflow, + task: request.task, + context: { + project_path: request.context.project_path, + phase: request.context.phase, + stage: request.context.stage, + specification: request.context.specification, + config_path: request.context.config_path, + }, + autonomy: request.autonomy, + }, null, 2); + } + + private parseResult(output: string, durationMs: number): BackendResult { + const jsonMatch = output.match(/\{[\s\S]*"success"[\s\S]*\}/); + if (jsonMatch) { + try { + const parsed = JSON.parse(jsonMatch[0]); + return { + success: parsed.success ?? true, + output: parsed.output || output, + artifacts: Array.isArray(parsed.artifacts) + ? parsed.artifacts.filter((a: unknown) => !!a).map((a: Record) => ({ + path: String(a.path || ""), + content: String(a.content || ""), + operation: (a.operation as "create" | "update" | "delete") || "create", + })) + : [], + decisions: Array.isArray(parsed.decisions) + ? parsed.decisions.filter((d: unknown) => !!d).map((d: Record) => ({ + id: String(d.id || "D-000"), + decision: String(d.decision || ""), + rationale: String(d.rationale || ""), + confidence: Number(d.confidence || 0.5), + category: (d.category as "implementation_approach" | "technology_choice" | "architecture" | "scope" | "verification" | "security" | "deployment" | "general") || "general", + alternatives_considered: Array.isArray(d.alternatives_considered) + ? d.alternatives_considered.map((a: unknown) => + typeof a === "string" + ? { option: a, rejected_reason: "" } + : (a as { option: string; rejected_reason: string }) + ) + : [], + learnship_equivalent: String(d.learnship_equivalent || ""), + human_override: d.human_override ? String(d.human_override) : null, + timestamp: String(d.timestamp || new Date().toISOString()), + })) + : [], + escalations: Array.isArray(parsed.escalations) + ? parsed.escalations.filter((e: unknown) => !!e).map((e: Record) => ({ + id: String(e.id || "E-000"), + timestamp: String(e.timestamp || new Date().toISOString()), + type: (e.type as "irreversible_action" | "verification_failure" | "low_confidence_decision" | "security_escalation" | "specification_ambiguity") || "specification_ambiguity", + phase: String(e.phase || ""), + description: String(e.description || ""), + context: String(e.context || ""), + options: Array.isArray(e.options) ? e.options : [], + default_option_id: String(e.default_option_id || ""), + resolution: (e.resolution as "approved" | "rejected" | "modified" | "pending" | "timeout_auto_proceed") || "pending", + audit_file: String(e.audit_file || ""), + })) + : [], + usage: parsed.usage || { + ...emptyTokenUsage(), + total_tokens: Math.ceil(output.length / 4), + }, + }; + } catch {} + } + + return { + success: true, + output, + artifacts: [], + decisions: [], + escalations: [], + usage: { + ...emptyTokenUsage(), + total_tokens: Math.ceil(output.length / 4), + }, + }; + } +} \ No newline at end of file diff --git a/src/backends/tool-registry.ts b/src/backends/tool-registry.ts new file mode 100644 index 0000000..adfc7e2 --- /dev/null +++ b/src/backends/tool-registry.ts @@ -0,0 +1,299 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { execSync } from "node:child_process"; + +export interface ToolDefinition { + name: string; + description: string; + parameters: { + type: "object"; + properties: Record; + required: string[]; + }; +} + +export interface ToolCall { + name: string; + arguments: Record; +} + +export interface ToolResult { + name: string; + content: string; + isError?: boolean; +} + +export const TOOL_DEFINITIONS: ToolDefinition[] = [ + { + name: "readFile", + description: "Read the contents of a file at the given path", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "Absolute file path to read" }, + }, + required: ["path"], + }, + }, + { + name: "writeFile", + description: "Write content to a file, creating it if it doesn't exist", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "Absolute file path to write" }, + content: { type: "string", description: "Content to write to the file" }, + }, + required: ["path", "content"], + }, + }, + { + name: "editFile", + description: "Replace an exact string in a file with a new string", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "Absolute file path to edit" }, + old: { type: "string", description: "Exact string to find in the file" }, + new: { type: "string", description: "String to replace it with" }, + }, + required: ["path", "old", "new"], + }, + }, + { + name: "runBash", + description: "Execute a bash command and return stdout/stderr", + parameters: { + type: "object", + properties: { + command: { type: "string", description: "Bash command to execute" }, + cwd: { type: "string", description: "Working directory for the command" }, + timeout: { type: "number", description: "Timeout in milliseconds (default 30000)" }, + }, + required: ["command"], + }, + }, + { + name: "glob", + description: "Find files matching a glob pattern recursively", + parameters: { + type: "object", + properties: { + pattern: { type: "string", description: "Glob pattern (e.g. **/*.ts)" }, + cwd: { type: "string", description: "Directory to search in" }, + }, + required: ["pattern"], + }, + }, + { + name: "grep", + description: "Search file contents using a regular expression", + parameters: { + type: "object", + properties: { + pattern: { type: "string", description: "Regex pattern to search for" }, + include: { type: "string", description: "File pattern to include (e.g. *.ts)" }, + cwd: { type: "string", description: "Directory to search in" }, + }, + required: ["pattern"], + }, + }, +]; + +export class ToolRegistry { + private projectPath: string; + private maxFileSize: number; + + constructor(projectPath: string, maxFileSize: number = 1024 * 1024) { + this.projectPath = projectPath; + this.maxFileSize = maxFileSize; + } + + execute(call: ToolCall): ToolResult { + try { + switch (call.name) { + case "readFile": + return this.readFile(call.arguments); + case "writeFile": + return this.writeFile(call.arguments); + case "editFile": + return this.editFile(call.arguments); + case "runBash": + return this.runBash(call.arguments); + case "glob": + return this.glob(call.arguments); + case "grep": + return this.grep(call.arguments); + default: + return { name: call.name, content: `Unknown tool: ${call.name}`, isError: true }; + } + } catch (err) { + return { + name: call.name, + content: `Tool error: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } + } + + getDefinitions(): ToolDefinition[] { + return TOOL_DEFINITIONS; + } + + getOpenAIToolSchema(): Array> { + return TOOL_DEFINITIONS.map((def) => ({ + type: "function", + function: { + name: def.name, + description: def.description, + parameters: def.parameters, + }, + })); + } + + private readFile(args: Record): ToolResult { + const filePath = String(args.path); + if (!fs.existsSync(filePath)) { + return { name: "readFile", content: `File not found: ${filePath}`, isError: true }; + } + try { + const stat = fs.statSync(filePath); + if (stat.size > this.maxFileSize) { + return { name: "readFile", content: `File too large: ${filePath} (${stat.size} bytes)`, isError: true }; + } + const content = fs.readFileSync(filePath, "utf-8"); + return { name: "readFile", content }; + } catch (err) { + return { name: "readFile", content: `Read error: ${err instanceof Error ? err.message : String(err)}`, isError: true }; + } + } + + private writeFile(args: Record): ToolResult { + const filePath = String(args.path); + const content = String(args.content); + try { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(filePath, content, "utf-8"); + return { name: "writeFile", content: `Written: ${filePath}` }; + } catch (err) { + return { name: "writeFile", content: `Write error: ${err instanceof Error ? err.message : String(err)}`, isError: true }; + } + } + + private editFile(args: Record): ToolResult { + const filePath = String(args.path); + const oldStr = String(args.old); + const newStr = String(args.new); + if (!fs.existsSync(filePath)) { + return { name: "editFile", content: `File not found: ${filePath}`, isError: true }; + } + try { + const content = fs.readFileSync(filePath, "utf-8"); + if (!content.includes(oldStr)) { + return { name: "editFile", content: `String not found in ${filePath}`, isError: true }; + } + const updated = content.replace(oldStr, newStr); + fs.writeFileSync(filePath, updated, "utf-8"); + return { name: "editFile", content: `Edited: ${filePath}` }; + } catch (err) { + return { name: "editFile", content: `Edit error: ${err instanceof Error ? err.message : String(err)}`, isError: true }; + } + } + + private runBash(args: Record): ToolResult { + const command = String(args.command); + const cwd = args.cwd ? String(args.cwd) : this.projectPath; + const timeout = args.timeout ? Number(args.timeout) : 30000; + try { + const stdout = execSync(command, { + cwd, + timeout, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + maxBuffer: 1024 * 1024, + }); + return { name: "runBash", content: stdout || "(no output)" }; + } catch (err: unknown) { + const execErr = err as { stderr?: string; stdout?: string; status?: number }; + const output = [`Exit code: ${execErr.status || 1}`, `stdout: ${execErr.stdout || ""}`, `stderr: ${execErr.stderr || ""}`].join("\n"); + return { name: "runBash", content: output, isError: true }; + } + } + + private glob(args: Record): ToolResult { + const pattern = String(args.pattern); + const cwd = args.cwd ? String(args.cwd) : this.projectPath; + const matches = this.globRecursive(cwd, pattern); + return { name: "glob", content: JSON.stringify(matches.slice(0, 200)) }; + } + + private grep(args: Record): ToolResult { + const pattern = String(args.pattern); + const cwd = args.cwd ? String(args.cwd) : this.projectPath; + const include = args.include ? String(args.include) : undefined; + const matches = this.grepRecursive(cwd, pattern, include); + return { name: "grep", content: JSON.stringify(matches.slice(0, 100)) }; + } + + private globRecursive(dir: string, pattern: string): string[] { + const results: string[] = []; + const regex = this.globToRegex(pattern); + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === ".git") continue; + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...this.globRecursive(fullPath, pattern)); + } else if (regex.test(entry.name) || regex.test(path.relative(this.projectPath, fullPath))) { + results.push(path.relative(this.projectPath, fullPath)); + } + } + } catch {} + return results.sort(); + } + + private globToRegex(pattern: string): RegExp { + const escaped = pattern + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*\*/g, "{{GLOBSTAR}}") + .replace(/\*/g, "[^/]*") + .replace(/{{GLOBSTAR}}/g, ".*") + .replace(/\?/g, "[^/]"); + return new RegExp(`^${escaped}$`); + } + + private grepRecursive(dir: string, patternStr: string, include?: string): Array<{ file: string; line: number; content: string }> { + const results: Array<{ file: string; line: number; content: string }> = []; + const regex = new RegExp(patternStr); + const includeRegex = include ? this.globToRegex(include) : null; + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === ".git") continue; + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...this.grepRecursive(fullPath, patternStr, include)); + } else if (includeRegex ? includeRegex.test(entry.name) : true) { + try { + const content = fs.readFileSync(fullPath, "utf-8"); + const lines = content.split("\n"); + for (let i = 0; i < lines.length; i++) { + if (regex.test(lines[i])) { + results.push({ + file: path.relative(this.projectPath, fullPath), + line: i + 1, + content: lines[i].trim(), + }); + } + } + } catch {} + } + } + } catch {} + return results; + } +} \ No newline at end of file diff --git a/src/backends/types.ts b/src/backends/types.ts new file mode 100644 index 0000000..1f683e0 --- /dev/null +++ b/src/backends/types.ts @@ -0,0 +1,137 @@ +import { AgentName, AutonomyLevel, ModelProfile } from "../types/config.js"; +import { AgentContext } from "../agents/base.js"; +import { Decision } from "../types/decisions.js"; +import { Escalation } from "../types/escalation.js"; + +export type BackendType = "llm" | "agent"; + +export interface BackendRequest { + persona: AgentName; + workflow: string; + task: string; + context: AgentContext; + autonomy: AutonomyLevel; +} + +export interface Artifact { + path: string; + content: string; + operation: "create" | "update" | "delete"; +} + +export interface TokenUsage { + input_tokens: number; + output_tokens: number; + total_tokens: number; + estimated_cost_usd: number; +} + +export interface BackendResult { + success: boolean; + output: string; + artifacts: Artifact[]; + decisions: Decision[]; + escalations: Escalation[]; + usage: TokenUsage; + error?: string; +} + +export interface IntelligenceBackend { + readonly name: string; + readonly type: BackendType; + isAvailable(): Promise; + execute(request: BackendRequest): Promise; +} + +export interface LLMBackendConfig { + base_url: string; + model_profile: ModelProfile; + model?: string; + timeout_ms?: number; +} + +export interface OllamaLocalConfig extends LLMBackendConfig { + base_url: string; + model_profile: ModelProfile; + model?: string; + timeout_ms?: number; +} + +export interface OllamaCloudConfig extends LLMBackendConfig { + base_url: string; + api_key_env: string; + model_profile: ModelProfile; + model?: string; + timeout_ms?: number; +} + +export interface OpencodeBackendConfig { + enabled: boolean; + executable?: string; +} + +export interface BackendConfigSection { + provider: "auto" | "opencode" | "ollama-local" | "ollama-cloud"; + fallback?: "opencode" | "ollama-local" | "ollama-cloud"; + agent_backends: { + opencode?: OpencodeBackendConfig; + }; + llm_backends: { + "ollama-local"?: OllamaLocalConfig; + "ollama-cloud"?: OllamaCloudConfig; + }; +} + +export const DEFAULT_BACKEND_CONFIG: BackendConfigSection = { + provider: "auto", + agent_backends: { + opencode: { enabled: true }, + }, + llm_backends: { + "ollama-local": { + base_url: "http://localhost:11434", + model_profile: "balanced", + }, + "ollama-cloud": { + base_url: "", + api_key_env: "OLLAMA_CLOUD_API_KEY", + model_profile: "quality", + timeout_ms: 60000, + }, + }, +}; + +export class BackendUnavailableError extends Error { + readonly backendName: string; + readonly agentName?: string; + + constructor(backendName: string, agentName?: string) { + const agentMsg = agentName ? ` (agent: ${agentName})` : ""; + super( + `Intelligence backend "${backendName}" is not available${agentMsg}. ` + + `Configure one of:\n` + + ` 1. Install opencode: npm i -g opencode\n` + + ` 2. Run Ollama locally: ollama serve\n` + + ` 3. Set OLLAMA_CLOUD_API_KEY for remote inference` + ); + this.name = "BackendUnavailableError"; + this.backendName = backendName; + this.agentName = agentName; + } +} + +export function emptyTokenUsage(): TokenUsage { + return { input_tokens: 0, output_tokens: 0, total_tokens: 0, estimated_cost_usd: 0 }; +} + +export function emptyBackendResult(error?: string): BackendResult { + return { + success: false, + output: "", + artifacts: [], + decisions: [], + escalations: [], + usage: emptyTokenUsage(), + error, + }; +} \ No newline at end of file diff --git a/src/cli/commands.ts b/src/cli/commands.ts index a3ef0c7..bbdc4d9 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -12,8 +12,12 @@ import { loadSpecification as loadSpec } from "../core/clarify.js"; import { AgentContext } from "../agents/base.js"; import { ErrorRecovery } from "../core/error-recovery.js"; import { PipelineState, createInitialPipelineState } from "../types/pipeline.js"; +import { resolveBackend } from "../backends/index.js"; +import { BackendUnavailableError } from "../backends/types.js"; +import { getAgent } from "../agents/index.js"; import * as fs from "node:fs"; import * as path from "node:path"; +import { execSync } from "node:child_process"; export function createInitCommand(): Command { return new Command("init") @@ -28,6 +32,7 @@ export function createInitCommand(): Command { ) .option("--model-profile ", "Model profile: quality, speed, balanced", "quality") .option("--no-parallel", "Disable parallel agent execution") + .option("--backend ", "Intelligence backend: auto, opencode, ollama-local, ollama-cloud", "auto") .action(async (specification, options) => { const projectPath = process.cwd(); @@ -71,10 +76,19 @@ export function createInitCommand(): Command { max_concurrent_agents: 5, min_plans_for_parallel: 2, }, + backend: { + provider: options.backend || "auto", + agent_backends: { opencode: { enabled: true } }, + llm_backends: { + "ollama-local": { base_url: "http://localhost:11434", model_profile: "balanced" }, + "ollama-cloud": { base_url: "", api_key_env: "OLLAMA_CLOUD_API_KEY", model_profile: "quality", timeout_ms: 60000 }, + }, + }, }; const fullConfig = initCI(projectPath, config); console.log(`✓ CI project initialized (autonomy: ${autonomyLevel})`); + console.log(` Backend: ${options.backend || "auto"}`); if (specText) { const spec: Specification = parseSpecification(specText, options.spec ? "file" : "inline"); @@ -109,12 +123,48 @@ export function createInitCommand(): Command { }); } +async function resolveBackendForCommand(config: CIConfig, overrideBackend?: string): Promise<{ backend: import("../backends/types.js").IntelligenceBackend | undefined; error?: string }> { + const backendConfig = { ...config.backend }; + if (overrideBackend) { + backendConfig.provider = overrideBackend as typeof backendConfig.provider; + } + + if (backendConfig.provider === "auto") { + try { + const backend = await resolveBackend(backendConfig); + console.log(` Backend: ${backend.name} (${backend.type})`); + return { backend }; + } catch (err) { + if (err instanceof BackendUnavailableError) { + return { backend: undefined, error: err.message }; + } + throw err; + } + } + + try { + const { createBackend } = await import("../backends/index.js"); + const backend = createBackend(backendConfig.provider, backendConfig); + if (await backend.isAvailable()) { + console.log(` Backend: ${backend.name} (${backend.type})`); + return { backend }; + } + return { backend: undefined, error: `Configured backend "${backendConfig.provider}" is not available.` }; + } catch (err) { + if (err instanceof BackendUnavailableError) { + return { backend: undefined, error: err.message }; + } + throw err; + } +} + export function createRunCommand(): Command { return new Command("run") .description("Execute a specific phase autonomously") .argument("[phase]", "Phase to run: research, plan, execute, verify, or --all") .option("--all", "Execute all remaining phases sequentially") .option("--phase ", "Phase number", "1") + .option("--backend ", "Override intelligence backend for this run") .action(async (phase, options) => { const projectPath = process.cwd(); @@ -124,6 +174,13 @@ export function createRunCommand(): Command { } const config = loadConfig(projectPath); + const { backend, error: backendError } = await resolveBackendForCommand(config, options.backend); + + if (!backend && backendError) { + console.warn(` ⚠ No intelligence backend available: ${backendError}`); + console.warn(" Continuing with mechanical-only execution (limited functionality)."); + } + const orchestrator = new OrchestratorAgent(config); const context: AgentContext = { project_path: projectPath, @@ -131,6 +188,7 @@ export function createRunCommand(): Command { stage: phase || "all", specification: "", config_path: path.join(projectPath, ".ci", "config.json"), + backend, }; const spec = loadSpec(projectPath); @@ -163,7 +221,8 @@ export function createQuickCommand(): Command { return new Command("quick") .description("Execute an ad-hoc task with full agentic guarantees") .argument("", "Task description") - .action(async (description) => { + .option("--backend ", "Override intelligence backend") + .action(async (description, options) => { const projectPath = process.cwd(); console.log(`Quick task: ${description}`); @@ -173,6 +232,14 @@ export function createQuickCommand(): Command { } const config = loadConfig(projectPath); + const { backend, error: backendError } = await resolveBackendForCommand(config, options.backend); + + if (!backend) { + console.error(`\n✗ "ci quick" requires an intelligence backend.`); + if (backendError) console.error(` ${backendError}`); + process.exit(1); + } + const spec = parseSpecification(description, "inline"); saveSpecification(projectPath, spec); @@ -183,6 +250,7 @@ export function createQuickCommand(): Command { stage: "all", specification: description, config_path: path.join(projectPath, ".ci", "config.json"), + backend, }; const result = await orchestrator.execute(context); @@ -202,6 +270,7 @@ export function createDebugCommand(): Command { .description("Autonomous debugging: diagnose root cause, propose fix") .argument("[description]", "Description of the issue to debug") .option("--confidence ", "Minimum confidence to auto-fix", "0.6") + .option("--backend ", "Override intelligence backend") .action(async (description, options) => { const projectPath = process.cwd(); @@ -210,18 +279,39 @@ export function createDebugCommand(): Command { process.exit(1); } + const config = loadConfig(projectPath); + const { backend, error: backendError } = await resolveBackendForCommand(config, options.backend); + + if (!backend) { + console.error(`\n✗ "ci debug" requires an intelligence backend.`); + if (backendError) console.error(` ${backendError}`); + process.exit(1); + } + console.log("Starting autonomous debug..."); if (description) { console.log(` Issue: ${description}`); } - - const config = loadConfig(projectPath); - const recovery = new ErrorRecovery(config, projectPath); - console.log(` Confidence threshold: ${options.confidence}`); - console.log(" Diagnosing root cause..."); - console.log("\n✓ Debug complete — autonomous diagnosis finished"); + const debuggerAgent = getAgent("debugger"); + const context: AgentContext = { + project_path: projectPath, + phase: 0, + stage: "debug", + specification: description || "", + config_path: path.join(projectPath, ".ci", "config.json"), + backend, + }; + + const result = await debuggerAgent.execute(context); + + if (result.success) { + console.log(`\n✓ ${result.output}`); + } else { + console.error(`\n✗ Debug failed: ${result.error}`); + process.exit(1); + } }); } @@ -230,6 +320,7 @@ export function createVerifyCommand(): Command { .description("Automated verification of a phase") .argument("[phase]", "Phase number to verify", "1") .option("--layer ", "Run specific layer: structural, behavioral, security, quality", "all") + .option("--backend ", "Override intelligence backend for behavioral verification") .action(async (phase, options) => { const projectPath = process.cwd(); @@ -276,7 +367,8 @@ export function createReviewCommand(): Command { return new Command("review") .description("Multi-persona autonomous code review") .argument("[phase]", "Phase number to review", "1") - .action(async (phase) => { + .option("--backend ", "Override intelligence backend") + .action(async (phase, options) => { const projectPath = process.cwd(); if (!isCIInitialized(projectPath)) { @@ -284,9 +376,36 @@ export function createReviewCommand(): Command { process.exit(1); } + const config = loadConfig(projectPath); + const { backend, error: backendError } = await resolveBackendForCommand(config, options.backend); + + if (!backend) { + console.error(`\n✗ "ci review" requires an intelligence backend.`); + if (backendError) console.error(` ${backendError}`); + process.exit(1); + } + const phaseNum = parseInt(phase) || 1; console.log(`Running code review for phase ${phaseNum}...`); - console.log("Review complete — findings logged to audit trail"); + + const reviewer = getAgent("code-reviewer"); + const context: AgentContext = { + project_path: projectPath, + phase: phaseNum, + stage: "review", + specification: "", + config_path: path.join(projectPath, ".ci", "config.json"), + backend, + }; + + const result = await reviewer.execute(context); + + if (result.success) { + console.log(`\n✓ ${result.output}`); + } else { + console.error(`\n✗ Review failed: ${result.error}`); + process.exit(1); + } }); } @@ -308,6 +427,7 @@ export function createStatusCommand(): Command { console.log("─── CI Project Status ───"); console.log(`\nAutonomy: ${config.autonomy.level}`); console.log(`Model Profile: ${config.model_profile}`); + console.log(`Backend: ${config.backend?.provider || "auto"}`); console.log(`Parallelization: ${config.parallelization.enabled ? "enabled" : "disabled"}`); const state = artifacts.readState(); @@ -393,7 +513,8 @@ export function createAuditCommand(): Command { export function createClarifyCommand(): Command { return new Command("clarify") .description("Re-run the Clarify phase if new ambiguities have emerged") - .action(() => { + .option("--backend ", "Use intelligence backend for question generation") + .action(async (options) => { const projectPath = process.cwd(); if (!isCIInitialized(projectPath)) { @@ -432,6 +553,7 @@ export function createRollbackCommand(): Command { .description("Autonomous undo with automatic dependency resolution") .argument("", "Phase number or plan ID to rollback to") .option("--force", "Force rollback even with downstream dependencies") + .option("--backend ", "Use intelligence backend for dependency resolution") .action(async (target, options) => { const projectPath = process.cwd(); @@ -440,17 +562,82 @@ export function createRollbackCommand(): Command { process.exit(1); } - console.log(`Rolling back to: ${target}`); + const phaseNum = parseInt(target) || 0; + console.log(`Rolling back to phase ${phaseNum}...`); - const config = loadConfig(projectPath); - const recovery = new ErrorRecovery(config, projectPath); - const result = await recovery.rollback(parseInt(target) || 0, "User-requested rollback"); + try { + const branchName = `phase/${String(phaseNum).padStart(2, "0")}-*`; + const branches = execSync("git branch --list", { + cwd: projectPath, + encoding: "utf-8", + }).split("\n").map((b) => b.trim()).filter(Boolean); - if (result.recovered) { - console.log(`✓ Rollback complete: ${result.message}`); - } else { - console.error(`✗ Rollback failed: ${result.message}`); - process.exit(1); + const phaseBranches = branches.filter((b) => + b.includes(`phase/${String(phaseNum).padStart(2, "0")}`) + ); + + if (phaseBranches.length > 0 && !options.force) { + console.log(`Found phase ${phaseNum} branches:`); + for (const b of phaseBranches) { + console.log(` ${b}`); + } + console.log("\nChecking for downstream dependencies..."); + + const downstreamPhases = branches.filter((b) => { + const match = b.match(/phase\/(\d+)/); + if (!match) return false; + return parseInt(match[1]) > phaseNum; + }); + + if (downstreamPhases.length > 0) { + console.warn(`⚠ Downstream phases found:`); + for (const b of downstreamPhases) { + console.warn(` ${b}`); + } + console.warn("Use --force to rollback anyway."); + process.exit(1); + } + } + + const targetCommit = execSync( + `git log --all --grep="phase: ${phaseNum}" --format="%H" -1`, + { cwd: projectPath, encoding: "utf-8" } + ).trim(); + + if (targetCommit) { + console.log(` Resetting to commit: ${targetCommit.slice(0, 8)}`); + execSync(`git reset --hard ${targetCommit}`, { + cwd: projectPath, + stdio: "pipe", + }); + console.log(`✓ Rollback complete: reset to phase ${phaseNum}`); + } else { + console.warn(` Could not find phase ${phaseNum} commit. Performing branch cleanup only.`); + + for (const b of phaseBranches) { + const cleanName = b.replace(/^\*?\s*/, ""); + if (cleanName) { + try { + execSync(`git branch -D ${cleanName}`, { + cwd: projectPath, + stdio: "pipe", + }); + console.log(` Deleted branch: ${cleanName}`); + } catch {} + } + } + console.log(`✓ Rollback complete: cleaned up phase ${phaseNum} branches`); + } + } catch (err) { + const recovery = new ErrorRecovery(loadConfig(projectPath), projectPath); + const result = await recovery.rollback(phaseNum, "User-requested rollback"); + + if (result.recovered) { + console.log(`✓ Rollback complete: ${result.message}`); + } else { + console.error(`✗ Rollback failed: ${result.message}`); + process.exit(1); + } } }); } @@ -459,7 +646,8 @@ export function createShipCommand(): Command { return new Command("ship") .description("Auto-complete phase: verify, security, commit, tag") .argument("[phase]", "Phase number to ship", "1") - .action(async (phase) => { + .option("--backend ", "Override intelligence backend") + .action(async (phase, options) => { const projectPath = process.cwd(); if (!isCIInitialized(projectPath)) { @@ -491,7 +679,40 @@ export function createShipCommand(): Command { console.log("\n Resolve escalations before deploying."); } - console.log(" Committing and tagging..."); + const config = loadConfig(projectPath); + const milestone = "v1.0"; + + try { + const isGitRepo = execSync("git rev-parse --is-inside-work-tree", { + cwd: projectPath, + encoding: "utf-8", + }).trim() === "true"; + + if (isGitRepo) { + console.log(" Committing and tagging..."); + const tag = `${milestone}-phase${phaseNum}`; + try { + execSync(`git add -A`, { cwd: projectPath, stdio: "pipe" }); + execSync(`git commit -m "chore: ship phase ${phaseNum}" --allow-empty`, { + cwd: projectPath, + stdio: "pipe", + }); + execSync(`git tag -a ${tag} -m "CI: Phase ${phaseNum} shipped"`, { + cwd: projectPath, + stdio: "pipe", + }); + console.log(` ✓ Tagged: ${tag}`); + + if (config.git.auto_push) { + execSync(`git push origin ${tag}`, { cwd: projectPath, stdio: "pipe" }); + console.log(` ✓ Pushed tag: ${tag}`); + } + } catch (err) { + console.warn(` ⚠ Git operations failed: ${err instanceof Error ? err.message : String(err)}`); + } + } + } catch {} + console.log(`\n✓ Phase ${phaseNum} shipped successfully`); }); } \ No newline at end of file diff --git a/src/core/config.ts b/src/core/config.ts index ad52641..36298bb 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -88,6 +88,7 @@ export function initCI(projectPath: string, config?: Partial, projectS verification: { ...DEFAULT_CI_CONFIG.verification, ...config?.verification }, security: { ...DEFAULT_CI_CONFIG.security, ...config?.security }, git: { ...DEFAULT_CI_CONFIG.git, ...config?.git }, + backend: { ...DEFAULT_CI_CONFIG.backend, ...config?.backend }, }; saveConfig(projectPath, fullConfig); return fullConfig; diff --git a/src/index.ts b/src/index.ts index 0a2ce17..9784ea3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,11 @@ export { createClarifyQuestion } from "./types/clarify.js"; export { parseSpecification } from "./types/specification.js"; export { getNextStage, createInitialPipelineState } from "./types/pipeline.js"; export * as fileUtils from "./utils/file.js"; +export { resolveBackend, createBackend } from "./backends/index.js"; +export { OpencodeBackend } from "./backends/opencode.js"; +export { OllamaLocalBackend } from "./backends/ollama-local.js"; +export { OllamaCloudBackend } from "./backends/ollama-cloud.js"; +export { ToolRegistry } from "./backends/tool-registry.js"; export type { CIConfig, AutonomyLevel, ModelProfile } from "./types/config.js"; export type { Decision, DecisionCategory } from "./types/decisions.js"; @@ -36,4 +41,6 @@ export type { AgentName } from "./types/config.js"; export type { CiMetadata, ParsedCiCommit, CommitType, CommitScope, CommitDecision, CommitEscalation, CommitRequirements, CommitCompoundMeta } from "./types/commit-meta.js"; export type { ProjectState, BranchInfo } from "./core/git-context.js"; export type { PhaseBranchInfo, MilestoneBranchInfo, BranchCreateResult, BranchMergeResult } from "./core/git-branch.js"; -export type { ProjectMd, RoadmapMd, RequirementsMd, ArchitectureMd } from "./core/ci-files.js"; \ No newline at end of file +export type { ProjectMd, RoadmapMd, RequirementsMd, ArchitectureMd } from "./core/ci-files.js"; +export type { IntelligenceBackend, BackendRequest, BackendResult, BackendConfigSection, BackendUnavailableError, Artifact, TokenUsage } from "./backends/types.js"; +export type { ToolDefinition, ToolCall, ToolResult } from "./backends/tool-registry.js"; \ No newline at end of file diff --git a/src/types/config.ts b/src/types/config.ts index 59afa37..be6aaf2 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -1,3 +1,5 @@ +import { BackendConfigSection } from "../backends/types.js"; + export type AutonomyLevel = "full" | "supervised" | "guided"; export type ModelProfile = "quality" | "speed" | "balanced"; @@ -76,6 +78,7 @@ export interface CIConfig { verification: VerificationConfig; security: SecurityConfig; git: GitConfig; + backend: BackendConfigSection; } export const DEFAULT_CI_CONFIG: CIConfig = { @@ -112,4 +115,22 @@ export const DEFAULT_CI_CONFIG: CIConfig = { auto_commit: true, auto_push: false, }, + backend: { + provider: "auto", + agent_backends: { + opencode: { enabled: true }, + }, + llm_backends: { + "ollama-local": { + base_url: "http://localhost:11434", + model_profile: "balanced", + }, + "ollama-cloud": { + base_url: "", + api_key_env: "OLLAMA_CLOUD_API_KEY", + model_profile: "quality", + timeout_ms: 60000, + }, + }, + }, }; \ No newline at end of file diff --git a/templates/config.json b/templates/config.json index e313a10..c0ebc23 100644 --- a/templates/config.json +++ b/templates/config.json @@ -29,5 +29,23 @@ "branching_strategy": "phase", "auto_commit": true, "auto_push": false + }, + "backend": { + "provider": "auto", + "agent_backends": { + "opencode": { "enabled": true } + }, + "llm_backends": { + "ollama-local": { + "base_url": "http://localhost:11434", + "model_profile": "balanced" + }, + "ollama-cloud": { + "base_url": "", + "api_key_env": "OLLAMA_CLOUD_API_KEY", + "model_profile": "quality", + "timeout_ms": 60000 + } + } } } \ No newline at end of file