Files
devclaw-gitea/lib/notify.ts
Lauren ten Hoor 57c78f3656 feat: Implement Architect role & design_task tool (#189)
Adds the Architect role for design/architecture investigations with
persistent sessions and structured design proposals.

## New Features

- **Architect role** with opus (complex) and sonnet (standard) levels
- **design_task tool** — Creates To Design issues and dispatches architect
- **Workflow states:** To Design → Designing → Planning
- **Completion rules:** architect:done → Planning, architect:blocked → Refining
- **Auto-level selection** based on complexity keywords

## Files Changed (22 files, 546 additions)

### New Files
- lib/tools/design-task.ts — design_task tool implementation
- lib/tools/design-task.test.ts — 16 tests for architect functionality

### Core Changes
- lib/tiers.ts — ARCHITECT_LEVELS, WorkerRole type, models, emoji
- lib/workflow.ts — toDesign/designing states, completion rules
- lib/projects.ts — architect WorkerState on Project type
- lib/dispatch.ts — architect role support in dispatch pipeline
- lib/services/pipeline.ts — architect completion rules
- lib/model-selector.ts — architect level selection heuristic

### Integration
- index.ts — Register design_task tool, architect config schema
- lib/notify.ts — architect role in notifications
- lib/bootstrap-hook.ts — architect session key parsing
- lib/services/tick.ts — architect in queue processing
- lib/services/heartbeat.ts — architect in health checks
- lib/tools/health.ts — architect in health scans
- lib/tools/status.ts — architect in status dashboard
- lib/tools/work-start.ts — architect role option
- lib/tools/work-finish.ts — architect validation
- lib/tools/project-register.ts — architect labels + role scaffolding
- lib/templates.ts — architect instructions + AGENTS.md updates
- lib/setup/workspace.ts — architect role file scaffolding
- lib/setup/smart-model-selector.ts — architect in model assignment
- lib/setup/llm-model-selector.ts — architect in LLM prompt
2026-02-14 17:08:17 +08:00

199 lines
5.7 KiB
TypeScript

/**
* notify.ts — Programmatic alerting for worker lifecycle events.
*
* Sends notifications to project groups for visibility into the DevClaw pipeline.
*
* Event types:
* - workerStart: Worker spawned/resumed for a task (→ project group)
* - workerComplete: Worker completed task (→ project group)
*/
import { log as auditLog } from "./audit.js";
import type { PluginRuntime } from "openclaw/plugin-sdk";
/** Per-event-type toggle. All default to true — set to false to suppress. */
export type NotificationConfig = Partial<Record<NotifyEvent["type"], boolean>>;
export type NotifyEvent =
| {
type: "workerStart";
project: string;
groupId: string;
issueId: number;
issueTitle: string;
issueUrl: string;
role: "dev" | "qa" | "architect";
level: string;
sessionAction: "spawn" | "send";
}
| {
type: "workerComplete";
project: string;
groupId: string;
issueId: number;
issueUrl: string;
role: "dev" | "qa" | "architect";
result: "done" | "pass" | "fail" | "refine" | "blocked";
summary?: string;
nextState?: string;
};
/**
* 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.level}) on #${event.issueId}: ${event.issueTitle}\n🔗 ${event.issueUrl}`;
}
case "workerComplete": {
const icons: Record<string, string> = {
done: "✅",
pass: "🎉",
fail: "❌",
refine: "🤔",
blocked: "🚫",
};
const icon = icons[event.result] ?? "📋";
const resultText: Record<string, string> = {
done: "completed",
pass: "PASSED",
fail: "FAILED",
refine: "needs refinement",
blocked: "BLOCKED",
};
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}`;
}
msg += `\n🔗 ${event.issueUrl}`;
return msg;
}
}
}
/**
* Send a notification message via the plugin runtime API.
*
* Uses the runtime's native send functions to bypass CLI → WebSocket timeouts.
* Falls back gracefully on error (notifications shouldn't break the main flow).
*/
async function sendMessage(
target: string,
message: string,
channel: string,
workspaceDir: string,
runtime?: PluginRuntime,
): Promise<boolean> {
try {
// Use runtime API when available (avoids CLI subprocess timeouts)
if (runtime) {
if (channel === "telegram") {
await runtime.channel.telegram.sendMessageTelegram(target, message, { silent: true });
return true;
}
if (channel === "whatsapp") {
await runtime.channel.whatsapp.sendMessageWhatsApp(target, message, { verbose: false });
return true;
}
if (channel === "discord") {
await runtime.channel.discord.sendMessageDiscord(target, message);
return true;
}
if (channel === "slack") {
await runtime.channel.slack.sendMessageSlack(target, message);
return true;
}
if (channel === "signal") {
await runtime.channel.signal.sendMessageSignal(target, message);
return true;
}
}
// Fallback: use CLI (for unsupported channels or when runtime isn't available)
// Import lazily to avoid circular dependency issues
const { runCommand } = await import("./run-command.js");
await runCommand(
[
"openclaw",
"message",
"send",
"--channel",
channel,
"--target",
target,
"--message",
message,
"--json",
],
{ timeoutMs: 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.
*
* Returns true if notification was sent, 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 (e.g. "telegram", "whatsapp", "discord", "slack") */
channel?: string;
/** Plugin runtime for direct API access (avoids CLI subprocess timeouts) */
runtime?: PluginRuntime;
},
): Promise<boolean> {
if (opts.config?.[event.type] === false) return true;
const channel = opts.channel ?? "telegram";
const message = buildMessage(event);
const target = opts.groupId ?? (event as { groupId?: string }).groupId;
if (!target) {
await auditLog(opts.workspaceDir, "notify_skip", {
eventType: event.type,
reason: "no target",
});
return true; // Not an error, just nothing to do
}
await auditLog(opts.workspaceDir, "notify", {
eventType: event.type,
target,
channel,
message,
});
return sendMessage(target, message, channel, opts.workspaceDir, opts.runtime);
}
/**
* Extract notification config from plugin config.
* All event types default to enabled (true).
*/
export function getNotificationConfig(
pluginConfig?: Record<string, unknown>,
): NotificationConfig {
return (pluginConfig?.notifications as NotificationConfig) ?? {};
}