aiwg
Version:
Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo
363 lines (315 loc) • 9.88 kB
JavaScript
/**
* Escalation Handler for External Ralph Loop Overseer
*
* Handles human escalation via multiple channels:
* - Desktop notifications (graceful fallback if unavailable)
* - Gitea issue creation
* - Webhook support for external integrations
*
* @implements Issue #25 - Autonomous Overseer
*/
import { spawnSync } from 'child_process';
import { existsSync, readFileSync } from 'fs';
/**
* Escalation levels
*/
export const ESCALATION_LEVELS = {
INFO: 'info',
WARNING: 'warning',
CRITICAL: 'critical',
EMERGENCY: 'emergency',
};
/**
* @typedef {Object} EscalationContext
* @property {string} loopId - Ralph loop ID
* @property {string} taskDescription - Task description
* @property {number} iterationNumber - Current iteration
* @property {string} level - Escalation level
* @property {string} reason - Why escalating
* @property {Object} detection - Detection that triggered escalation
* @property {Object} intervention - Intervention that triggered escalation
*/
export class EscalationHandler {
/**
* @param {Object} options
* @param {string} [options.giteaTokenPath] - Path to Gitea token file
* @param {string} [options.giteaUrl] - Gitea API base URL
* @param {string} [options.repo] - Repo in format owner/repo
* @param {Function} [options.webhookUrl] - Webhook URL for external notifications
* @param {boolean} [options.enableNotifications=true] - Enable desktop notifications
*/
constructor(options = {}) {
this.giteaTokenPath = options.giteaTokenPath || '~/.config/gitea/token';
this.giteaUrl = options.giteaUrl || 'https://git.integrolabs.net/api/v1';
this.repo = options.repo || 'roctinam/ai-writing-guide';
this.webhookUrl = options.webhookUrl || null;
this.enableNotifications = options.enableNotifications !== false;
this.escalationLog = [];
}
/**
* Escalate to human via configured channels
* @param {string} level - Escalation level
* @param {EscalationContext} context - Escalation context
* @returns {Object} Escalation result
*/
async escalate(level, context) {
const escalation = {
level,
context,
timestamp: new Date().toISOString(),
channels: [],
};
// Desktop notification (non-blocking)
if (this.enableNotifications) {
try {
this.sendDesktopNotification(level, context);
escalation.channels.push('desktop_notification');
} catch (error) {
// Graceful fallback - notification failure doesn't block
console.warn('Desktop notification failed:', error.message);
}
}
// Gitea issue (for CRITICAL and EMERGENCY)
if (level === ESCALATION_LEVELS.CRITICAL || level === ESCALATION_LEVELS.EMERGENCY) {
try {
const issue = await this.createIssue({
title: `[Ralph Overseer] ${level.toUpperCase()}: ${context.reason}`,
body: this.buildIssueBody(context),
labels: ['ralph-overseer', level, 'automated'],
});
escalation.channels.push('gitea_issue');
escalation.issueNumber = issue.number;
escalation.issueUrl = issue.url;
} catch (error) {
console.error('Gitea issue creation failed:', error.message);
escalation.errors = escalation.errors || [];
escalation.errors.push({ channel: 'gitea', error: error.message });
}
}
// Webhook (if configured)
if (this.webhookUrl) {
try {
await this.sendWebhook(level, context);
escalation.channels.push('webhook');
} catch (error) {
console.warn('Webhook notification failed:', error.message);
}
}
// Log escalation
this.escalationLog.push(escalation);
return escalation;
}
/**
* Send desktop notification
* @param {string} level
* @param {EscalationContext} context
*/
sendDesktopNotification(level, context) {
// Try notify-send (Linux)
const title = `Ralph Overseer: ${level.toUpperCase()}`;
const message = `${context.reason}\n\nLoop: ${context.loopId}\nIteration: ${context.iterationNumber}`;
const urgency = {
[ESCALATION_LEVELS.INFO]: 'low',
[ESCALATION_LEVELS.WARNING]: 'normal',
[ESCALATION_LEVELS.CRITICAL]: 'critical',
[ESCALATION_LEVELS.EMERGENCY]: 'critical',
}[level] || 'normal';
try {
spawnSync('notify-send', [
'--urgency', urgency,
'--app-name', 'Ralph Overseer',
title,
message,
]);
} catch (error) {
// Silently fail - notifications are optional
}
}
/**
* Create Gitea issue
* @param {Object} details
* @param {string} details.title - Issue title
* @param {string} details.body - Issue body
* @param {string[]} [details.labels] - Issue labels
* @returns {Promise<Object>} Created issue
*/
async createIssue(details) {
// Read Gitea token
const token = this.getGiteaToken();
if (!token) {
throw new Error('Gitea token not found');
}
// Build API request
const [owner, repo] = this.repo.split('/');
const url = `${this.giteaUrl}/repos/${owner}/${repo}/issues`;
const body = JSON.stringify({
title: details.title,
body: details.body,
labels: (details.labels || []).map(name => ({ name })),
});
// Use curl for API call (more reliable than fetch in Node)
const result = spawnSync('curl', [
'-s',
'-X', 'POST',
'-H', `Authorization: token ${token}`,
'-H', 'Content-Type: application/json',
url,
'-d', body,
], {
encoding: 'utf8',
timeout: 10000,
});
if (result.status !== 0) {
throw new Error(`Gitea API call failed: ${result.stderr}`);
}
const response = JSON.parse(result.stdout);
return {
number: response.number,
url: response.html_url,
id: response.id,
};
}
/**
* Build issue body from context
* @param {EscalationContext} context
* @returns {string} Markdown body
*/
buildIssueBody(context) {
const { loopId, taskDescription, iterationNumber, reason, detection, intervention } = context;
let body = `## Ralph Loop Overseer Alert\n\n`;
body += `**Loop ID:** ${loopId}\n`;
body += `**Task:** ${taskDescription}\n`;
body += `**Iteration:** ${iterationNumber}\n`;
body += `**Timestamp:** ${new Date().toISOString()}\n\n`;
body += `### Issue\n\n${reason}\n\n`;
if (detection) {
body += `### Detection\n\n`;
body += `- **Type:** ${detection.type}\n`;
body += `- **Severity:** ${detection.severity}\n`;
body += `- **Message:** ${detection.message}\n\n`;
if (detection.evidence) {
body += `**Evidence:**\n\`\`\`json\n${JSON.stringify(detection.evidence, null, 2)}\n\`\`\`\n\n`;
}
if (detection.recommendations && detection.recommendations.length > 0) {
body += `**Recommendations:**\n`;
detection.recommendations.forEach((rec, idx) => {
body += `${idx + 1}. ${rec}\n`;
});
body += '\n';
}
}
if (intervention) {
body += `### Intervention\n\n`;
body += `- **Level:** ${intervention.level}\n`;
body += `- **Reason:** ${intervention.reason}\n\n`;
}
body += `### Actions Required\n\n`;
body += `- [ ] Review loop state and iteration history\n`;
body += `- [ ] Determine if loop should continue or abort\n`;
body += `- [ ] Provide guidance on how to proceed\n\n`;
body += `---\n`;
body += `*Automated escalation from Ralph External Loop Overseer*\n`;
return body;
}
/**
* Send webhook notification
* @param {string} level
* @param {EscalationContext} context
*/
async sendWebhook(level, context) {
if (!this.webhookUrl) {
return;
}
const payload = {
level,
context,
timestamp: new Date().toISOString(),
};
const result = spawnSync('curl', [
'-s',
'-X', 'POST',
'-H', 'Content-Type: application/json',
this.webhookUrl,
'-d', JSON.stringify(payload),
], {
encoding: 'utf8',
timeout: 5000,
});
if (result.status !== 0) {
throw new Error(`Webhook call failed: ${result.stderr}`);
}
}
/**
* Get Gitea token from file
* @returns {string|null}
*/
getGiteaToken() {
const expandedPath = this.giteaTokenPath.replace(/^~/, process.env.HOME || '');
if (!existsSync(expandedPath)) {
return null;
}
try {
const token = readFileSync(expandedPath, 'utf8').trim();
return token;
} catch (error) {
console.error('Error reading Gitea token:', error.message);
return null;
}
}
/**
* Get escalation log
* @param {number} [limit] - Max entries to return
* @returns {Array}
*/
getLog(limit = null) {
if (limit) {
return this.escalationLog.slice(-limit);
}
return [...this.escalationLog];
}
/**
* Get summary of escalations
* @returns {Object}
*/
getSummary() {
const summary = {
total: this.escalationLog.length,
byLevel: {},
byChannel: {},
};
this.escalationLog.forEach(esc => {
// Count by level
summary.byLevel[esc.level] = (summary.byLevel[esc.level] || 0) + 1;
// Count by channel
esc.channels.forEach(channel => {
summary.byChannel[channel] = (summary.byChannel[channel] || 0) + 1;
});
});
return summary;
}
/**
* Clear escalation log
*/
clearLog() {
this.escalationLog = [];
}
/**
* Export state for persistence
* @returns {Object}
*/
exportState() {
return {
escalationLog: this.escalationLog,
};
}
/**
* Import state from persistence
* @param {Object} state
*/
importState(state) {
if (state.escalationLog) {
this.escalationLog = state.escalationLog;
}
}
}
export default EscalationHandler;