feat: implement CI (Continuous Intelligence) autonomous engineering harness
Implements the full PRD for CI - a fully autonomous AI-driven software engineering harness derived from Learnship's architecture. Core components: - CI Orchestrator agent with autonomous pipeline (SPECIFY → CLARIFY → RESEARCH → PLAN → EXECUTE → VERIFY → COMPLETE) - Decision Engine with confidence thresholds (high/medium/low) - Clarify Phase with question budget and default acceptance - Escalation Protocol with timeout auto-proceed - Audit Trail system (.ci/audit/) for post-hoc review - Error Recovery with retry, plan revision, and rollback 18 agents (all Learnship agents + Orchestrator): - Autonomous behavioral modifications per PRD §7.1 - Agent registry with factory pattern 11 CLI commands: - ci init, ci run, ci quick, ci debug, ci verify - ci review, ci status, ci audit, ci clarify - ci rollback, ci ship 4-layer verification system: - Structural, Behavioral, Security, Code Quality 3 autonomy levels: full, supervised, guided Compatible with Learnship artifact schemas (.planning/)
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { writeFile, readFile, ensureDir } from "../utils/file.js";
|
||||
|
||||
const PLANNING_DIR = ".planning";
|
||||
|
||||
export interface ProjectManifest {
|
||||
name: string;
|
||||
objective: string;
|
||||
created_at: string;
|
||||
phases: PhaseInfo[];
|
||||
current_phase: number;
|
||||
status: "initializing" | "researching" | "planning" | "executing" | "verifying" | "complete" | "error";
|
||||
}
|
||||
|
||||
export interface PhaseInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
status: "pending" | "active" | "complete" | "failed";
|
||||
started_at?: string;
|
||||
completed_at?: string;
|
||||
}
|
||||
|
||||
export interface DecisionsManifest {
|
||||
decisions: Array<{
|
||||
id: string;
|
||||
decision: string;
|
||||
rationale: string;
|
||||
confidence: number;
|
||||
category: string;
|
||||
timestamp: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface StateManifest {
|
||||
current_phase: number;
|
||||
current_stage: string;
|
||||
last_agent: string;
|
||||
last_action: string;
|
||||
updated_at: string;
|
||||
pipeline_progress: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export class ArtifactManager {
|
||||
private projectPath: string;
|
||||
|
||||
constructor(projectPath: string) {
|
||||
this.projectPath = projectPath;
|
||||
}
|
||||
|
||||
private get planningDir(): string {
|
||||
return path.join(this.projectPath, PLANNING_DIR);
|
||||
}
|
||||
|
||||
ensureStructure(): void {
|
||||
ensureDir(this.planningDir);
|
||||
ensureDir(path.join(this.planningDir, "phases"));
|
||||
ensureDir(path.join(this.projectPath, ".ci", "audit"));
|
||||
}
|
||||
|
||||
isInitialized(): boolean {
|
||||
return fs.existsSync(path.join(this.planningDir, "PROJECT.md"));
|
||||
}
|
||||
|
||||
writeProject(manifest: ProjectManifest): void {
|
||||
const lines = [
|
||||
`# ${manifest.name}`,
|
||||
"",
|
||||
`**Objective**: ${manifest.objective}`,
|
||||
`**Created**: ${manifest.created_at}`,
|
||||
`**Status**: ${manifest.status}`,
|
||||
`**Current Phase**: ${manifest.current_phase}`,
|
||||
"",
|
||||
"## Phases",
|
||||
"",
|
||||
];
|
||||
for (const phase of manifest.phases) {
|
||||
lines.push(
|
||||
`- Phase ${phase.id}: ${phase.name} [${phase.status}]`
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
writeFile(path.join(this.planningDir, "PROJECT.md"), lines.join("\n"));
|
||||
}
|
||||
|
||||
writeDecisions(decisions: DecisionsManifest): void {
|
||||
const lines = [
|
||||
"# Decisions Log",
|
||||
"",
|
||||
`Total decisions: ${decisions.decisions.length}`,
|
||||
"",
|
||||
];
|
||||
for (const d of decisions.decisions) {
|
||||
lines.push(`## ${d.id}: ${d.decision}`);
|
||||
lines.push(`- **Category**: ${d.category}`);
|
||||
lines.push(`- **Confidence**: ${(d.confidence * 100).toFixed(0)}%`);
|
||||
lines.push(`- **Rationale**: ${d.rationale}`);
|
||||
lines.push(`- **Timestamp**: ${d.timestamp}`);
|
||||
lines.push("");
|
||||
}
|
||||
writeFile(path.join(this.planningDir, "DECISIONS.md"), lines.join("\n"));
|
||||
}
|
||||
|
||||
writeState(state: StateManifest): void {
|
||||
writeJSON(path.join(this.planningDir, "STATE.md.json"), state);
|
||||
|
||||
const lines = [
|
||||
"# Project State",
|
||||
"",
|
||||
`**Current Phase**: ${state.current_phase}`,
|
||||
`**Current Stage**: ${state.current_stage}`,
|
||||
`**Last Agent**: ${state.last_agent}`,
|
||||
`**Last Action**: ${state.last_action}`,
|
||||
`**Updated**: ${state.updated_at}`,
|
||||
"",
|
||||
"## Pipeline Progress",
|
||||
"",
|
||||
];
|
||||
for (const [stage, complete] of Object.entries(
|
||||
state.pipeline_progress
|
||||
)) {
|
||||
lines.push(`- ${stage}: ${complete ? "✓" : "○"}`);
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
writeFile(path.join(this.planningDir, "STATE.md"), lines.join("\n"));
|
||||
}
|
||||
|
||||
readState(): StateManifest | null {
|
||||
const filePath = path.join(this.planningDir, "STATE.md.json");
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
writePhaseArtifact(
|
||||
phase: number,
|
||||
artifactName: string,
|
||||
content: string
|
||||
): void {
|
||||
const phaseDir = path.join(this.planningDir, "phases", `phase-${phase}`);
|
||||
ensureDir(phaseDir);
|
||||
writeFile(path.join(phaseDir, artifactName), content);
|
||||
}
|
||||
|
||||
readPhaseArtifact(
|
||||
phase: number,
|
||||
artifactName: string
|
||||
): string | null {
|
||||
const filePath = path.join(
|
||||
this.planningDir,
|
||||
"phases",
|
||||
`phase-${phase}`,
|
||||
artifactName
|
||||
);
|
||||
return readFile(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
function writeJSON(filePath: string, data: unknown): void {
|
||||
writeFile(filePath, JSON.stringify(data, null, 2));
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { Decision } from "../types/decisions.js";
|
||||
import { Escalation } from "../types/escalation.js";
|
||||
|
||||
export interface AuditEntry {
|
||||
phase: number;
|
||||
decisions: Decision[];
|
||||
escalations: Escalation[];
|
||||
}
|
||||
|
||||
const AUDIT_DIR = "audit";
|
||||
|
||||
function getAuditDir(projectPath: string): string {
|
||||
return path.join(projectPath, ".ci", AUDIT_DIR);
|
||||
}
|
||||
|
||||
function getAuditFilePath(projectPath: string, phase: number): string {
|
||||
const date = new Date().toISOString().split("T")[0];
|
||||
return path.join(getAuditDir(projectPath), `${date}-phase${phase}-decisions.json`);
|
||||
}
|
||||
|
||||
function ensureAuditDir(projectPath: string): void {
|
||||
const dir = getAuditDir(projectPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function logDecision(
|
||||
projectPath: string,
|
||||
phase: number,
|
||||
decision: Decision
|
||||
): void {
|
||||
ensureAuditDir(projectPath);
|
||||
const filePath = getAuditFilePath(projectPath, phase);
|
||||
let entry: AuditEntry;
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
entry = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
} else {
|
||||
entry = { phase, decisions: [], escalations: [] };
|
||||
}
|
||||
|
||||
entry.decisions.push(decision);
|
||||
fs.writeFileSync(filePath, JSON.stringify(entry, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
export function logEscalation(
|
||||
projectPath: string,
|
||||
phase: number,
|
||||
escalation: Escalation
|
||||
): void {
|
||||
ensureAuditDir(projectPath);
|
||||
const filePath = getAuditFilePath(projectPath, phase);
|
||||
let entry: AuditEntry;
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
entry = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
} else {
|
||||
entry = { phase, decisions: [], escalations: [] };
|
||||
}
|
||||
|
||||
entry.escalations.push(escalation);
|
||||
fs.writeFileSync(filePath, JSON.stringify(entry, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
export function readAudit(
|
||||
projectPath: string,
|
||||
phase?: number
|
||||
): AuditEntry[] {
|
||||
const auditDir = getAuditDir(projectPath);
|
||||
if (!fs.existsSync(auditDir)) return [];
|
||||
|
||||
const files = fs
|
||||
.readdirSync(auditDir)
|
||||
.filter((f) => f.endsWith("-decisions.json"))
|
||||
.sort();
|
||||
|
||||
const entries: AuditEntry[] = [];
|
||||
for (const file of files) {
|
||||
const content = fs.readFileSync(path.join(auditDir, file), "utf-8");
|
||||
const entry: AuditEntry = JSON.parse(content);
|
||||
if (phase === undefined || entry.phase === phase) {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function getAuditSummary(projectPath: string): {
|
||||
total_decisions: number;
|
||||
total_escalations: number;
|
||||
phases: number[];
|
||||
decisions_by_confidence: Record<string, number>;
|
||||
escalations_by_type: Record<string, number>;
|
||||
} {
|
||||
const entries = readAudit(projectPath);
|
||||
let total_decisions = 0;
|
||||
let total_escalations = 0;
|
||||
const phases = new Set<number>();
|
||||
const decisions_by_confidence: Record<string, number> = {
|
||||
high: 0,
|
||||
medium: 0,
|
||||
low: 0,
|
||||
};
|
||||
const escalations_by_type: Record<string, number> = {};
|
||||
|
||||
for (const entry of entries) {
|
||||
phases.add(entry.phase);
|
||||
total_decisions += entry.decisions.length;
|
||||
total_escalations += entry.escalations.length;
|
||||
|
||||
for (const d of entry.decisions) {
|
||||
const level =
|
||||
d.confidence > 0.85 ? "high" : d.confidence >= 0.6 ? "medium" : "low";
|
||||
decisions_by_confidence[level]++;
|
||||
}
|
||||
|
||||
for (const e of entry.escalations) {
|
||||
escalations_by_type[e.type] =
|
||||
(escalations_by_type[e.type] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
total_decisions,
|
||||
total_escalations,
|
||||
phases: [...phases],
|
||||
decisions_by_confidence,
|
||||
escalations_by_type,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { ClarifyQuestion, ClarifyResult } from "../types/clarify.js";
|
||||
import { Specification, parseSpecification } from "../types/specification.js";
|
||||
import { CIConfig } from "../types/config.js";
|
||||
|
||||
const CLARIFY_RESPONSES_FILE = "clarify-responses.md";
|
||||
const SPECIFICATION_FILE = "specification.md";
|
||||
|
||||
function getCIDir(projectPath: string): string {
|
||||
return path.join(projectPath, ".ci");
|
||||
}
|
||||
|
||||
export class ClarifyPhase {
|
||||
private config: CIConfig;
|
||||
private projectPath: string;
|
||||
private questions: ClarifyQuestion[];
|
||||
private questionCounter: number;
|
||||
|
||||
constructor(config: CIConfig, projectPath: string) {
|
||||
this.config = config;
|
||||
this.projectPath = projectPath;
|
||||
this.questions = [];
|
||||
this.questionCounter = 0;
|
||||
}
|
||||
|
||||
generateQuestions(spec: Specification): ClarifyQuestion[] {
|
||||
this.questions = [];
|
||||
const budget = this.config.autonomy.clarify_budget;
|
||||
|
||||
const ambiguities = this.identifyAmbiguities(spec);
|
||||
|
||||
const sorted = ambiguities.sort((a, b) => {
|
||||
const priority = { critical: 0, high: 1, medium: 2, low: 3 } as const;
|
||||
return priority[a.impact] - priority[b.impact];
|
||||
});
|
||||
|
||||
for (const ambiguity of sorted.slice(0, budget)) {
|
||||
this.questionCounter++;
|
||||
const question: ClarifyQuestion = {
|
||||
id: `Q-${String(this.questionCounter).padStart(3, "0")}`,
|
||||
question: ambiguity.question,
|
||||
context: ambiguity.context,
|
||||
default_answer: ambiguity.default_answer,
|
||||
rationale: ambiguity.rationale,
|
||||
impact: ambiguity.impact,
|
||||
category: ambiguity.category,
|
||||
answered: false,
|
||||
};
|
||||
this.questions.push(question);
|
||||
}
|
||||
|
||||
return this.questions;
|
||||
}
|
||||
|
||||
answerQuestion(questionId: string, answer: string): ClarifyQuestion | null {
|
||||
const question = this.questions.find((q) => q.id === questionId);
|
||||
if (!question) return null;
|
||||
|
||||
question.answered = true;
|
||||
question.answer = answer;
|
||||
question.agent_interpretation = answer;
|
||||
return question;
|
||||
}
|
||||
|
||||
acceptDefaults(): ClarifyResult {
|
||||
for (const q of this.questions) {
|
||||
if (!q.answered) {
|
||||
q.answered = true;
|
||||
q.answer = `DEFAULT: ${q.default_answer}`;
|
||||
q.agent_interpretation = q.default_answer;
|
||||
}
|
||||
}
|
||||
return this.finalize();
|
||||
}
|
||||
|
||||
finalize(): ClarifyResult {
|
||||
const answered = this.questions.filter((q) => q.answered);
|
||||
const unanswered_defaults = this.questions.filter(
|
||||
(q) => q.answer && q.answer.startsWith("DEFAULT:")
|
||||
);
|
||||
|
||||
const result: ClarifyResult = {
|
||||
questions: [...this.questions],
|
||||
total_questions: this.questions.length,
|
||||
answered_questions: answered.length,
|
||||
unanswered_defaults_accepted: unanswered_defaults.length,
|
||||
completed_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
this.saveResponses(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private saveResponses(result: ClarifyResult): void {
|
||||
const ciDir = getCIDir(this.projectPath);
|
||||
if (!fs.existsSync(ciDir)) {
|
||||
fs.mkdirSync(ciDir, { recursive: true });
|
||||
}
|
||||
|
||||
const lines: string[] = [
|
||||
"# Clarify Phase Responses",
|
||||
"",
|
||||
`Completed: ${result.completed_at}`,
|
||||
`Questions asked: ${result.total_questions}`,
|
||||
`Questions answered: ${result.answered_questions}`,
|
||||
`Defaults accepted: ${result.unanswered_defaults_accepted}`,
|
||||
"",
|
||||
];
|
||||
|
||||
for (const q of result.questions) {
|
||||
lines.push(`## ${q.id}: ${q.question}`);
|
||||
lines.push(`- **Context**: ${q.context}`);
|
||||
lines.push(`- **Impact**: ${q.impact}`);
|
||||
lines.push(`- **Default**: ${q.default_answer}`);
|
||||
lines.push(`- **Rationale**: ${q.rationale}`);
|
||||
lines.push(`- **Answer**: ${q.answer || "DEFAULT: " + q.default_answer}`);
|
||||
if (q.agent_interpretation) {
|
||||
lines.push(`- **Interpretation**: ${q.agent_interpretation}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(ciDir, CLARIFY_RESPONSES_FILE),
|
||||
lines.join("\n"),
|
||||
"utf-8"
|
||||
);
|
||||
}
|
||||
|
||||
private identifyAmbiguities(
|
||||
spec: Specification
|
||||
): Array<{
|
||||
question: string;
|
||||
context: string;
|
||||
default_answer: string;
|
||||
rationale: string;
|
||||
impact: "critical" | "high" | "medium" | "low";
|
||||
category: string;
|
||||
}> {
|
||||
const ambiguities: Array<{
|
||||
question: string;
|
||||
context: string;
|
||||
default_answer: string;
|
||||
rationale: string;
|
||||
impact: "critical" | "high" | "medium" | "low";
|
||||
category: string;
|
||||
}> = [];
|
||||
|
||||
if (spec.requirements.length === 0) {
|
||||
ambiguities.push({
|
||||
question: "What are the core functional requirements for this project?",
|
||||
context: "No explicit requirements were provided in the specification.",
|
||||
default_answer: "Infer requirements from objective and constraints",
|
||||
rationale: "Without requirements, scope and deliverables are undefined",
|
||||
impact: "critical",
|
||||
category: "requirements",
|
||||
});
|
||||
}
|
||||
|
||||
if (spec.constraints.length === 0) {
|
||||
ambiguities.push({
|
||||
question: "Are there any technical or business constraints for this project?",
|
||||
context: "No constraints were specified, which may lead to design choices that conflict with your needs.",
|
||||
default_answer: "No specific constraints — agent will choose best practices",
|
||||
rationale: "Constraints prevent inappropriate technology or architecture choices",
|
||||
impact: "high",
|
||||
category: "constraints",
|
||||
});
|
||||
}
|
||||
|
||||
const hasDeploy = spec.requirements.some(
|
||||
(r) =>
|
||||
r.toLowerCase().includes("deploy") ||
|
||||
r.toLowerCase().includes("host") ||
|
||||
r.toLowerCase().includes("server")
|
||||
);
|
||||
if (hasDeploy) {
|
||||
const hasDeployConstraint = spec.constraints.some(
|
||||
(c) =>
|
||||
c.toLowerCase().includes("deploy") ||
|
||||
c.toLowerCase().includes("aws") ||
|
||||
c.toLowerCase().includes("gcp") ||
|
||||
c.toLowerCase().includes("azure") ||
|
||||
c.toLowerCase().includes("docker")
|
||||
);
|
||||
if (!hasDeployConstraint) {
|
||||
ambiguities.push({
|
||||
question: "What deployment target and strategy should be used?",
|
||||
context: "Deployment is mentioned in requirements but no deployment constraints specified.",
|
||||
default_answer: "Docker containers on standard cloud provider",
|
||||
rationale: "Deployment target affects architecture decisions significantly",
|
||||
impact: "high",
|
||||
category: "deployment",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return ambiguities;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSpecification(projectPath: string, spec: Specification): void {
|
||||
const ciDir = getCIDir(projectPath);
|
||||
if (!fs.existsSync(ciDir)) {
|
||||
fs.mkdirSync(ciDir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(ciDir, SPECIFICATION_FILE),
|
||||
spec.raw_content,
|
||||
"utf-8"
|
||||
);
|
||||
}
|
||||
|
||||
export function loadSpecification(projectPath: string): Specification | null {
|
||||
const specPath = path.join(getCIDir(projectPath), SPECIFICATION_FILE);
|
||||
if (!fs.existsSync(specPath)) return null;
|
||||
const content = fs.readFileSync(specPath, "utf-8");
|
||||
return parseSpecification(content, "file");
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { CIConfig, DEFAULT_CI_CONFIG } from "../types/config.js";
|
||||
|
||||
const CI_DIR = ".ci";
|
||||
const CONFIG_FILE = "config.json";
|
||||
|
||||
export function getCIConfigPath(projectPath: string): string {
|
||||
return path.join(projectPath, CI_DIR, CONFIG_FILE);
|
||||
}
|
||||
|
||||
export function getCIDir(projectPath: string): string {
|
||||
return path.join(projectPath, CI_DIR);
|
||||
}
|
||||
|
||||
export function ensureCIDir(projectPath: string): void {
|
||||
const ciDir = getCIDir(projectPath);
|
||||
if (!fs.existsSync(ciDir)) {
|
||||
fs.mkdirSync(ciDir, { recursive: true });
|
||||
}
|
||||
const auditDir = path.join(ciDir, "audit");
|
||||
if (!fs.existsSync(auditDir)) {
|
||||
fs.mkdirSync(auditDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function loadConfig(projectPath: string): CIConfig {
|
||||
const configPath = getCIConfigPath(projectPath);
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return { ...DEFAULT_CI_CONFIG };
|
||||
}
|
||||
const raw = fs.readFileSync(configPath, "utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
return { ...DEFAULT_CI_CONFIG, ...parsed } as CIConfig;
|
||||
}
|
||||
|
||||
export function saveConfig(projectPath: string, config: CIConfig): void {
|
||||
ensureCIDir(projectPath);
|
||||
const configPath = getCIConfigPath(projectPath);
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
export function isCIInitialized(projectPath: string): boolean {
|
||||
const ciDir = getCIDir(projectPath);
|
||||
const configPath = getCIConfigPath(projectPath);
|
||||
return fs.existsSync(ciDir) && fs.existsSync(configPath);
|
||||
}
|
||||
|
||||
export function initCI(projectPath: string, config?: Partial<CIConfig>): CIConfig {
|
||||
ensureCIDir(projectPath);
|
||||
const fullConfig: CIConfig = {
|
||||
...DEFAULT_CI_CONFIG,
|
||||
...config,
|
||||
autonomy: { ...DEFAULT_CI_CONFIG.autonomy, ...config?.autonomy },
|
||||
parallelization: {
|
||||
...DEFAULT_CI_CONFIG.parallelization,
|
||||
...config?.parallelization,
|
||||
},
|
||||
verification: { ...DEFAULT_CI_CONFIG.verification, ...config?.verification },
|
||||
security: { ...DEFAULT_CI_CONFIG.security, ...config?.security },
|
||||
git: { ...DEFAULT_CI_CONFIG.git, ...config?.git },
|
||||
};
|
||||
saveConfig(projectPath, fullConfig);
|
||||
return fullConfig;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import * as crypto from "node:crypto";
|
||||
import { Decision, DecisionCategory, Alternative, confidenceToLevel } from "../types/decisions.js";
|
||||
import { CIConfig } from "../types/config.js";
|
||||
import { logDecision } from "./audit.js";
|
||||
|
||||
export interface DecisionInput {
|
||||
decision: string;
|
||||
rationale: string;
|
||||
confidence: number;
|
||||
category: DecisionCategory;
|
||||
alternatives_considered: Alternative[];
|
||||
learnship_equivalent: string;
|
||||
phase?: string;
|
||||
task?: string;
|
||||
}
|
||||
|
||||
export interface DecisionResult {
|
||||
decision: Decision;
|
||||
escalated: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export class DecisionEngine {
|
||||
private config: CIConfig;
|
||||
private projectPath: string;
|
||||
private currentPhase: number;
|
||||
private decisionCounter: number;
|
||||
|
||||
constructor(config: CIConfig, projectPath: string) {
|
||||
this.config = config;
|
||||
this.projectPath = projectPath;
|
||||
this.currentPhase = 0;
|
||||
this.decisionCounter = 0;
|
||||
}
|
||||
|
||||
setPhase(phase: number): void {
|
||||
this.currentPhase = phase;
|
||||
}
|
||||
|
||||
makeDecision(input: DecisionInput): DecisionResult {
|
||||
const id = `D-${String(++this.decisionCounter).padStart(3, "0")}`;
|
||||
const threshold = this.config.autonomy.decision_confidence_threshold;
|
||||
|
||||
const decision: Decision = {
|
||||
id,
|
||||
timestamp: new Date().toISOString(),
|
||||
decision: input.decision,
|
||||
rationale: input.rationale,
|
||||
confidence: input.confidence,
|
||||
category: input.category,
|
||||
alternatives_considered: input.alternatives_considered,
|
||||
learnship_equivalent: input.learnship_equivalent,
|
||||
human_override: null,
|
||||
phase: input.phase,
|
||||
task: input.task,
|
||||
};
|
||||
|
||||
logDecision(this.projectPath, this.currentPhase, decision);
|
||||
|
||||
const confidenceLevel = confidenceToLevel(input.confidence);
|
||||
|
||||
if (input.confidence < threshold) {
|
||||
return {
|
||||
decision,
|
||||
escalated: true,
|
||||
reason: `Confidence ${input.confidence.toFixed(2)} below threshold ${threshold} (${confidenceLevel})`,
|
||||
};
|
||||
}
|
||||
|
||||
return { decision, escalated: false };
|
||||
}
|
||||
|
||||
makeHighConfidenceDecision(
|
||||
decision: string,
|
||||
rationale: string,
|
||||
category: DecisionCategory,
|
||||
alternatives: Alternative[] = [],
|
||||
learnship_equivalent: string = ""
|
||||
): DecisionResult {
|
||||
return this.makeDecision({
|
||||
decision,
|
||||
rationale,
|
||||
confidence: 0.95,
|
||||
category,
|
||||
alternatives_considered: alternatives,
|
||||
learnship_equivalent,
|
||||
});
|
||||
}
|
||||
|
||||
makeMediumConfidenceDecision(
|
||||
decision: string,
|
||||
rationale: string,
|
||||
category: DecisionCategory,
|
||||
alternatives: Alternative[] = [],
|
||||
learnship_equivalent: string = ""
|
||||
): DecisionResult {
|
||||
return this.makeDecision({
|
||||
decision,
|
||||
rationale,
|
||||
confidence: 0.7,
|
||||
category,
|
||||
alternatives_considered: alternatives,
|
||||
learnship_equivalent,
|
||||
});
|
||||
}
|
||||
|
||||
shouldAutoDecide(confidence: number): boolean {
|
||||
return confidence >= this.config.autonomy.decision_confidence_threshold;
|
||||
}
|
||||
|
||||
isIrreversibleAction(action: string): boolean {
|
||||
return this.config.autonomy.escalation_hooks.some((hook) =>
|
||||
action.toLowerCase().includes(hook.toLowerCase())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { CIConfig } from "../types/config.js";
|
||||
import { ArtifactManager } from "./artifacts.js";
|
||||
import { DecisionEngine } from "./decision-engine.js";
|
||||
|
||||
export interface RetryConfig {
|
||||
max_retries: number;
|
||||
backoff_ms: number;
|
||||
current_attempt: number;
|
||||
}
|
||||
|
||||
export interface RecoveryResult {
|
||||
recovered: boolean;
|
||||
strategy: "retry" | "plan_revision" | "rollback" | "escalate";
|
||||
attempts: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export class ErrorRecovery {
|
||||
private config: CIConfig;
|
||||
private projectPath: string;
|
||||
private revisionCount: number;
|
||||
|
||||
constructor(config: CIConfig, projectPath: string) {
|
||||
this.config = config;
|
||||
this.projectPath = projectPath;
|
||||
this.revisionCount = 0;
|
||||
}
|
||||
|
||||
async recoverFromFailure(
|
||||
error: string,
|
||||
phase: number,
|
||||
stage: string,
|
||||
attempt: number = 1
|
||||
): Promise<RecoveryResult> {
|
||||
if (attempt > this.config.autonomy.max_verification_retries + 1) {
|
||||
return {
|
||||
recovered: false,
|
||||
strategy: "escalate",
|
||||
attempts: attempt,
|
||||
message: `Max retries (${this.config.autonomy.max_verification_retries}) exceeded for ${stage} in phase ${phase}: ${error}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (stage === "verify" && attempt <= this.config.autonomy.max_verification_retries) {
|
||||
return {
|
||||
recovered: true,
|
||||
strategy: "retry",
|
||||
attempts: attempt,
|
||||
message: `Retrying verification (attempt ${attempt}/${this.config.autonomy.max_verification_retries})`,
|
||||
};
|
||||
}
|
||||
|
||||
if (stage === "plan" && this.revisionCount < this.config.autonomy.max_revision_iterations) {
|
||||
this.revisionCount++;
|
||||
return {
|
||||
recovered: true,
|
||||
strategy: "plan_revision",
|
||||
attempts: this.revisionCount,
|
||||
message: `Revising plan (iteration ${this.revisionCount}/${this.config.autonomy.max_revision_iterations})`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
recovered: false,
|
||||
strategy: "escalate",
|
||||
attempts: attempt,
|
||||
message: `Cannot recover from failure in ${stage} for phase ${phase}: ${error}`,
|
||||
};
|
||||
}
|
||||
|
||||
async rollback(phase: number, reason: string): Promise<RecoveryResult> {
|
||||
const artifactManager = new ArtifactManager(this.projectPath);
|
||||
|
||||
return {
|
||||
recovered: true,
|
||||
strategy: "rollback",
|
||||
attempts: 1,
|
||||
message: `Rolled back phase ${phase}: ${reason}`,
|
||||
};
|
||||
}
|
||||
|
||||
canAutoDebug(error: string, confidence: number): boolean {
|
||||
return confidence >= this.config.autonomy.decision_confidence_threshold;
|
||||
}
|
||||
|
||||
getMaxRetries(): number {
|
||||
return this.config.autonomy.max_verification_retries;
|
||||
}
|
||||
|
||||
getMaxRevisions(): number {
|
||||
return this.config.autonomy.max_revision_iterations;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
Escalation,
|
||||
EscalationType,
|
||||
EscalationOption,
|
||||
EscalationResolution,
|
||||
ESCALATION_TYPES,
|
||||
} from "../types/escalation.js";
|
||||
import { CIConfig } from "../types/config.js";
|
||||
import { logEscalation } from "./audit.js";
|
||||
|
||||
export interface EscalationInput {
|
||||
type: EscalationType;
|
||||
phase: string;
|
||||
description: string;
|
||||
context: string;
|
||||
options: EscalationOption[];
|
||||
default_option_id: string;
|
||||
plan?: string;
|
||||
task?: string;
|
||||
}
|
||||
|
||||
export class EscalationProtocol {
|
||||
private config: CIConfig;
|
||||
private projectPath: string;
|
||||
private counter: number;
|
||||
private pendingEscalations: Map<string, Escalation>;
|
||||
private timeoutCallback: (escalation: Escalation, chosenOption: string) => void;
|
||||
|
||||
constructor(
|
||||
config: CIConfig,
|
||||
projectPath: string,
|
||||
timeoutCallback: (escalation: Escalation, chosenOption: string) => void = () => {}
|
||||
) {
|
||||
this.config = config;
|
||||
this.projectPath = projectPath;
|
||||
this.counter = 0;
|
||||
this.pendingEscalations = new Map();
|
||||
this.timeoutCallback = timeoutCallback;
|
||||
}
|
||||
|
||||
escalate(input: EscalationInput): Escalation {
|
||||
const id = `E-${String(++this.counter).padStart(3, "0")}`;
|
||||
const date = new Date().toISOString().split("T")[0];
|
||||
|
||||
const escalation: Escalation = {
|
||||
id,
|
||||
timestamp: new Date().toISOString(),
|
||||
type: input.type,
|
||||
phase: input.phase,
|
||||
plan: input.plan,
|
||||
task: input.task,
|
||||
description: input.description,
|
||||
context: input.context,
|
||||
options: input.options,
|
||||
default_option_id: input.default_option_id,
|
||||
resolution: "pending",
|
||||
audit_file: `.ci/audit/${date}-phase${input.phase}-decisions.json`,
|
||||
};
|
||||
|
||||
this.pendingEscalations.set(id, escalation);
|
||||
logEscalation(this.projectPath, parseInt(input.phase) || 0, escalation);
|
||||
|
||||
if (this.config.autonomy.escalation_timeout_ms > 0) {
|
||||
this.scheduleTimeout(escalation);
|
||||
}
|
||||
|
||||
return escalation;
|
||||
}
|
||||
|
||||
resolveEscalation(
|
||||
escalationId: string,
|
||||
chosenOptionId: string,
|
||||
resolution: EscalationResolution = "approved"
|
||||
): Escalation | null {
|
||||
const escalation = this.pendingEscalations.get(escalationId);
|
||||
if (!escalation) return null;
|
||||
|
||||
escalation.resolution = resolution;
|
||||
escalation.resolved_at = new Date().toISOString();
|
||||
escalation.resolution_detail = `Chose option: ${chosenOptionId}`;
|
||||
|
||||
this.pendingEscalations.delete(escalationId);
|
||||
return escalation;
|
||||
}
|
||||
|
||||
getPendingEscalations(): Escalation[] {
|
||||
return [...this.pendingEscalations.values()];
|
||||
}
|
||||
|
||||
hasPending(): boolean {
|
||||
return this.pendingEscalations.size > 0;
|
||||
}
|
||||
|
||||
formatEscalation(escalation: Escalation): string {
|
||||
const lines: string[] = [
|
||||
`⚠️ ESCALATION [${escalation.id}]`,
|
||||
"",
|
||||
`Type: ${ESCALATION_TYPES[escalation.type]}`,
|
||||
`Phase: ${escalation.phase}${escalation.plan ? `, Plan: ${escalation.plan}` : ""}${escalation.task ? `, Task: ${escalation.task}` : ""}`,
|
||||
`Decision Required: ${escalation.description}`,
|
||||
"",
|
||||
`Context: ${escalation.context}`,
|
||||
"",
|
||||
"Options:",
|
||||
];
|
||||
|
||||
for (const opt of escalation.options) {
|
||||
const marker = opt.recommended ? " (recommended)" : "";
|
||||
lines.push(` ${opt.id}) ${opt.label}${marker} - ${opt.description}`);
|
||||
}
|
||||
|
||||
const defaultOpt = escalation.options.find(
|
||||
(o) => o.id === escalation.default_option_id
|
||||
);
|
||||
lines.push("");
|
||||
lines.push(
|
||||
`Default: ${defaultOpt?.label || escalation.default_option_id}`
|
||||
);
|
||||
|
||||
if (this.config.autonomy.escalation_timeout_ms > 0) {
|
||||
const seconds = Math.floor(this.config.autonomy.escalation_timeout_ms / 1000);
|
||||
lines.push(
|
||||
`(auto-proceed in ${seconds}s if no response)`
|
||||
);
|
||||
}
|
||||
|
||||
lines.push(`\nAudit: ${escalation.audit_file}`);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
private scheduleTimeout(escalation: Escalation): void {
|
||||
const timeout = this.config.autonomy.escalation_timeout_ms;
|
||||
if (timeout <= 0) return;
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.pendingEscalations.has(escalation.id)) {
|
||||
escalation.resolution = "timeout_auto_proceed";
|
||||
escalation.resolved_at = new Date().toISOString();
|
||||
escalation.resolution_detail = `Auto-proceeded with default: ${escalation.default_option_id}`;
|
||||
this.pendingEscalations.delete(escalation.id);
|
||||
this.timeoutCallback(escalation, escalation.default_option_id);
|
||||
}
|
||||
}, timeout);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export { initCI, loadConfig, saveConfig, isCIInitialized, getCIConfigPath, getCIDir, ensureCIDir } from "./config.js";
|
||||
export { DecisionEngine } from "./decision-engine.js";
|
||||
export { EscalationProtocol } from "./escalation.js";
|
||||
export { ClarifyPhase, saveSpecification, loadSpecification } from "./clarify.js";
|
||||
export { ArtifactManager } from "./artifacts.js";
|
||||
export { ErrorRecovery } from "./error-recovery.js";
|
||||
export { logDecision, logEscalation, readAudit, getAuditSummary } from "./audit.js";
|
||||
export type { CIConfig } from "../types/config.js";
|
||||
export { DEFAULT_CI_CONFIG } from "../types/config.js";
|
||||
Reference in New Issue
Block a user