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; 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); } }