ofiere-openclaw-plugin 4.16.0 → 4.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,7 +5,7 @@ Manage your Ofiere PM dashboard directly from OpenClaw agents. Create tasks, man
5
5
  ## Quick Install (One-Click)
6
6
 
7
7
  ```bash
8
- curl -sSL https://raw.githubusercontent.com/gilanggemar/Ofiere/main/ofiere-openclaw-plugin/install.sh | bash -s -- \
8
+ curl -sSL https://ofiere.com/scripts/install.sh | bash -s -- \
9
9
  --supabase-url "https://xxx.supabase.co" \
10
10
  --service-key "eyJ..." \
11
11
  --user-id "your-uuid"
@@ -16,7 +16,7 @@ Only 3 parameters needed. All agents get the plugin automatically.
16
16
  ## Uninstall
17
17
 
18
18
  ```bash
19
- curl -sSL https://raw.githubusercontent.com/gilanggemar/Ofiere/main/ofiere-openclaw-plugin/uninstall.sh | bash
19
+ curl -sSL https://ofiere.com/scripts/uninstall.sh | bash
20
20
  ```
21
21
 
22
22
  ## How It Works
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "ofiere-openclaw-plugin",
3
- "version": "4.16.0",
3
+ "version": "4.18.0",
4
4
  "type": "module",
5
- "description": "OpenClaw plugin for Ofiere PM - 11 meta-tools covering tasks, agents, projects, scheduling, knowledge, workflows, notifications, memory, prompts, constellation, and space file management",
5
+ "description": "OpenClaw plugin for Ofiere PM - 12 meta-tools covering tasks, agents, projects, scheduling, knowledge, workflows, notifications, memory, prompts, constellation, space file management, and execution plan builder",
6
6
  "keywords": ["openclaw", "ofiere", "project-management", "agents", "plugin"],
7
7
  "homepage": "https://github.com/gilanggemar/Ofiere",
8
8
  "repository": {
package/src/prompt.ts CHANGED
@@ -62,10 +62,19 @@ const TOOL_DOCS: Record<string, string> = {
62
62
  - Edge handles: condition edges use sourceHandle "condition-true"/"condition-false". Loop edges use "loop_body"/"done"
63
63
  - Variables: Use {{prev.nodeId.outputText}} for prior outputs, {{variables.key}} for stored variables`,
64
64
 
65
- OFIERE_NOTIFY_OPS: `- **OFIERE_NOTIFY_OPS** — Notifications (action: "list", "mark_read", "mark_all_read", "delete")
65
+ OFIERE_NOTIFY_OPS: `- **OFIERE_NOTIFY_OPS** — Notifications & Channel Reports (action: "list", "mark_read", "mark_all_read", "delete", "send_report", "schedule_report", "list_schedules", "delete_schedule")
66
66
  - list: Recent notifications. unread_only=true for unread only
67
67
  - mark_read: Mark one notification read by ID
68
- - mark_all_read: Mark all as read`,
68
+ - mark_all_read: Mark all as read
69
+ - send_report: Send a PM progress report to YOUR connected channels (Telegram, Discord, Slack, etc.)
70
+ - Required: scope_type (space|folder|project|task|all)
71
+ - Optional: scope_id, channel_types[] (filter to specific channels), include_completed
72
+ - The report is automatically generated from current PM data and sent through YOUR channel bindings ONLY
73
+ - schedule_report: Create a recurring/scheduled report
74
+ - Required: scope_type, recurrence_type (hourly|daily|weekly|monthly)
75
+ - Optional: scope_id, scope_label, channel_types[], recurrence_time (HH:MM UTC, default 09:00), recurrence_interval, recurrence_days_of_week (e.g. "mon,wed,fri"), include_completed
76
+ - list_schedules: View all your active report schedules
77
+ - delete_schedule: Remove a scheduled report by schedule_id`,
69
78
 
70
79
  OFIERE_MEMORY_OPS: `- **OFIERE_MEMORY_OPS** — Conversation history & knowledge memory (action: "list_conversations", "get_messages", "search_messages", "add_knowledge", "search_knowledge")
71
80
  - list_conversations: Recent chats, filter by agent_id
@@ -107,6 +116,20 @@ const TOOL_DOCS: Record<string, string> = {
107
116
  - delete_folder: Remove folder + all nested files recursively. Required: folder_id
108
117
  - share_file / unshare_file: Toggle shared status. Required: file_id
109
118
  - Files created here appear in the PM Space Files tab immediately`,
119
+
120
+ OFIERE_PLAN_OPS: `- **OFIERE_PLAN_OPS** — Visual execution plan builder (action: "list", "get", "create", "update", "delete", "add_nodes", "execute")
121
+ - list: All saved plans. Optional: space_id filter
122
+ - get: Full plan with complete node tree. Required: plan_id
123
+ - create: New plan. Required: name. Optional: description, space_id, nodes[] (initial tree)
124
+ - update: Modify plan. Required: plan_id. Optional: name, description, nodes[] (full tree replace)
125
+ - delete: Remove plan. Required: plan_id
126
+ - add_nodes: Add nodes to existing plan. Required: plan_id, nodes[]. Optional: parent_node_id (null = add as root)
127
+ - execute: Deploy plan into real PM tasks/folders/dependencies. Required: plan_id. Optional: create_folder (default true), create_scheduler (default true), space_id, folder_id
128
+ - Node types: task, gate, milestone
129
+ - Node fields: type, title, description, agent_id, priority (0-3), status (PENDING/IN_PROGRESS/DONE/FAILED), start_date, due_date, tags[], execution_steps[{text}], goals[{label, type?}], constraints[{label, type?}], system_prompt, children[], parallel (boolean)
130
+ - Plans are visual DAG drafts — they don't become real tasks until you call "execute"
131
+ - Execution maps ALL node fields into real tasks: execution_steps → custom_fields.execution_plan, goals → custom_fields.goals, constraints → custom_fields.constraints, system_prompt → custom_fields.system_prompt
132
+ - The user can see and edit your plans in the Planning Tab of the dashboard in real-time`,
110
133
  };
111
134
 
