diff --git a/index.ts b/index.ts index 6bdeebe..e801edd 100644 --- a/index.ts +++ b/index.ts @@ -36,6 +36,31 @@ const plugin = { description: "Work mode: parallel (each project independent) or sequential (1 DEV + 1 QA globally)", default: "parallel", }, + orchestratorDm: { + type: "string", + description: "Telegram/WhatsApp chat ID for orchestrator DM notifications (heartbeat summaries)", + }, + notifications: { + type: "object", + description: "Notification settings for worker lifecycle events", + properties: { + heartbeatDm: { + type: "boolean", + description: "Send heartbeat summaries to orchestrator DM. Default: true", + default: true, + }, + workerStart: { + type: "boolean", + description: "Post when worker starts a task. Default: true", + default: true, + }, + workerComplete: { + type: "boolean", + description: "Post when worker completes a task. Default: true", + default: true, + }, + }, + }, }, }, diff --git a/lib/notify.ts b/lib/notify.ts new file mode 100644 index 0000000..57624a0 --- /dev/null +++ b/lib/notify.ts @@ -0,0 +1,242 @@ +/** + * notify.ts — Programmatic alerting for worker lifecycle events. + * + * Sends notifications to project groups and orchestrator DM for visibility + * into the DevClaw pipeline. + * + * Event types: + * - workerStart: Worker spawned/resumed for a task (→ project group) + * - workerComplete: Worker completed task (→ project group) + * - heartbeat: Heartbeat tick summary (→ orchestrator DM) + */ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { log as auditLog } from "./audit.js"; + +const execFileAsync = promisify(execFile); + +export type NotificationConfig = { + /** Send heartbeat summaries to orchestrator DM. Default: true */ + heartbeatDm?: boolean; + /** Post when worker starts a task. Default: true */ + workerStart?: boolean; + /** Post when worker completes a task. Default: true */ + workerComplete?: boolean; +}; + +export type NotifyEvent = + | { + type: "workerStart"; + project: string; + groupId: string; + issueId: number; + issueTitle: string; + role: "dev" | "qa"; + model: string; + sessionAction: "spawn" | "send"; + } + | { + type: "workerComplete"; + project: string; + groupId: string; + issueId: number; + role: "dev" | "qa"; + result: "done" | "pass" | "fail" | "refine"; + summary?: string; + nextState?: string; + } + | { + type: "heartbeat"; + projectsScanned: number; + healthFixes: number; + pickups: number; + skipped: number; + dryRun: boolean; + pickupDetails?: Array<{ + project: string; + issueId: number; + role: "dev" | "qa"; + }>; + }; + +/** + * Build a human-readable message for a notification event. + */ +function buildMessage(event: NotifyEvent): string { + switch (event.type) { + case "workerStart": { + const action = event.sessionAction === "spawn" ? "🚀 Started" : "▶️ Resumed"; + return `${action} ${event.role.toUpperCase()} (${event.model}) on #${event.issueId}: ${event.issueTitle}`; + } + + case "workerComplete": { + const icons: Record = { + done: "✅", + pass: "🎉", + fail: "❌", + refine: "🤔", + }; + const icon = icons[event.result] ?? "📋"; + const resultText: Record = { + done: "completed", + pass: "PASSED", + fail: "FAILED", + refine: "needs refinement", + }; + const text = resultText[event.result] ?? event.result; + let msg = `${icon} ${event.role.toUpperCase()} ${text} #${event.issueId}`; + if (event.summary) { + msg += ` — ${event.summary}`; + } + if (event.nextState) { + msg += ` → ${event.nextState}`; + } + return msg; + } + + case "heartbeat": { + if (event.dryRun) { + return `🔄 Heartbeat (dry run): scanned ${event.projectsScanned} projects, would pick up ${event.pickups} tasks`; + } + const parts = [`🔄 Heartbeat: scanned ${event.projectsScanned} projects`]; + if (event.healthFixes > 0) { + parts.push(`fixed ${event.healthFixes} zombie(s)`); + } + if (event.pickups > 0) { + parts.push(`spawned ${event.pickups} worker(s)`); + if (event.pickupDetails && event.pickupDetails.length > 0) { + const details = event.pickupDetails + .map((p) => `${p.project}#${p.issueId}(${p.role})`) + .join(", "); + parts.push(`[${details}]`); + } + } + if (event.pickups === 0 && event.healthFixes === 0) { + parts.push("no actions needed"); + } + return parts.join(", "); + } + } +} + +/** + * Send a notification message to a Telegram/WhatsApp target. + * + * Uses the OpenClaw CLI to invoke the message tool. + * Fails silently (logs error but doesn't throw) to avoid breaking the main flow. + */ +async function sendMessage( + target: string, + message: string, + channel: "telegram" | "whatsapp", + workspaceDir: string, +): Promise { + try { + // Use openclaw agent to send via message tool + // The message tool requires action=send, target, message + await execFileAsync( + "openclaw", + [ + "gateway", + "call", + "tools.invoke", + "--params", + JSON.stringify({ + tool: "message", + params: { + action: "send", + target, + message, + }, + }), + ], + { timeout: 30_000 }, + ); + return true; + } catch (err) { + // Log but don't throw — notifications shouldn't break the main flow + await auditLog(workspaceDir, "notify_error", { + target, + channel, + error: (err as Error).message, + }); + return false; + } +} + +/** + * Send a notification for a worker lifecycle event. + * + * Respects notification config settings. + * Returns true if notification was sent (or skipped due to config), false on error. + */ +export async function notify( + event: NotifyEvent, + opts: { + workspaceDir: string; + config?: NotificationConfig; + /** Target for project-scoped notifications (groupId) */ + groupId?: string; + /** Channel type for routing */ + channel?: "telegram" | "whatsapp"; + /** Target for DM notifications (orchestrator) */ + orchestratorDm?: string; + }, +): Promise { + const config = opts.config ?? {}; + const channel = opts.channel ?? "telegram"; + + // Check if notification is enabled + if (event.type === "workerStart" && config.workerStart === false) { + return true; // Skipped, not an error + } + if (event.type === "workerComplete" && config.workerComplete === false) { + return true; + } + if (event.type === "heartbeat" && config.heartbeatDm === false) { + return true; + } + + const message = buildMessage(event); + + // Determine target + let target: string | undefined; + if (event.type === "heartbeat") { + target = opts.orchestratorDm; + } else { + target = opts.groupId ?? (event as { groupId?: string }).groupId; + } + + if (!target) { + // No target specified, can't send + await auditLog(opts.workspaceDir, "notify_skip", { + eventType: event.type, + reason: "no target", + }); + return true; // Not an error, just nothing to do + } + + // Audit the notification attempt + await auditLog(opts.workspaceDir, "notify", { + eventType: event.type, + target, + channel, + message, + }); + + return sendMessage(target, message, channel, opts.workspaceDir); +} + +/** + * Get notification config from plugin config. + */ +export function getNotificationConfig( + pluginConfig?: Record, +): NotificationConfig { + const notifications = pluginConfig?.notifications as NotificationConfig | undefined; + return { + heartbeatDm: notifications?.heartbeatDm ?? true, + workerStart: notifications?.workerStart ?? true, + workerComplete: notifications?.workerComplete ?? true, + }; +} diff --git a/lib/tools/heartbeat-tick.ts b/lib/tools/heartbeat-tick.ts index acb4ab6..2863957 100644 --- a/lib/tools/heartbeat-tick.ts +++ b/lib/tools/heartbeat-tick.ts @@ -29,6 +29,7 @@ import type { ToolContext } from "../types.js"; import { detectContext, generateGuardrails } from "../context-guard.js"; import { type Tier } from "../tiers.js"; import { log as auditLog } from "../audit.js"; +import { notify, getNotificationConfig } from "../notify.js"; /** Labels that map to DEV role */ const DEV_LABELS: StateLabel[] = ["To Do", "To Improve"]; @@ -496,6 +497,32 @@ export function createHeartbeatTickTool(api: OpenClawPluginApi) { skipped: result.skipped.length, }); + // Send heartbeat notification to orchestrator DM + const notifyConfig = getNotificationConfig(pluginConfig); + const orchestratorDm = pluginConfig?.orchestratorDm as string | undefined; + + await notify( + { + type: "heartbeat", + projectsScanned: projectEntries.length, + healthFixes: result.healthFixes.length, + pickups: result.pickups.length, + skipped: result.skipped.length, + dryRun, + pickupDetails: result.pickups.map((p) => ({ + project: p.project, + issueId: p.issueId, + role: p.role, + })), + }, + { + workspaceDir, + config: notifyConfig, + orchestratorDm, + channel: "telegram", + }, + ); + return jsonResult(result); }, }); diff --git a/lib/tools/task-complete.ts b/lib/tools/task-complete.ts index d278d1c..da946bd 100644 --- a/lib/tools/task-complete.ts +++ b/lib/tools/task-complete.ts @@ -25,6 +25,7 @@ import { readProjects, } from "../projects.js"; import type { ToolContext } from "../types.js"; +import { notify, getNotificationConfig } from "../notify.js"; const execFileAsync = promisify(execFile); @@ -266,6 +267,42 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) { output.announcement = `🤔 QA REFINE #${issueId}${summary ? ` — ${summary}` : ""}. Awaiting human decision.`; } + // Send notification to project group + const pluginConfig = api.pluginConfig as Record | undefined; + const notifyConfig = getNotificationConfig(pluginConfig); + + // Determine next state for the notification + let nextState: string | undefined; + if (role === "dev" && result === "done") { + nextState = "QA queue"; + } else if (role === "qa" && result === "pass") { + nextState = "Done!"; + } else if (role === "qa" && result === "fail") { + nextState = "back to DEV"; + } else if (role === "qa" && result === "refine") { + nextState = "awaiting human decision"; + } + + await notify( + { + type: "workerComplete", + project: project.name, + groupId, + issueId, + role, + result, + summary, + nextState, + }, + { + workspaceDir, + config: notifyConfig, + groupId, + // Channel detection: default to telegram if not available + channel: "telegram", + }, + ); + // Audit log await auditLog(workspaceDir, "task_complete", { project: project.name, diff --git a/lib/tools/task-pickup.ts b/lib/tools/task-pickup.ts index a558aec..4248a38 100644 --- a/lib/tools/task-pickup.ts +++ b/lib/tools/task-pickup.ts @@ -21,6 +21,7 @@ import { getProject, getWorker, readProjects } from "../projects.js"; import type { ToolContext } from "../types.js"; import { detectContext, generateGuardrails } from "../context-guard.js"; import { isDevTier, isTier, type Tier } from "../tiers.js"; +import { notify, getNotificationConfig } from "../notify.js"; /** Labels that map to DEV role */ const DEV_LABELS: StateLabel[] = ["To Do", "To Improve"]; @@ -300,7 +301,28 @@ export function createTaskPickupTool(api: OpenClawPluginApi) { pluginConfig, }); - // 9. Build result + // 9. Send notification to project group + const notifyConfig = getNotificationConfig(pluginConfig); + await notify( + { + type: "workerStart", + project: project.name, + groupId, + issueId: issue.iid, + issueTitle: issue.title, + role, + model: dispatchResult.modelAlias, + sessionAction: dispatchResult.sessionAction, + }, + { + workspaceDir, + config: notifyConfig, + groupId, + channel: context.channel, + }, + ); + + // 10. Build result const result: Record = { success: true, project: project.name,