112
135
  export function getSystemPrompt(state: {
@@ -165,6 +188,13 @@ ${toolDocs}
165
188
  - When task instructions or system prompts contain file references like @[filename](file:FILE_ID), use OFIERE_FILE_OPS action:"read_text_file" file_id:"FILE_ID" to read the file content. Do NOT ask the user for the file — retrieve it yourself.
166
189
  - Use OFIERE_FILE_OPS to create output files (reports, data, configs) in the Space Files explorer. Prefer create_text_file for text-based outputs.
167
190
  - To save task output as a file, call OFIERE_FILE_OPS action:"create_text_file" with the space_id from the task context.
191
+ - CHANNEL REPORTS: When the user asks you to "send a report", "send progress", "update me on Telegram/Discord/Slack/WhatsApp", use OFIERE_NOTIFY_OPS action:"send_report". The report is generated from live PM data and sent through YOUR connected channels ONLY — not other agents' channels.
192
+ - To set up recurring reports (e.g. "send me a daily report at 9am"), use OFIERE_NOTIFY_OPS action:"schedule_report" with scope_type and recurrence_type.
193
+ - If a report is too long for the channel's message limit, save the full report as a markdown file using OFIERE_FILE_OPS action:"create_text_file" and send a summary to the channel instead.
194
+ - PLANNING WORKFLOW: Use OFIERE_PLAN_OPS to build complex multi-step execution flows BEFORE creating individual tasks. Build the plan → let the user review in the Planning Tab → execute when approved.
195
+ - When creating a plan with nodes, nest children inside each node's children[] array. Sequential children execute in order; set parallel: true on a parent node to fork its children into parallel branches.
196
+ - Always call OFIERE_PLAN_OPS action:"get" before action:"add_nodes" or action:"update" to see the current tree structure and node IDs.
197
+ - Execution ("execute") maps plan nodes 1:1 into real PM tasks with ALL enrichment fields preserved: execution_steps, goals, constraints, system_prompt. No data is lost in the handoff.
168
198
  </ofiere-pm>`;
169
199
  }
170
200
 
package/src/tools.ts CHANGED
@@ -2144,24 +2144,51 @@ function registerNotifyOps(
2144
2144
  supabase: SupabaseClient,
2145
2145
  userId: string,
2146
2146
  ): void {
2147
+ // Helper: resolve the agent's own ID for agent-scoped operations
2148
+ const resolveAgentId = (): string => {
2149
+ try {
2150
+ const state = api.getState?.() || {};
2151
+ return state.agentId || process.env.OFIERE_AGENT_ID || "";
2152
+ } catch { return process.env.OFIERE_AGENT_ID || ""; }
2153
+ };
2154
+
2147
2155
  api.registerTool({
2148
2156
  name: "OFIERE_NOTIFY_OPS",
2149
- label: "Ofiere Notification Operations",
2157
+ label: "Ofiere Notification & Report Operations",
2150
2158
  description:
2151
- `Read and manage notifications.\n\n` +
2159
+ `Read/manage notifications and send PM reports to your connected channels.\n\n` +
2152
2160
  `Actions:\n` +
2153
2161
  `- "list": List notifications. Optional: unread_only (true/false), limit\n` +
2154
2162
  `- "mark_read": Mark one as read. Required: id\n` +
2155
2163
  `- "mark_all_read": Mark all as read\n` +
2156
- `- "delete": Delete a notification. Required: id`,
2164
+ `- "delete": Delete a notification. Required: id\n` +
2165
+ `- "send_report": Send a PM progress report to YOUR connected channels (Telegram, Discord, etc.)\n` +
2166
+ ` Required: scope_type (space|folder|project|task|all)\n` +
2167
+ ` Optional: scope_id, channel_types[] (filter specific channels), include_completed (default true)\n` +
2168
+ `- "schedule_report": Create a recurring/scheduled report\n` +
2169
+ ` Required: scope_type, recurrence_type (hourly|daily|weekly|monthly)\n` +
2170
+ ` Optional: scope_id, scope_label, channel_types[], recurrence_time (HH:MM UTC, default 09:00),\n` +
2171
+ ` recurrence_interval (default 1), recurrence_days_of_week (e.g. "mon,wed,fri"), include_completed\n` +
2172
+ `- "list_schedules": List your active report schedules\n` +
2173
+ `- "delete_schedule": Delete a scheduled report. Required: schedule_id`,
2157
2174
  parameters: {
2158
2175
  type: "object",
2159
2176
  required: ["action"],
2160
2177
  properties: {
2161
- action: { type: "string", enum: ["list", "mark_read", "mark_all_read", "delete"] },
2178
+ action: { type: "string", enum: ["list", "mark_read", "mark_all_read", "delete", "send_report", "schedule_report", "list_schedules", "delete_schedule"] },
2162
2179
  id: { type: "string", description: "Notification ID" },
2180
+ schedule_id: { type: "string", description: "Schedule ID (for delete_schedule)" },
2163
2181
  unread_only: { type: "boolean", description: "Only show unread" },
2164
2182
  limit: { type: "number", description: "Max results (default 50)" },
2183
+ scope_type: { type: "string", enum: ["space", "folder", "project", "task", "all"], description: "Report scope" },
2184
+ scope_id: { type: "string", description: "ID of the space/folder/project/task" },
2185
+ scope_label: { type: "string", description: "Human-readable label for the scope" },
2186
+ channel_types: { type: "array", items: { type: "string" }, description: "Filter to specific channels (e.g. ['telegram'])" },
2187
+ include_completed: { type: "boolean", description: "Include completed tasks in report" },
2188
+ recurrence_type: { type: "string", enum: ["hourly", "daily", "weekly", "monthly"], description: "How often to send" },
2189
+ recurrence_interval: { type: "number", description: "Interval multiplier (default 1)" },
2190
+ recurrence_time: { type: "string", description: "Time in HH:MM UTC (default 09:00)" },
2191
+ recurrence_days_of_week: { type: "string", description: "Comma-separated days for weekly (e.g. mon,wed,fri)" },
2165
2192
  },
2166
2193
  },
2167
2194
  async execute(_id: string, params: Record<string, unknown>) {
@@ -2194,13 +2221,267 @@ function registerNotifyOps(
2194
2221
  if (error) return err(error.message);
2195
2222
  return ok({ message: "Notification deleted", ok: true });
2196
2223
  }
2224
+
2225
+ // ── SEND REPORT — Dispatch PM report to agent's channels ──
2226
+ case "send_report": {
2227
+ const scopeType = (params.scope_type as string) || "all";
2228
+ const scopeId = params.scope_id as string | undefined;
2229
+ const channelTypes = (params.channel_types as string[]) || [];
2230
+ const includeCompleted = params.include_completed !== false;
2231
+ const agentId = resolveAgentId();
2232
+ if (!agentId) return err("Cannot resolve your agent ID. Ensure OFIERE_AGENT_ID is configured.");
2233
+
2234
+ // Call the dashboard API to generate + dispatch
2235
+ const supabaseUrl = (supabase as any).supabaseUrl || process.env.OFIERE_SUPABASE_URL || process.env.SUPABASE_URL || "";
2236
+ const serviceRoleKey = (supabase as any).supabaseKey || process.env.OFIERE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_ROLE_KEY || "";
2237
+
2238
+ // Generate report directly from DB (we have service-role access)
2239
+ const reportResult = await generatePMReportDirect(supabase, userId, scopeType, scopeId, agentId, includeCompleted);
2240
+
2241
+ // Dispatch to channels
2242
+ const delivery = await dispatchReportDirect(supabase, userId, agentId, reportResult.report, channelTypes);
2243
+
2244
+ // Create audit notification
2245
+ await supabase.from("notifications").insert({
2246
+ id: crypto.randomUUID(),
2247
+ user_id: userId,
2248
+ title: `Report sent: ${reportResult.scopeLabel}`,
2249
+ message: `Dispatched via ${delivery.sent.join(", ") || "no channels"}. ${reportResult.taskCount} tasks.`,
2250
+ type: "system",
2251
+ category: "project_management",
2252
+ read: false,
2253
+ });
2254
+
2255
+ return ok({
2256
+ message: `Report sent to ${delivery.sent.length} channel(s)`,
2257
+ channels_sent: delivery.sent,
2258
+ channels_failed: delivery.failed,
2259
+ task_count: reportResult.taskCount,
2260
+ scope: reportResult.scopeLabel,
2261
+ report_preview: reportResult.report.slice(0, 500),
2262
+ });
2263
+ }
2264
+
2265
+ // ── SCHEDULE REPORT — Create recurring report ──
2266
+ case "schedule_report": {
2267
+ const scopeType = (params.scope_type as string) || "all";
2268
+ const recurrenceType = (params.recurrence_type as string) || "daily";
2269
+ const agentId = resolveAgentId();
2270
+ if (!agentId) return err("Cannot resolve your agent ID.");
2271
+
2272
+ const recurrenceTime = (params.recurrence_time as string) || "09:00";
2273
+ const recurrenceInterval = (params.recurrence_interval as number) || 1;
2274
+ const recurrenceDaysOfWeek = (params.recurrence_days_of_week as string) || null;
2275
+
2276
+ // Compute next run
2277
+ const [hours, minutes] = recurrenceTime.split(":").map(Number);
2278
+ const next = new Date();
2279
+ next.setUTCHours(hours, minutes, 0, 0);
2280
+ if (next.getTime() <= Date.now()) next.setUTCDate(next.getUTCDate() + 1);
2281
+
2282
+ const { data, error } = await supabase.from("pm_report_schedules").insert({
2283
+ user_id: userId,
2284
+ agent_id: agentId,
2285
+ scope_type: scopeType,
2286
+ scope_id: (params.scope_id as string) || null,
2287
+ scope_label: (params.scope_label as string) || null,
2288
+ channel_types: (params.channel_types as string[]) || [],
2289
+ recurrence_type: recurrenceType,
2290
+ recurrence_interval: recurrenceInterval,
2291
+ recurrence_time: recurrenceTime,
2292
+ recurrence_days_of_week: recurrenceDaysOfWeek,
2293
+ include_completed: params.include_completed !== false,
2294
+ next_run_at: next.toISOString(),
2295
+ }).select().single();
2296
+
2297
+ if (error) return err(error.message);
2298
+ return ok({
2299
+ message: `Scheduled ${recurrenceType} report created`,
2300
+ schedule_id: data.id,
2301
+ next_run: next.toISOString(),
2302
+ recurrence: `${recurrenceType} at ${recurrenceTime} UTC`,
2303
+ });
2304
+ }
2305
+
2306
+ // ── LIST SCHEDULES ──
2307
+ case "list_schedules": {
2308
+ const { data, error } = await supabase.from("pm_report_schedules")
2309
+ .select("*")
2310
+ .eq("user_id", userId)
2311
+ .order("created_at", { ascending: false });
2312
+ if (error) return err(error.message);
2313
+ return ok({ schedules: data || [], count: (data || []).length });
2314
+ }
2315
+
2316
+ // ── DELETE SCHEDULE ──
2317
+ case "delete_schedule": {
2318
+ const scheduleId = (params.schedule_id || params.id) as string;
2319
+ if (!scheduleId) return err("Missing required: schedule_id");
2320
+ const { error } = await supabase.from("pm_report_schedules")
2321
+ .delete().eq("id", scheduleId).eq("user_id", userId);
2322
+ if (error) return err(error.message);
2323
+ return ok({ message: "Schedule deleted", ok: true });
2324
+ }
2325
+
2197
2326
  default:
2198
- return err(`Unknown action "${action}".`);
2327
+ return err(`Unknown action "${action}". Valid: list, mark_read, mark_all_read, delete, send_report, schedule_report, list_schedules, delete_schedule`);
2199
2328
  }
2200
2329
  },
2201
2330
  });
2202
2331
  }
2203
2332
 
2333
+ // ── Inline report generation for plugin (avoids HTTP roundtrip) ──
2334
+
2335
+ async function generatePMReportDirect(
2336
+ supabase: SupabaseClient,
2337
+ userId: string,
2338
+ scopeType: string,
2339
+ scopeId: string | undefined,
2340
+ agentId: string,
2341
+ includeCompleted: boolean,
2342
+ ): Promise<{ report: string; scopeLabel: string; taskCount: number }> {
2343
+ let scopeLabel = "All Projects";
2344
+ let query = supabase.from("tasks").select("id, title, status, priority, progress, completed_at, due_date")
2345
+ .eq("user_id", userId).is("parent_task_id", null);
2346
+
2347
+ if (scopeType === "space" && scopeId) {
2348
+ query = query.eq("space_id", scopeId);
2349
+ const { data: s } = await supabase.from("pm_spaces").select("name").eq("id", scopeId).single();
2350
+ scopeLabel = s?.name || "Space";
2351
+ } else if (scopeType === "folder" && scopeId) {
2352
+ query = query.eq("folder_id", scopeId);
2353
+ const { data: f } = await supabase.from("pm_folders").select("name").eq("id", scopeId).single();
2354
+ scopeLabel = f?.name || "Folder";
2355
+ } else if (scopeType === "project" && scopeId) {
2356
+ query = query.eq("project_id", scopeId);
2357
+ const { data: p } = await supabase.from("pm_folders").select("name").eq("id", scopeId).single();
2358
+ scopeLabel = p?.name || "Project";
2359
+ }
2360
+
2361
+ const { data: tasks } = await query.order("priority", { ascending: false }).limit(40);
2362
+ const all = tasks || [];
2363
+
2364
+ // Agent name
2365
+ let agentName = agentId;
2366
+ const { data: agentRow } = await supabase.from("agents").select("name, codename")
2367
+ .eq("user_id", userId)
2368
+ .or(`id.eq.${agentId},name.ilike.${agentId},codename.ilike.${agentId}`)
2369
+ .limit(1).single();
2370
+ if (agentRow) agentName = agentRow.name || agentRow.codename || agentId;
2371
+
2372
+ const completed = all.filter((t: any) => t.status === "DONE");
2373
+ const inProgress = all.filter((t: any) => t.status === "IN_PROGRESS" || t.status === "RUNNING");
2374
+ const pending = all.filter((t: any) => t.status === "PENDING" || t.status === "NEW");
2375
+ const failed = all.filter((t: any) => t.status === "FAILED");
2376
+
2377
+ const lines: string[] = [];
2378
+ lines.push(`📊 Project Report — ${scopeLabel}`);
2379
+ lines.push(`Agent: ${agentName}`);
2380
+ lines.push("");
2381
+ lines.push("━━━ Summary ━━━");
2382
+ lines.push(`✅ ${completed.length} | 🔄 ${inProgress.length} | ⏳ ${pending.length} | ❌ ${failed.length}`);
2383
+
2384
+ if (inProgress.length > 0) {
2385
+ lines.push("");
2386
+ lines.push("━━━ Active ━━━");
2387
+ for (const t of inProgress.slice(0, 8)) {
2388
+ const pl = t.priority === 3 ? "CRIT" : t.priority === 2 ? "HIGH" : t.priority === 1 ? "MED" : "LOW";
2389
+ lines.push(`🔄 [${pl}] ${t.title}${t.progress ? ` — ${t.progress}%` : ""}`);
2390
+ }
2391
+ if (inProgress.length > 8) lines.push(`... +${inProgress.length - 8} more`);
2392
+ }
2393
+
2394
+ if (pending.length > 0) {
2395
+ lines.push("");
2396
+ lines.push("━━━ Pending ━━━");
2397
+ for (const t of pending.slice(0, 6)) {
2398
+ const pl = t.priority === 3 ? "CRIT" : t.priority === 2 ? "HIGH" : t.priority === 1 ? "MED" : "LOW";
2399
+ lines.push(`⏳ [${pl}] ${t.title}`);
2400
+ }
2401
+ if (pending.length > 6) lines.push(`... +${pending.length - 6} more`);
2402
+ }
2403
+
2404
+ if (includeCompleted && completed.length > 0) {
2405
+ lines.push("");
2406
+ lines.push("━━━ Done ━━━");
2407
+ const recent = completed.filter((t: any) => t.completed_at)
2408
+ .sort((a: any, b: any) => new Date(b.completed_at).getTime() - new Date(a.completed_at).getTime())
2409
+ .slice(0, 5);
2410
+ for (const t of recent) {
2411
+ const diff = Date.now() - new Date(t.completed_at).getTime();
2412
+ const mins = Math.floor(diff / 60000);
2413
+ const ago = mins < 60 ? `${mins}m ago` : mins < 1440 ? `${Math.floor(mins / 60)}h ago` : `${Math.floor(mins / 1440)}d ago`;
2414
+ lines.push(`✅ ${t.title} (${ago})`);
2415
+ }
2416
+ }
2417
+
2418
+ const now = new Date();
2419
+ lines.push("");
2420
+ lines.push(`Generated: ${now.toISOString().replace("T", " ").slice(0, 16)} UTC`);
2421
+
2422
+ return { report: lines.join("\n"), scopeLabel, taskCount: all.length };
2423
+ }
2424
+
2425
+ async function dispatchReportDirect(
2426
+ supabase: SupabaseClient,
2427
+ userId: string,
2428
+ agentId: string,
2429
+ report: string,
2430
+ channelTypes: string[],
2431
+ ): Promise<{ sent: string[]; failed: string[] }> {
2432
+ // Query agent's active channel bindings
2433
+ let query = supabase.from("channel_bindings").select("channel_type, channel_account_id, channel_config")
2434
+ .eq("user_id", userId)
2435
+ .eq("agent_id", agentId)
2436
+ .eq("notifications_enabled", true)
2437
+ .eq("is_active", true);
2438
+
2439
+ if (channelTypes.length > 0) {
2440
+ query = query.in("channel_type", channelTypes);
2441
+ }
2442
+
2443
+ const { data: bindings } = await query;
2444
+ if (!bindings || bindings.length === 0) {
2445
+ return { sent: [], failed: ["No active channel bindings found for this agent"] };
2446
+ }
2447
+
2448
+ const LIMITS: Record<string, number> = { telegram: 4096, discord: 2000, slack: 40000, whatsapp: 65536 };
2449
+ const sent: string[] = [];
2450
+ const failed: string[] = [];
2451
+
2452
+ for (const b of bindings) {
2453
+ const limit = LIMITS[b.channel_type] || 4000;
2454
+ let msg = report;
2455
+ if (msg.length > limit) {
2456
+ msg = msg.substring(0, limit - 80) + "\n\n... [Full report on dashboard]";
2457
+ }
2458
+
2459
+ // Use the Ofiere dashboard API to send via gateway
2460
+ // The dashboard URL is derived from the Supabase URL
2461
+ const dashboardUrl = process.env.OFIERE_DASHBOARD_URL || "https://ofiere.com";
2462
+ const dispatchSecret = process.env.DISPATCH_SECRET || "";
2463
+
2464
+ try {
2465
+ const res = await fetch(`${dashboardUrl}/api/channels/gateway-send`, {
2466
+ method: "POST",
2467
+ headers: { "Content-Type": "application/json", "x-dispatch-secret": dispatchSecret },
2468
+ body: JSON.stringify({
2469
+ userId,
2470
+ channelType: b.channel_type,
2471
+ accountId: b.channel_account_id || null,
2472
+ message: msg,
2473
+ }),
2474
+ });
2475
+ if (res.ok) sent.push(b.channel_type);
2476
+ else failed.push(`${b.channel_type}: HTTP ${res.status}`);
2477
+ } catch (e: any) {
2478
+ failed.push(`${b.channel_type}: ${e.message || "fetch error"}`);
2479
+ }
2480
+ }
2481
+
2482
+ return { sent, failed };
2483
+ }
2484
+
2204
2485
  // ═══════════════════════════════════════════════════════════════════════════════
2205
2486
  // META-TOOL 8: OFIERE_MEMORY_OPS — Conversations & Knowledge Fragments
2206
2487
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -3763,6 +4044,417 @@ async function handleShareFile(
3763
4044
  } catch (e) { return err(e instanceof Error ? e.message : String(e)); }
3764
4045
  }
3765
4046
 
4047
+ // ═══════════════════════════════════════════════════════════════════════════════
4048
+ // META-TOOL 12: OFIERE_PLAN_OPS — Execution Plan Builder
4049
+ // ═══════════════════════════════════════════════════════════════════════════════
4050
+
4051
+ function registerPlanOps(
4052
+ api: any,
4053
+ supabase: SupabaseClient,
4054
+ userId: string,
4055
+ resolveAgent: (id?: string) => Promise<string | null>,
4056
+ ): void {
4057
+ api.registerTool({
4058
+ name: "OFIERE_PLAN_OPS",
4059
+ label: "Ofiere Plan Operations",
4060
+ description:
4061
+ `Build and deploy visual execution plans (DAGs) in the Ofiere Planning Tab.\n\n` +
4062
+ `Actions:\n` +
4063
+ `- "list": List saved plans. Optional: space_id\n` +
4064
+ `- "get": Get full plan with node tree. Required: plan_id\n` +
4065
+ `- "create": New plan. Required: name. Optional: description, space_id, nodes[]\n` +
4066
+ `- "update": Modify plan. Required: plan_id. Optional: name, description, nodes[]\n` +
4067
+ `- "delete": Remove plan. Required: plan_id\n` +
4068
+ `- "add_nodes": Add nodes to plan. Required: plan_id, nodes[]. Optional: parent_node_id\n` +
4069
+ `- "execute": Deploy plan to real PM tasks. Required: plan_id. Optional: create_folder, create_scheduler, space_id, folder_id\n\n` +
4070
+ `Node structure: { type, title, description?, agent_id?, priority?, status?, start_date?, due_date?, tags?, execution_steps?[{text}], goals?[{label,type?}], constraints?[{label,type?}], system_prompt?, children?[], parallel? }`,
4071
+ parameters: {
4072
+ type: "object",
4073
+ required: ["action"],
4074
+ properties: {
4075
+ action: { type: "string", enum: ["list", "get", "create", "update", "delete", "add_nodes", "execute"] },
4076
+ plan_id: { type: "string", description: "Plan ID (required for get, update, delete, add_nodes, execute)" },
4077
+ name: { type: "string", description: "Plan name (required for create)" },
4078
+ description: { type: "string", description: "Plan description" },
4079
+ space_id: { type: "string", description: "PM Space ID" },
4080
+ folder_id: { type: "string", description: "PM Folder ID (for execute)" },
4081
+ parent_node_id: { type: "string", description: "Parent node ID to attach new nodes to (add_nodes). Null = root level" },
4082
+ create_folder: { type: "boolean", description: "Create a project folder on execute (default true)" },
4083
+ create_scheduler: { type: "boolean", description: "Create scheduler events for dated tasks on execute (default true)" },
4084
+ nodes: {
4085
+ type: "array",
4086
+ description: "Array of plan nodes. Each node can have children[] for nesting.",
4087
+ items: {
4088
+ type: "object",
4089
+ properties: {
4090
+ type: { type: "string", enum: ["task", "gate", "milestone"] },
4091
+ title: { type: "string" },
4092
+ description: { type: "string" },
4093
+ agent_id: { type: "string" },
4094
+ priority: { type: "number" },
4095
+ status: { type: "string", enum: ["PENDING", "IN_PROGRESS", "DONE", "FAILED"] },
4096
+ start_date: { type: "string" },
4097
+ due_date: { type: "string" },
4098
+ tags: { type: "array", items: { type: "string" } },
4099
+ execution_steps: { type: "array", items: { type: "object", properties: { text: { type: "string" } }, required: ["text"] } },
4100
+ goals: { type: "array", items: { type: "object", properties: { label: { type: "string" }, type: { type: "string" } }, required: ["label"] } },
4101
+ constraints: { type: "array", items: { type: "object", properties: { label: { type: "string" }, type: { type: "string" } }, required: ["label"] } },
4102
+ system_prompt: { type: "string" },
4103
+ parallel: { type: "boolean" },
4104
+ children: { type: "array", description: "Nested child nodes (recursive)" },
4105
+ },
4106
+ required: ["title"],
4107
+ },
4108
+ },
4109
+ },
4110
+ },
4111
+ async execute(_id: string, params: Record<string, unknown>) {
4112
+ const action = params.action as string;
4113
+ switch (action) {
4114
+ case "list": return handleListPlans(supabase, userId, params);
4115
+ case "get": return handleGetPlan(supabase, userId, params);
4116
+ case "create": return handleCreatePlan(supabase, userId, params);
4117
+ case "update": return handleUpdatePlan(supabase, userId, params);
4118
+ case "delete": return handleDeletePlan(supabase, userId, params);
4119
+ case "add_nodes": return handleAddNodes(supabase, userId, params);
4120
+ case "execute": return handleExecutePlan(supabase, userId, resolveAgent, params);
4121
+ default: return err(`Unknown action "${action}". Valid: list, get, create, update, delete, add_nodes, execute`);
4122
+ }
4123
+ },
4124
+ });
4125
+ }
4126
+
4127
+ // ── Plan helpers ─────────────────────────────────────────────────────────────
4128
+
4129
+ function generatePlanNodeId(): string {
4130
+ return `pn-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
4131
+ }
4132
+
4133
+ function generatePlanId(): string {
4134
+ return `plan-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
4135
+ }
4136
+
4137
+ /** Normalize agent-provided node into the PlanNode shape stored in plan_data */
4138
+ function normalizeNode(raw: any): any {
4139
+ const id = raw.id || generatePlanNodeId();
4140
+ const children = Array.isArray(raw.children) ? raw.children.map(normalizeNode) : [];
4141
+ return {
4142
+ id,
4143
+ type: raw.type || "task",
4144
+ title: raw.title || "Untitled",
4145
+ description: raw.description || undefined,
4146
+ agentId: raw.agent_id || raw.agentId || undefined,
4147
+ assignees: raw.agent_id ? [{ id: raw.agent_id, type: "agent" }] : undefined,
4148
+ priority: raw.priority ?? 1,
4149
+ status: raw.status || "PENDING",
4150
+ startDate: raw.start_date || raw.startDate || undefined,
4151
+ dueDate: raw.due_date || raw.dueDate || undefined,
4152
+ tags: raw.tags || [],
4153
+ executionSteps: Array.isArray(raw.execution_steps)
4154
+ ? raw.execution_steps.map((s: any, i: number) => ({ id: `step-${Date.now()}-${i}`, text: typeof s === "string" ? s : s.text || String(s), order: i }))
4155
+ : undefined,
4156
+ goals: Array.isArray(raw.goals)
4157
+ ? raw.goals.map((g: any, i: number) => ({ id: `goal-${Date.now()}-${i}`, type: g.type || "custom", label: typeof g === "string" ? g : g.label || String(g) }))
4158
+ : undefined,
4159
+ constraints: Array.isArray(raw.constraints)
4160
+ ? raw.constraints.map((c: any, i: number) => ({ id: `cstr-${Date.now()}-${i}`, type: c.type || "custom", label: typeof c === "string" ? c : c.label || String(c) }))
4161
+ : undefined,
4162
+ systemPrompt: raw.system_prompt || raw.systemPrompt || undefined,
4163
+ gateCondition: raw.gate_condition || raw.gateCondition || (raw.type === "gate" ? "all_predecessors_complete" : undefined),
4164
+ children,
4165
+ parallel: raw.parallel || false,
4166
+ collapsed: false,
4167
+ };
4168
+ }
4169
+
4170
+ /** Count all nodes in a tree (BFS) */
4171
+ function countAllNodes(nodes: any[]): number {
4172
+ let count = 0;
4173
+ const queue = [...nodes];
4174
+ while (queue.length > 0) {
4175
+ const n = queue.shift()!;
4176
+ count++;
4177
+ if (Array.isArray(n.children)) queue.push(...n.children);
4178
+ }
4179
+ return count;
4180
+ }
4181
+
4182
+ /** Collect unique agent IDs from tree */
4183
+ function collectAgents(nodes: any[]): string[] {
4184
+ const ids = new Set<string>();
4185
+ const queue = [...nodes];
4186
+ while (queue.length > 0) {
4187
+ const n = queue.shift()!;
4188
+ if (n.agentId) ids.add(n.agentId);
4189
+ if (Array.isArray(n.assignees)) n.assignees.forEach((a: any) => { if (a.id) ids.add(a.id); });
4190
+ if (Array.isArray(n.children)) queue.push(...n.children);
4191
+ }
4192
+ return Array.from(ids);
4193
+ }
4194
+
4195
+ /** Build a full ExecutionPlan JSON from metadata + nodes */
4196
+ function buildPlanJson(id: string, name: string, description: string | undefined, rootNodes: any[]): string {
4197
+ const plan = {
4198
+ id,
4199
+ name,
4200
+ description,
4201
+ rootNodes,
4202
+ createdAt: new Date().toISOString(),
4203
+ updatedAt: new Date().toISOString(),
4204
+ metadata: { totalTasks: countAllNodes(rootNodes), totalAgents: collectAgents(rootNodes) },
4205
+ };
4206
+ return JSON.stringify(plan, null, 2);
4207
+ }
4208
+
4209
+ /** Find a node by ID in a tree */
4210
+ function findNodeInTree(nodes: any[], nodeId: string): any | null {
4211
+ for (const n of nodes) {
4212
+ if (n.id === nodeId) return n;
4213
+ if (Array.isArray(n.children)) {
4214
+ const found = findNodeInTree(n.children, nodeId);
4215
+ if (found) return found;
4216
+ }
4217
+ }
4218
+ return null;
4219
+ }
4220
+
4221
+ // ── Plan action handlers ─────────────────────────────────────────────────────
4222
+
4223
+ async function handleListPlans(supabase: SupabaseClient, userId: string, params: Record<string, unknown>): Promise<ToolResult> {
4224
+ try {
4225
+ let query = supabase.from("pm_plans").select("id, name, description, space_id, is_deployed, deployed_at, created_at, updated_at").eq("user_id", userId).order("updated_at", { ascending: false });
4226
+ if (params.space_id) query = query.eq("space_id", params.space_id as string);
4227
+ const { data, error } = await query;
4228
+ if (error) return err(error.message);
4229
+ return ok({ plans: data || [], count: (data || []).length });
4230
+ } catch (e) { return err(e instanceof Error ? e.message : String(e)); }
4231
+ }
4232
+
4233
+ async function handleGetPlan(supabase: SupabaseClient, userId: string, params: Record<string, unknown>): Promise<ToolResult> {
4234
+ try {
4235
+ if (!params.plan_id) return err("Missing required field: plan_id");
4236
+ const { data, error } = await supabase.from("pm_plans").select("*").eq("id", params.plan_id as string).eq("user_id", userId).single();
4237
+ if (error) return err(error.message);
4238
+ let parsed: any = null;
4239
+ try { parsed = JSON.parse(data.plan_data || "{}"); } catch { parsed = {}; }
4240
+ return ok({ plan: { ...data, plan_data: parsed } });
4241
+ } catch (e) { return err(e instanceof Error ? e.message : String(e)); }
4242
+ }
4243
+
4244
+ async function handleCreatePlan(supabase: SupabaseClient, userId: string, params: Record<string, unknown>): Promise<ToolResult> {
4245
+ try {
4246
+ if (!params.name) return err("Missing required field: name");
4247
+ const planId = generatePlanId();
4248
+ const rootNodes = Array.isArray(params.nodes) ? (params.nodes as any[]).map(normalizeNode) : [];
4249
+ const planData = buildPlanJson(planId, params.name as string, params.description as string | undefined, rootNodes);
4250
+ const row = {
4251
+ id: planId, user_id: userId, space_id: (params.space_id as string) || null,
4252
+ name: params.name as string, description: (params.description as string) || null,
4253
+ plan_data: planData, is_deployed: false, deployed_at: null, updated_at: new Date().toISOString(),
4254
+ };
4255
+ const { error } = await supabase.from("pm_plans").insert(row);
4256
+ if (error) return err(error.message);
4257
+ return ok({ id: planId, message: `Plan "${params.name}" created with ${countAllNodes(rootNodes)} nodes`, node_count: countAllNodes(rootNodes) });
4258
+ } catch (e) { return err(e instanceof Error ? e.message : String(e)); }
4259
+ }
4260
+
4261
+ async function handleUpdatePlan(supabase: SupabaseClient, userId: string, params: Record<string, unknown>): Promise<ToolResult> {
4262
+ try {
4263
+ if (!params.plan_id) return err("Missing required field: plan_id");
4264
+ const updates: Record<string, any> = { updated_at: new Date().toISOString() };
4265
+ if (params.name !== undefined) updates.name = params.name;
4266
+ if (params.description !== undefined) updates.description = params.description;
4267
+ if (params.nodes !== undefined) {
4268
+ const rootNodes = Array.isArray(params.nodes) ? (params.nodes as any[]).map(normalizeNode) : [];
4269
+ updates.plan_data = buildPlanJson(params.plan_id as string, (params.name as string) || "Plan", params.description as string | undefined, rootNodes);
4270
+ }
4271
+ const { error } = await supabase.from("pm_plans").update(updates).eq("id", params.plan_id as string).eq("user_id", userId);
4272
+ if (error) return err(error.message);
4273
+ return ok({ message: `Plan updated` });
4274
+ } catch (e) { return err(e instanceof Error ? e.message : String(e)); }
4275
+ }
4276
+
4277
+ async function handleDeletePlan(supabase: SupabaseClient, userId: string, params: Record<string, unknown>): Promise<ToolResult> {
4278
+ try {
4279
+ if (!params.plan_id) return err("Missing required field: plan_id");
4280
+ const { error } = await supabase.from("pm_plans").delete().eq("id", params.plan_id as string).eq("user_id", userId);
4281
+ if (error) return err(error.message);
4282
+ return ok({ message: `Plan deleted`, deleted: true });
4283
+ } catch (e) { return err(e instanceof Error ? e.message : String(e)); }
4284
+ }
4285
+
4286
+ async function handleAddNodes(supabase: SupabaseClient, userId: string, params: Record<string, unknown>): Promise<ToolResult> {
4287
+ try {
4288
+ if (!params.plan_id) return err("Missing required field: plan_id");
4289
+ if (!params.nodes || !Array.isArray(params.nodes)) return err("Missing required field: nodes[]");
4290
+ const { data, error: fetchErr } = await supabase.from("pm_plans").select("plan_data, name").eq("id", params.plan_id as string).eq("user_id", userId).single();
4291
+ if (fetchErr || !data) return err("Plan not found");
4292
+ let plan: any;
4293
+ try { plan = JSON.parse(data.plan_data || "{}"); } catch { return err("Invalid plan data"); }
4294
+ const newNodes = (params.nodes as any[]).map(normalizeNode);
4295
+ const parentId = params.parent_node_id as string | undefined;
4296
+ if (parentId) {
4297
+ const parent = findNodeInTree(plan.rootNodes || [], parentId);
4298
+ if (!parent) return err(`Parent node "${parentId}" not found in plan`);
4299
+ if (!Array.isArray(parent.children)) parent.children = [];
4300
+ parent.children.push(...newNodes);
4301
+ } else {
4302
+ if (!Array.isArray(plan.rootNodes)) plan.rootNodes = [];
4303
+ plan.rootNodes.push(...newNodes);
4304
+ }
4305
+ plan.updatedAt = new Date().toISOString();
4306
+ plan.metadata = { totalTasks: countAllNodes(plan.rootNodes), totalAgents: collectAgents(plan.rootNodes) };
4307
+ const { error } = await supabase.from("pm_plans").update({ plan_data: JSON.stringify(plan, null, 2), updated_at: new Date().toISOString() }).eq("id", params.plan_id as string).eq("user_id", userId);
4308
+ if (error) return err(error.message);
4309
+ return ok({ message: `Added ${newNodes.length} nodes${parentId ? ` under ${parentId}` : " as roots"}`, total_nodes: countAllNodes(plan.rootNodes) });
4310
+ } catch (e) { return err(e instanceof Error ? e.message : String(e)); }
4311
+ }
4312
+
4313
+ async function handleExecutePlan(
4314
+ supabase: SupabaseClient, userId: string,
4315
+ resolveAgent: (id?: string) => Promise<string | null>,
4316
+ params: Record<string, unknown>,
4317
+ ): Promise<ToolResult> {
4318
+ try {
4319
+ if (!params.plan_id) return err("Missing required field: plan_id");
4320
+ const { data, error: fetchErr } = await supabase.from("pm_plans").select("*").eq("id", params.plan_id as string).eq("user_id", userId).single();
4321
+ if (fetchErr || !data) return err("Plan not found");
4322
+ let plan: any;
4323
+ try { plan = JSON.parse(data.plan_data || "{}"); } catch { return err("Invalid plan data"); }
4324
+ const rootNodes: any[] = plan.rootNodes || [];
4325
+ if (rootNodes.length === 0) return err("Plan has no nodes to execute");
4326
+
4327
+ const createFolder = params.create_folder !== false;
4328
+ const createScheduler = params.create_scheduler !== false;
4329
+ const spaceId = (params.space_id as string) || data.space_id || null;
4330
+ let folderId = (params.folder_id as string) || null;
4331
+
4332
+ // Step 1: Create project folder
4333
+ if (createFolder && spaceId) {
4334
+ const folderRow = {
4335
+ id: `folder-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
4336
+ user_id: userId, space_id: spaceId, name: plan.name || "Untitled Plan",
4337
+ type: "project", parent_folder_id: folderId, sort_order: 0,
4338
+ created_at: new Date().toISOString(), updated_at: new Date().toISOString(),
4339
+ };
4340
+ const { error: folderErr } = await supabase.from("pm_folders").insert(folderRow);
4341
+ if (!folderErr) folderId = folderRow.id;
4342
+ }
4343
+
4344
+ // Step 2: BFS — create tasks with full field mapping
4345
+ const idMap = new Map<string, string>();
4346
+ const queue: { node: any; parentTaskId: string | null }[] = rootNodes.map((n: any) => ({ node: n, parentTaskId: null }));
4347
+ let tasksCreated = 0;
4348
+
4349
+ while (queue.length > 0) {
4350
+ const { node, parentTaskId } = queue.shift()!;
4351
+ if (node.type === "task" || node.type === "milestone") {
4352
+ const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
4353
+ const now = new Date().toISOString();
4354
+ const agentId = node.agentId ? await resolveAgent(node.agentId) : null;
4355
+
4356
+ // Build custom_fields — the seamless handoff
4357
+ const cf: Record<string, any> = { pm_only: true };
4358
+ if (node.executionSteps && node.executionSteps.length > 0) cf.execution_plan = node.executionSteps;
4359
+ if (node.goals && node.goals.length > 0) cf.goals = node.goals;
4360
+ if (node.constraints && node.constraints.length > 0) cf.constraints = node.constraints;
4361
+ if (node.systemPrompt) cf.system_prompt = node.systemPrompt;
4362
+ if (agentId) cf.assignees = [{ id: agentId, type: "agent" }];
4363
+
4364
+ const taskRow = {
4365
+ id: taskId, user_id: userId, title: node.title, description: node.description || null,
4366
+ agent_id: agentId, assignee_type: agentId ? "agent" : "auto",
4367
+ priority: node.priority ?? 1, status: node.status || "PENDING",
4368
+ start_date: node.startDate || null, due_date: node.dueDate || null,
4369
+ tags: node.tags || [], space_id: spaceId, folder_id: folderId,
4370
+ parent_task_id: parentTaskId, progress: 0, sort_order: 0,
4371
+ custom_fields: cf, created_at: now, updated_at: now,
4372
+ };
4373
+
4374
+ const { error: taskErr } = await supabase.from("tasks").insert(taskRow);
4375
+ if (!taskErr) {
4376
+ idMap.set(node.id, taskId);
4377
+ tasksCreated++;
4378
+
4379
+ // Scheduler event for dated tasks
4380
+ if (createScheduler && node.startDate && agentId) {
4381
+ try {
4382
+ const parsedDate = new Date(node.startDate);
4383
+ const scheduledDate = parsedDate.toISOString().split("T")[0];
4384
+ const hasTime = /[T ]\d{2}:\d{2}/.test(node.startDate);
4385
+ const scheduledTime = hasTime
4386
+ ? `${String(parsedDate.getUTCHours()).padStart(2, "0")}:${String(parsedDate.getUTCMinutes()).padStart(2, "0")}`
4387
+ : "09:00";
4388
+ let nextRunAt = Math.floor(parsedDate.getTime() / 1000);
4389
+ if (nextRunAt <= Math.floor(Date.now() / 1000)) nextRunAt = Math.floor(Date.now() / 1000) + 60;
4390
+
4391
+ await supabase.from("scheduler_events").insert({
4392
+ id: crypto.randomUUID(), user_id: userId, task_id: taskId, agent_id: agentId,
4393
+ title: node.title, description: node.description || null,
4394
+ scheduled_date: scheduledDate, scheduled_time: scheduledTime,
4395
+ duration_minutes: 30, recurrence_type: "none", recurrence_interval: 1,
4396
+ status: "scheduled", next_run_at: nextRunAt, run_count: 0, priority: node.priority ?? 1,
4397
+ });
4398
+ } catch { /* scheduler is best-effort */ }
4399
+ }
4400
+ }
4401
+ }
4402
+
4403
+ // Enqueue children
4404
+ const realId = idMap.get(node.id) || null;
4405
+ if (Array.isArray(node.children)) {
4406
+ for (const child of node.children) {
4407
+ queue.push({ node: child, parentTaskId: node.parallel ? parentTaskId : realId });
4408
+ }
4409
+ }
4410
+ }
4411
+
4412
+ // Step 3: Create dependencies (sequential chain)
4413
+ const depsQueue = [...rootNodes];
4414
+ let depsCreated = 0;
4415
+ while (depsQueue.length > 0) {
4416
+ const node = depsQueue.shift()!;
4417
+ const nodeRealId = idMap.get(node.id);
4418
+ if (nodeRealId && !node.parallel && node.children?.length > 0) {
4419
+ const firstChildId = idMap.get(node.children[0].id);
4420
+ if (firstChildId) {
4421
+ await supabase.from("task_dependencies").insert({ user_id: userId, predecessor_id: nodeRealId, successor_id: firstChildId, dependency_type: "finish_to_start", lag_days: 0 });
4422
+ depsCreated++;
4423
+ }
4424
+ for (let i = 1; i < node.children.length; i++) {
4425
+ const prevId = idMap.get(node.children[i - 1].id);
4426
+ const currId = idMap.get(node.children[i].id);
4427
+ if (prevId && currId) {
4428
+ await supabase.from("task_dependencies").insert({ user_id: userId, predecessor_id: prevId, successor_id: currId, dependency_type: "finish_to_start", lag_days: 0 });
4429
+ depsCreated++;
4430
+ }
4431
+ }
4432
+ }
4433
+ if (nodeRealId && node.parallel && node.children?.length > 0) {
4434
+ for (const child of node.children) {
4435
+ const childId = idMap.get(child.id);
4436
+ if (childId) {
4437
+ await supabase.from("task_dependencies").insert({ user_id: userId, predecessor_id: nodeRealId, successor_id: childId, dependency_type: "finish_to_start", lag_days: 0 });
4438
+ depsCreated++;
4439
+ }
4440
+ }
4441
+ }
4442
+ if (Array.isArray(node.children)) depsQueue.push(...node.children);
4443
+ }
4444
+
4445
+ // Mark plan as deployed
4446
+ await supabase.from("pm_plans").update({ is_deployed: true, deployed_at: new Date().toISOString(), updated_at: new Date().toISOString() }).eq("id", params.plan_id as string).eq("user_id", userId);
4447
+
4448
+ return ok({
4449
+ message: `Plan "${plan.name}" executed successfully`,
4450
+ tasks_created: tasksCreated,
4451
+ dependencies_created: depsCreated,
4452
+ folder_id: folderId,
4453
+ space_id: spaceId,
4454
+ });
4455
+ } catch (e) { return err(e instanceof Error ? e.message : String(e)); }
4456
+ }
4457
+
3766
4458
  // ═══════════════════════════════════════════════════════════════════════════════
3767
4459
  // Public: Register All Meta-Tools
3768
4460
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -3791,9 +4483,10 @@ export function registerTools(
3791
4483
  registerPromptOps(api, supabase, userId); // 9
3792
4484
  registerConstellationOps(api, supabase, userId); // 10
3793
4485
  registerFileOps(api, supabase, userId); // 11
4486
+ registerPlanOps(api, supabase, userId, resolveAgent); // 12
3794
4487
 
3795
4488
  // ── Count and log ──
3796
- const toolCount = 11;
4489
+ const toolCount = 12;
3797
4490
  const callerName = getCallingAgentName(api);
3798
4491
  const agentLabel = fallbackAgentId || callerName || "auto-detect";
3799
4492
  api.logger.info(`[ofiere] ${toolCount} meta-tools registered (agent: ${agentLabel})`);