ofiere-openclaw-plugin 4.41.1 → 4.43.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ofiere-openclaw-plugin",
3
- "version": "4.41.1",
3
+ "version": "4.43.0",
4
4
  "type": "module",
5
5
  "description": "OpenClaw plugin for Ofiere PM - 16 meta-tools covering tasks, agents, projects, scheduling, knowledge, workflows, notifications, memory, prompts, constellation, space file management, execution plan builder, SOP management, agent brain, talent management, and corporate frameworks",
6
6
  "keywords": ["openclaw", "ofiere", "project-management", "agents", "plugin"],
@@ -15,6 +15,7 @@ import {
15
15
  invalidateAgentTier,
16
16
  } from "./agent-tier.js";
17
17
  import { issueAttachmentToken, verifyAttachmentToken } from "./attach-token.js";
18
+ import { loadSubagentRow, buildStaffPersonaBlock, readDispatchSubagentId, type SubagentRow } from "./staffPersona.js";
18
19
 
19
20
  interface ToolResult {
20
21
  content: Array<{ type: "text"; text: string }>;
@@ -360,8 +361,29 @@ async function buildAttachmentBlock(args: {
360
361
  supabase: SupabaseClient;
361
362
  userId: string;
362
363
  agentId: string;
364
+ subagentId?: string | null;
363
365
  }): Promise<string> {
364
- const { supabase, userId, agentId } = args;
366
+ const { supabase, userId, agentId, subagentId } = args;
367
+
368
+ // When a staff dispatch arrives, prefer attachments scoped to the subagent
369
+ // so a chief and its staff never share an attachment surface implicitly.
370
+ if (subagentId) {
371
+ const { data: staffConv } = await supabase
372
+ .from("conversations")
373
+ .select("id, attached_sop_ids, attached_framework_ids")
374
+ .eq("user_id", userId)
375
+ .eq("agent_id", agentId)
376
+ .eq("subagent_id", subagentId)
377
+ .order("updated_at", { ascending: false })
378
+ .limit(1)
379
+ .maybeSingle();
380
+ if (staffConv) {
381
+ const sopIds: string[] = (staffConv.attached_sop_ids as string[] | null) || [];
382
+ const fwIds: string[] = (staffConv.attached_framework_ids as string[] | null) || [];
383
+ return renderBlockForIds({ supabase, userId, sopIds, fwIds });
384
+ }
385
+ // No staff-scoped conversation yet — fall through to chief-level lookup.
386
+ }
365
387
 
366
388
  // Find the most recently active conversation for this agent. The dashboard
367
389
  // bumps `updated_at` whenever a message is sent or attachments change, so
@@ -402,6 +424,36 @@ export function registerAttachmentContextHook(args: {
402
424
  }
403
425
  if (!resolvedAgentId) return;
404
426
 
427
+ // Cycle 7b — staff persona injection. Only fires when subagent_id is
428
+ // present in dispatch metadata (task-dispatcher / scheduler / explicit
429
+ // dispatch params). Plain user chats with the chief never persona-swap.
430
+ const subagentId = readDispatchSubagentId(ctx);
431
+ let staffPrefix = "";
432
+ let staffRow: SubagentRow | null = null;
433
+ if (subagentId) {
434
+ staffRow = await loadSubagentRow(supabase, userId, subagentId);
435
+ if (staffRow && staffRow.chief_agent_id === resolvedAgentId) {
436
+ staffPrefix = buildStaffPersonaBlock(staffRow) + "\n\n---\n\n";
437
+ api.logger?.debug?.(
438
+ `[ofiere-staff] subagent ${subagentId} (${staffRow.name}) reporting to ${resolvedAgentId} — persona injected`,
439
+ );
440
+ } else if (staffRow) {
441
+ api.logger?.warn?.(
442
+ `[ofiere-staff] subagent ${subagentId} chief_mismatch (got ${staffRow.chief_agent_id}, expected ${resolvedAgentId}) — ignoring persona`,
443
+ );
444
+ staffRow = null;
445
+ } else {
446
+ api.logger?.warn?.(`[ofiere-staff] subagent ${subagentId} not found — ignoring persona`);
447
+ }
448
+ }
449
+
450
+ // wrap any return-block with the staff prefix (or pass through unchanged
451
+ // when no staff dispatch).
452
+ const wrap = (block: string | undefined | null): { appendSystemContext: string } | undefined => {
453
+ const finalBlock = staffPrefix + (block || "");
454
+ return finalBlock ? { appendSystemContext: finalBlock } : undefined;
455
+ };
456
+
405
457
  // Dispatch-params path: workflow executor + task-dispatcher edge function
406
458
  // can stash explicit `attached_sop_ids` / `attached_framework_ids` on the
407
459
  // chat.send frame's metadata. When present, prefer them over the
@@ -416,7 +468,16 @@ export function registerAttachmentContextHook(args: {
416
468
  sopIds: dispatchIds.sopIds,
417
469
  fwIds: dispatchIds.fwIds,
418
470
  });
419
- return block ? { appendSystemContext: block } : undefined;
471
+ return wrap(block);
472
+ }
473
+
474
+ // Staff-mode runs bypass the chief-level attachment cache so a chief
475
+ // and its staff never share a cached attachment block.
476
+ if (subagentId) {
477
+ const block = await buildAttachmentBlock({
478
+ supabase, userId, agentId: resolvedAgentId, subagentId,
479
+ });
480
+ return wrap(block);
420
481
  }
421
482
 
422
483
  // Multi-tenant: a single plugin process can serve multiple users — key
@@ -426,7 +487,7 @@ export function registerAttachmentContextHook(args: {
426
487
  if (cached) {
427
488
  const age = Date.now() - cached.at;
428
489
  if (age < ATTACH_CACHE_TTL_MS) {
429
- return cached.text ? { appendSystemContext: cached.text } : undefined;
490
+ return wrap(cached.text);
430
491
  }
431
492
  if (age < ATTACH_CACHE_STALE_MS) {
432
493
  // Refresh in background
@@ -436,13 +497,13 @@ export function registerAttachmentContextHook(args: {
436
497
  attachmentCache.set(cacheKey, { text: fresh, at: Date.now() });
437
498
  } catch { /* ignore */ }
438
499
  })();
439
- return cached.text ? { appendSystemContext: cached.text } : undefined;
500
+ return wrap(cached.text);
440
501
  }
441
502
  }
442
503
 
443
504
  const block = await buildAttachmentBlock({ supabase, userId, agentId: resolvedAgentId });
444
505
  attachmentCache.set(cacheKey, { text: block, at: Date.now() });
445
- return block ? { appendSystemContext: block } : undefined;
506
+ return wrap(block);
446
507
  } catch (e) {
447
508
  api.logger?.debug?.(`[ofiere-attach] before_prompt_build error: ${e instanceof Error ? e.message : e}`);
448
509
  }
@@ -0,0 +1,82 @@
1
+ import type { SupabaseClient } from "@supabase/supabase-js";
2
+
3
+ export interface SubagentRow {
4
+ id: string;
5
+ chief_agent_id: string;
6
+ name: string;
7
+ role: string | null;
8
+ codename: string | null;
9
+ system_prompt: string | null;
10
+ mission: string | null;
11
+ responsibilities: string | null;
12
+ instructions: string | null;
13
+ primary_model: string | null;
14
+ coding_model: string | null;
15
+ tool_call_model: string | null;
16
+ function_call_model: string | null;
17
+ vision_model: string | null;
18
+ mcp_server_ids: string[] | null;
19
+ }
20
+
21
+ export async function loadSubagentRow(
22
+ supabase: SupabaseClient,
23
+ userId: string,
24
+ subagentId: string,
25
+ ): Promise<SubagentRow | null> {
26
+ const { data, error } = await supabase
27
+ .from("agent_subagents")
28
+ .select(
29
+ "id, chief_agent_id, name, role, codename, system_prompt, mission, responsibilities, instructions, primary_model, coding_model, tool_call_model, function_call_model, vision_model, mcp_server_ids",
30
+ )
31
+ .eq("user_id", userId)
32
+ .eq("id", subagentId)
33
+ .maybeSingle();
34
+ if (error || !data) return null;
35
+ return data as SubagentRow;
36
+ }
37
+
38
+ // Cycle 7b — pluck subagent_id from dispatch metadata only. Plain user chats
39
+ // must NOT trigger the staff persona swap or report emission, so we deliberately
40
+ // do not consult ctx.subagentId / ctx.subagent_id at the top level.
41
+ export function readDispatchSubagentId(ctx: any, event?: any): string | null {
42
+ const candidates: Array<unknown> = [
43
+ ctx?.metadata?.subagent_id,
44
+ ctx?.metadata?.dispatch?.subagent_id,
45
+ ctx?.params?.metadata?.subagent_id,
46
+ ctx?.payload?.metadata?.subagent_id,
47
+ ctx?.request?.metadata?.subagent_id,
48
+ ctx?.options?.metadata?.subagent_id,
49
+ ctx?.attachments?.subagent_id,
50
+ ctx?.dispatch?.subagent_id,
51
+ event?.metadata?.subagent_id,
52
+ event?.context?.metadata?.subagent_id,
53
+ ];
54
+ for (const c of candidates) {
55
+ if (typeof c === "string" && c.length) return c;
56
+ }
57
+ return null;
58
+ }
59
+
60
+ export function buildStaffPersonaBlock(staff: SubagentRow): string {
61
+ const lines: string[] = [];
62
+ lines.push(`# STAFF IDENTITY`);
63
+ lines.push(
64
+ `You are **${staff.name}**${staff.role ? ` (${staff.role})` : ""}, a staff member reporting to chief \`${staff.chief_agent_id}\`.`,
65
+ );
66
+ lines.push(
67
+ `You are NOT the chief. Execute the assigned task, then return a structured report. Do not adopt the chief's persona.`,
68
+ );
69
+ if (staff.system_prompt?.trim()) {
70
+ lines.push(``, `## Persona`, staff.system_prompt.trim());
71
+ }
72
+ if (staff.mission?.trim()) {
73
+ lines.push(``, `## Mission`, staff.mission.trim());
74
+ }
75
+ if (staff.responsibilities?.trim()) {
76
+ lines.push(``, `## Responsibilities`, staff.responsibilities.trim());
77
+ }
78
+ if (staff.instructions?.trim()) {
79
+ lines.push(``, `## Operating Instructions`, staff.instructions.trim());
80
+ }
81
+ return lines.join("\n");
82
+ }
package/src/tools.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  registerAttachmentContextHook,
20
20
  } from "./attachments.js";
21
21
  import { invalidateAgentTier } from "./agent-tier.js";
22
+ import { loadSubagentRow, readDispatchSubagentId } from "./staffPersona.js";
22
23
 
23
24
  // ─── Tool result shape (matches OpenClaw SDK) ────────────────────────────────
24
25
 
@@ -38,6 +39,42 @@ function err(message: string): ToolResult {
38
39
  };
39
40
  }
40
41
 
42
+ // ─── Subagent ↔ chief invariant ──────────────────────────────────────────────
43
+ // Mirrors dashboard/lib/subagentValidation.ts. Used by TASK_OPS + SCHEDULE_OPS
44
+ // when a chief delegates work to one of their staff via tool call.
45
+ //
46
+ // Returns:
47
+ // { ok: true, subagentId: <id> | null } — passed (or no subagent provided)
48
+ // { ok: false, reason: string } — invariant violated
49
+ async function validateSubagentForChief(
50
+ supabase: SupabaseClient,
51
+ userId: string,
52
+ subagentIdInput: unknown,
53
+ chiefAgentId: string | null | undefined,
54
+ ): Promise<{ ok: true; subagentId: string | null } | { ok: false; reason: string }> {
55
+ const subagentId =
56
+ typeof subagentIdInput === "string" && subagentIdInput.trim()
57
+ ? subagentIdInput.trim()
58
+ : null;
59
+ if (!subagentId) return { ok: true, subagentId: null };
60
+
61
+ const { data: sub } = await supabase
62
+ .from("agent_subagents")
63
+ .select("id, chief_agent_id")
64
+ .eq("user_id", userId)
65
+ .eq("id", subagentId)
66
+ .maybeSingle();
67
+
68
+ if (!sub) {
69
+ return { ok: false, reason: "subagent_not_found_or_not_yours" };
70
+ }
71
+ const chief = (chiefAgentId || "").trim();
72
+ if (!chief || sub.chief_agent_id !== chief) {
73
+ return { ok: false, reason: "subagent_chief_mismatch" };
74
+ }
75
+ return { ok: true, subagentId };
76
+ }
77
+
41
78
  // ─── Helper: extract calling agent's accountId from OpenClaw context ─────────
42
79
 
43
80
  // Module-level: set once at registration time from index.ts
@@ -205,8 +242,8 @@ function registerTaskOps(
205
242
  `Actions:\n` +
206
243
  `- "list": List/filter tasks. Optional: status, agent_id, space_id, folder_id, task_id, limit\n` +
207
244
  `- "get": Get a single task by ID. Required: task_id\n` +
208
- `- "create": Create a task. Required: title. Optional: agent_id, description, status, priority, space_id, folder_id, start_date, due_date, tags, instructions, execution_plan, goals, constraints, system_prompt, recurrence_type, recurrence_interval, scheduled_time. ⚠️ PLANNING GATE: If the task has 3+ execution steps, goals, or constraints, FIRST ask the user "Plan first or create directly?" If they choose to plan, use OFIERE_PLAN_OPS instead.\n` +
209
- `- "update": Update a task. Required: task_id. Optional: all create fields + progress\n` +
245
+ `- "create": Create a task. Required: title. Optional: agent_id, subagent_id, description, status, priority, space_id, folder_id, start_date, due_date, tags, instructions, execution_plan, goals, constraints, system_prompt, recurrence_type, recurrence_interval, scheduled_time. ⚠️ PLANNING GATE: If the task has 3+ execution steps, goals, or constraints, FIRST ask the user "Plan first or create directly?" If they choose to plan, use OFIERE_PLAN_OPS instead.\n` +
246
+ `- "update": Update a task. Required: task_id. Optional: all create fields + progress + subagent_id\n` +
210
247
  `- "delete": Delete task + subtasks. Required: task_id\n` +
211
248
  `- "add_approval": Request approval on a task. Required: task_id, approver_name. Optional: approver_type (human|agent, auto-detected), due_date, comment\n` +
212
249
  `- "list_approvals": List approvals. Optional: task_id, approval_status filter (pending|approved|rejected)\n` +
@@ -214,6 +251,7 @@ function registerTaskOps(
214
251
  `For complex tasks, fill in execution_plan (step-by-step plan), goals, constraints, and system_prompt to help the executing agent.\n` +
215
252
  `For simple tasks, just provide title and optionally description.\n` +
216
253
  `agent_id: Pass your name to self-assign, another agent's name, or 'none'.\n` +
254
+ `subagent_id: When delegating to one of your own staff, pass the staff UUID here AND set agent_id to yourself (or the chief). The staff's chief_agent_id MUST match agent_id or the call rejects with subagent_chief_mismatch. Use OFIERE_AGENT_OPS list_subagents to discover available staff under a chief.\n` +
217
255
  `For recurring tasks: set start_date + recurrence_type + recurrence_interval. Example: every 2 minutes = recurrence_type: "minutely", recurrence_interval: 2.\n` +
218
256
  `Approvals: Use add_approval to request sign-off from humans or agents. Approvals are separate from workflow gate nodes.\n` +
219
257
  `Status: PENDING, IN_PROGRESS, DONE, FAILED | Priority: 0=LOW, 1=MEDIUM, 2=HIGH, 3=CRITICAL`,
@@ -237,6 +275,10 @@ function registerTaskOps(
237
275
  type: "string",
238
276
  description: "Agent name or ID. Your name to self-assign, 'none' for unassigned.",
239
277
  },
278
+ subagent_id: {
279
+ type: "string",
280
+ description: "Staff subagent UUID for delegation. Must belong to the chief in agent_id (verified server-side). Discover available staff via OFIERE_AGENT_OPS list_subagents.",
281
+ },
240
282
  status: {
241
283
  type: "string",
242
284
  description: "Task status",
@@ -437,6 +479,13 @@ async function handleCreateTask(
437
479
 
438
480
  const assignee = isUnassigned ? null : await resolveAgent(rawAgentId);
439
481
 
482
+ // ── Subagent delegation: validate staff↔chief invariant ──────────────
483
+ const subCheck = await validateSubagentForChief(supabase, userId, params.subagent_id, assignee);
484
+ if (!subCheck.ok) {
485
+ return err(subCheck.reason);
486
+ }
487
+ const subagentId = subCheck.subagentId;
488
+
440
489
  // Build custom_fields from task-ops extended fields
441
490
  const cf: Record<string, unknown> = {};
442
491
 
@@ -533,6 +582,7 @@ async function handleCreateTask(
533
582
  title: params.title,
534
583
  description: (params.description as string) || (params.instructions as string) || null,
535
584
  agent_id: assignee,
585
+ subagent_id: subagentId,
536
586
  assignee_type: "agent",
537
587
  status: (params.status as string) || "PENDING",
538
588
  priority: params.priority !== undefined ? params.priority : 1,
@@ -666,6 +716,7 @@ async function handleCreateTask(
666
716
  user_id: userId,
667
717
  task_id: id,
668
718
  agent_id: effectiveAgentId,
719
+ subagent_id: subagentId,
669
720
  title: params.title,
670
721
  description: (params.description as string) || (params.instructions as string) || null,
671
722
  scheduled_date: scheduledDateFinal,
@@ -743,6 +794,37 @@ async function handleUpdateTask(
743
794
  }
744
795
  if (params.status === "DONE") updates.completed_at = new Date().toISOString();
745
796
 
797
+ // ── Subagent + chief invariant ───────────────────────────────────────
798
+ // Mirrors dashboard PATCH: chief change without explicit subagent_id
799
+ // clears any existing subagent assignment; subagent_id alone validates
800
+ // against the row's current agent_id.
801
+ if (params.agent_id !== undefined || params.subagent_id !== undefined) {
802
+ if (params.agent_id !== undefined && params.subagent_id === undefined) {
803
+ // Chief switched, no explicit subagent — clear subagent_id.
804
+ updates.subagent_id = null;
805
+ } else {
806
+ let effectiveAgentId: string | null | undefined =
807
+ params.agent_id !== undefined ? (params.agent_id as string) : undefined;
808
+ if (effectiveAgentId === undefined) {
809
+ const { data: row } = await supabase
810
+ .from("tasks")
811
+ .select("agent_id")
812
+ .eq("id", params.task_id as string)
813
+ .eq("user_id", userId)
814
+ .maybeSingle();
815
+ effectiveAgentId = (row?.agent_id as string) ?? null;
816
+ }
817
+ const subCheck = await validateSubagentForChief(
818
+ supabase,
819
+ userId,
820
+ params.subagent_id,
821
+ effectiveAgentId,
822
+ );
823
+ if (!subCheck.ok) return err(subCheck.reason);
824
+ updates.subagent_id = subCheck.subagentId;
825
+ }
826
+ }
827
+
746
828
  // If task is being marked DONE or FAILED, auto-complete any linked scheduler events
747
829
  if (params.status === "DONE" || params.status === "FAILED") {
748
830
  try {
@@ -1093,7 +1175,9 @@ function registerAgentOps(
1093
1175
  `Actions:\n` +
1094
1176
  `- "list": List all top-level agents (chiefs / native + OpenClaw) with their IDs, names, roles, and status. Use this to find the correct agent_id for task assignment.\n` +
1095
1177
  `- "list_subagents": List staff subagents under a chief. Required: chief_agent_id.\n` +
1178
+ `- "get_subagent": Fetch a single subagent's full row (including persona + model overrides + mcp_server_ids). Required: subagent_id.\n` +
1096
1179
  `- "create_subagent": Create a staff subagent under a chief (max 5 per chief). Required: chief_agent_id, name. Optional: role (default "Staff"), codename, color_hex (default "#64748b").\n` +
1180
+ `- "update_subagent": Update a staff subagent's persona, model overrides, MCP allowlist, or identity fields. Required: subagent_id. Any of name, role, codename, color_hex, system_prompt, mission, responsibilities, instructions, primary_model, coding_model, tool_call_model, function_call_model, vision_model, mcp_server_ids may be set.\n` +
1097
1181
  `- "delete_subagent": Remove a staff subagent. Required: subagent_id.\n` +
1098
1182
  `- "invalidate_tier_cache": Flush the plugin's in-process tier resolver cache (5 min TTL). Optional: agent_id to flush a single entry; omit to flush all entries for the calling user. Use after any direct-DB mutation of agent_tier_overrides.`,
1099
1183
  parameters: {
@@ -1103,15 +1187,29 @@ function registerAgentOps(
1103
1187
  action: {
1104
1188
  type: "string",
1105
1189
  description: "The operation to perform",
1106
- enum: ["list", "list_subagents", "create_subagent", "delete_subagent", "invalidate_tier_cache"],
1190
+ enum: ["list", "list_subagents", "get_subagent", "create_subagent", "update_subagent", "delete_subagent", "invalidate_tier_cache"],
1107
1191
  },
1108
1192
  chief_agent_id: { type: "string", description: "Chief agent ID (required for list_subagents, create_subagent)" },
1109
- subagent_id: { type: "string", description: "Subagent ID (required for delete_subagent)" },
1193
+ subagent_id: { type: "string", description: "Subagent ID (required for get/update/delete_subagent)" },
1110
1194
  agent_id: { type: "string", description: "Agent ID (optional for invalidate_tier_cache — omit to flush all entries for the user)" },
1111
1195
  name: { type: "string", description: "Subagent display name (required for create_subagent)" },
1112
1196
  role: { type: "string", description: "Subagent role label, e.g. 'Staff', 'Analyst'. Defaults to 'Staff'." },
1113
1197
  codename: { type: "string", description: "Optional subagent codename" },
1114
1198
  color_hex: { type: "string", description: "Optional subagent UI color, default '#64748b'" },
1199
+ system_prompt: { type: "string", description: "Staff persona / identity (Markdown). Optional." },
1200
+ mission: { type: "string", description: "Short-form mission statement. Optional." },
1201
+ responsibilities: { type: "string", description: "Bulleted Markdown list of duties. Optional." },
1202
+ instructions: { type: "string", description: "Detailed how-they-work instructions (Markdown). Optional." },
1203
+ primary_model: { type: "string", description: "Override primary slot. NULL/omit = inherit chief." },
1204
+ coding_model: { type: "string", description: "Override coding slot." },
1205
+ tool_call_model: { type: "string", description: "Override tool-call slot." },
1206
+ function_call_model: { type: "string", description: "Override function-call slot." },
1207
+ vision_model: { type: "string", description: "Override vision slot." },
1208
+ mcp_server_ids: {
1209
+ type: "array",
1210
+ items: { type: "string" },
1211
+ description: "Allowlist of MCP server UUIDs. Reserved for cycle 8 enforcement.",
1212
+ },
1115
1213
  },
1116
1214
  },
1117
1215
  async execute(_id: string, params: Record<string, unknown>) {
@@ -1122,14 +1220,18 @@ function registerAgentOps(
1122
1220
  return handleListAgents(api, supabase, userId, fallbackAgentId);
1123
1221
  case "list_subagents":
1124
1222
  return handleListSubagents(supabase, userId, params);
1223
+ case "get_subagent":
1224
+ return handleGetSubagent(supabase, userId, params);
1125
1225
  case "create_subagent":
1126
1226
  return handleCreateSubagent(supabase, userId, params);
1227
+ case "update_subagent":
1228
+ return handleUpdateSubagent(supabase, userId, params);
1127
1229
  case "delete_subagent":
1128
1230
  return handleDeleteSubagent(supabase, userId, params);
1129
1231
  case "invalidate_tier_cache":
1130
1232
  return handleInvalidateTierCache(userId, params);
1131
1233
  default:
1132
- return err(`Unknown action "${action}". Valid actions: list, list_subagents, create_subagent, delete_subagent, invalidate_tier_cache`);
1234
+ return err(`Unknown action "${action}". Valid actions: list, list_subagents, get_subagent, create_subagent, update_subagent, delete_subagent, invalidate_tier_cache`);
1133
1235
  }
1134
1236
  },
1135
1237
  });
@@ -1395,8 +1497,8 @@ function registerScheduleOps(
1395
1497
  `Manage calendar events and schedule tasks on the timeline.\n\n` +
1396
1498
  `Actions:\n` +
1397
1499
  `- "list": List events. Optional: start_date, end_date, agent_id\n` +
1398
- `- "create": Schedule an event. Required: title, scheduled_date. Optional: task_id, agent_id, scheduled_time, duration_minutes, recurrence_type, recurrence_interval, color, priority\n` +
1399
- `- "update": Update event. Required: id. Optional: title, scheduled_date, scheduled_time, duration_minutes, status, recurrence_type\n` +
1500
+ `- "create": Schedule an event. Required: title, scheduled_date. Optional: task_id, agent_id, subagent_id, scheduled_time, duration_minutes, recurrence_type, recurrence_interval, color, priority\n` +
1501
+ `- "update": Update event. Required: id. Optional: title, scheduled_date, scheduled_time, duration_minutes, status, recurrence_type, agent_id, subagent_id\n` +
1400
1502
  `- "delete": Remove event. Required: id\n` +
1401
1503
  `recurrence_type: none, hourly, daily, weekly, monthly\n` +
1402
1504
  `priority: 0=low, 1=medium, 2=high, 3=critical`,
@@ -1410,6 +1512,7 @@ function registerScheduleOps(
1410
1512
  description: { type: "string" },
1411
1513
  task_id: { type: "string", description: "Link to a task" },
1412
1514
  agent_id: { type: "string", description: "Assigned agent" },
1515
+ subagent_id: { type: "string", description: "Staff subagent UUID for delegation. Must belong to the chief in agent_id (verified server-side)." },
1413
1516
  scheduled_date: { type: "string", description: "Date (YYYY-MM-DD)" },
1414
1517
  scheduled_time: { type: "string", description: "Time (HH:MM)" },
1415
1518
  start_date: { type: "string", description: "List filter: start (YYYY-MM-DD)" },
@@ -1442,6 +1545,15 @@ function registerScheduleOps(
1442
1545
  const pVal = typeof params.priority === "number" ? params.priority
1443
1546
  : priorityMap[String(params.priority || "").toLowerCase()] ?? 0;
1444
1547
 
1548
+ // Validate subagent ↔ chief invariant before insert
1549
+ const evtSubCheck = await validateSubagentForChief(
1550
+ supabase,
1551
+ userId,
1552
+ params.subagent_id,
1553
+ (params.agent_id as string) || null,
1554
+ );
1555
+ if (!evtSubCheck.ok) return err(evtSubCheck.reason);
1556
+
1445
1557
  // Compute next_run_at from scheduled_date + scheduled_time
1446
1558
  let nextRunAt: number | null = null;
1447
1559
  try {
@@ -1461,6 +1573,7 @@ function registerScheduleOps(
1461
1573
  user_id: userId,
1462
1574
  task_id: (params.task_id as string) || null,
1463
1575
  agent_id: (params.agent_id as string) || null,
1576
+ subagent_id: evtSubCheck.subagentId,
1464
1577
  title: params.title,
1465
1578
  description: (params.description as string) || null,
1466
1579
  scheduled_date: params.scheduled_date,
@@ -1498,6 +1611,33 @@ function registerScheduleOps(
1498
1611
  "recurrence_type", "recurrence_interval", "status", "color", "priority", "agent_id"]) {
1499
1612
  if ((params as any)[f] !== undefined) upd[f] = (params as any)[f];
1500
1613
  }
1614
+
1615
+ // Subagent + chief invariant. Same rule as TASK_OPS update.
1616
+ if (params.agent_id !== undefined || params.subagent_id !== undefined) {
1617
+ if (params.agent_id !== undefined && params.subagent_id === undefined) {
1618
+ upd.subagent_id = null;
1619
+ } else {
1620
+ let effectiveAgent: string | null | undefined =
1621
+ params.agent_id !== undefined ? (params.agent_id as string) : undefined;
1622
+ if (effectiveAgent === undefined) {
1623
+ const { data: row } = await supabase
1624
+ .from("scheduler_events")
1625
+ .select("agent_id")
1626
+ .eq("id", params.id as string)
1627
+ .eq("user_id", userId)
1628
+ .maybeSingle();
1629
+ effectiveAgent = (row?.agent_id as string) ?? null;
1630
+ }
1631
+ const evtUpdSub = await validateSubagentForChief(
1632
+ supabase,
1633
+ userId,
1634
+ params.subagent_id,
1635
+ effectiveAgent,
1636
+ );
1637
+ if (!evtUpdSub.ok) return err(evtUpdSub.reason);
1638
+ upd.subagent_id = evtUpdSub.subagentId;
1639
+ }
1640
+ }
1501
1641
  // Map string priority to number if provided
1502
1642
  if (upd.priority !== undefined && typeof upd.priority === "string") {
1503
1643
  const pMap: Record<string, number> = { low: 0, medium: 1, high: 2, critical: 3 };
@@ -5279,6 +5419,66 @@ async function handleDeleteSubagent(supabase: SupabaseClient, userId: string, pa
5279
5419
  } catch (e) { return err(e instanceof Error ? e.message : String(e)); }
5280
5420
  }
5281
5421
 
5422
+ async function handleGetSubagent(
5423
+ supabase: SupabaseClient,
5424
+ userId: string,
5425
+ params: Record<string, unknown>,
5426
+ ): Promise<ToolResult> {
5427
+ try {
5428
+ const subagentId = params.subagent_id as string | undefined;
5429
+ if (!subagentId) return err("Missing required field: subagent_id");
5430
+ const { data, error } = await supabase
5431
+ .from("agent_subagents")
5432
+ .select("*")
5433
+ .eq("user_id", userId)
5434
+ .eq("id", subagentId)
5435
+ .maybeSingle();
5436
+ if (error) return err(error.message);
5437
+ if (!data) return err("subagent_not_found_or_not_yours");
5438
+ return ok({ subagent: data });
5439
+ } catch (e) { return err(e instanceof Error ? e.message : String(e)); }
5440
+ }
5441
+
5442
+ async function handleUpdateSubagent(
5443
+ supabase: SupabaseClient,
5444
+ userId: string,
5445
+ params: Record<string, unknown>,
5446
+ ): Promise<ToolResult> {
5447
+ try {
5448
+ const subagentId = params.subagent_id as string | undefined;
5449
+ if (!subagentId) return err("Missing required field: subagent_id");
5450
+
5451
+ const updates: Record<string, unknown> = {};
5452
+ const stringFields = [
5453
+ "name", "role", "codename", "color_hex",
5454
+ "system_prompt", "mission", "responsibilities", "instructions",
5455
+ "primary_model", "coding_model", "tool_call_model", "function_call_model", "vision_model",
5456
+ ] as const;
5457
+ for (const key of stringFields) {
5458
+ if (key in params && params[key] !== undefined) updates[key] = params[key];
5459
+ }
5460
+ if ("mcp_server_ids" in params && Array.isArray(params.mcp_server_ids)) {
5461
+ updates.mcp_server_ids = (params.mcp_server_ids as unknown[]).filter(
5462
+ (x): x is string => typeof x === "string",
5463
+ );
5464
+ }
5465
+ if (Object.keys(updates).length === 0) {
5466
+ return err("no updatable fields provided");
5467
+ }
5468
+
5469
+ const { data, error } = await supabase
5470
+ .from("agent_subagents")
5471
+ .update(updates)
5472
+ .eq("id", subagentId)
5473
+ .eq("user_id", userId)
5474
+ .select()
5475
+ .maybeSingle();
5476
+ if (error) return err(error.message);
5477
+ if (!data) return err("subagent_not_found_or_not_yours");
5478
+ return ok({ subagent: data, updated_fields: Object.keys(updates) });
5479
+ } catch (e) { return err(e instanceof Error ? e.message : String(e)); }
5480
+ }
5481
+
5282
5482
  async function handleSOPApplyTemplate(
5283
5483
  supabase: SupabaseClient, userId: string,
5284
5484
  resolveAgent: (id?: string) => Promise<string | null>,
@@ -6331,6 +6531,77 @@ function registerBrainExtractionHook(
6331
6531
 
6332
6532
  api.logger.debug?.(`[ofiere-brain] agent_end identity: ctx.agentId=${ctx?.agentId || "(none)"} event.agentId=${event?.agentId || "(none)"} resolved=${resolvedAgentId}`);
6333
6533
 
6534
+ // ── Cycle 7b — Staff report emit ──
6535
+ // If this agent_end was triggered by a staff dispatch, write a chief-
6536
+ // scoped memory note ("Staff X reported on task Y: ...") and POST a
6537
+ // webhook so the dashboard can surface the report. The staff itself
6538
+ // gets no memory entry (req 3 — staff has no memory of its own).
6539
+ const staffDispatchSubagentId = readDispatchSubagentId(ctx, event);
6540
+ if (staffDispatchSubagentId) {
6541
+ (async () => {
6542
+ try {
6543
+ const staff = await loadSubagentRow(supabase, userId, staffDispatchSubagentId);
6544
+ if (!staff) {
6545
+ api.logger.warn?.(`[ofiere-staff-report] subagent ${staffDispatchSubagentId} not found — skipping report`);
6546
+ return;
6547
+ }
6548
+ if (staff.chief_agent_id !== resolvedAgentId) {
6549
+ api.logger.warn?.(`[ofiere-staff-report] chief_mismatch (subagent ${staffDispatchSubagentId} reports to ${staff.chief_agent_id}, run was on ${resolvedAgentId}) — skipping report`);
6550
+ return;
6551
+ }
6552
+ const taskId =
6553
+ ctx?.metadata?.task_id || ctx?.metadata?.dispatch?.task_id ||
6554
+ event?.metadata?.task_id || event?.context?.metadata?.task_id || null;
6555
+ const excerpt = lastAssistant.slice(0, 1500);
6556
+ const reportBody = taskId
6557
+ ? `Staff ${staff.name}${staff.role ? ` (${staff.role})` : ""} reported on task ${taskId}:\n\n${excerpt}`
6558
+ : `Staff ${staff.name}${staff.role ? ` (${staff.role})` : ""} reported:\n\n${excerpt}`;
6559
+ await supabase.from("agent_memories").insert({
6560
+ user_id: userId,
6561
+ agent_id: staff.chief_agent_id,
6562
+ tier: "L4_episodic",
6563
+ content: reportBody,
6564
+ source: "staff_report",
6565
+ importance: 5,
6566
+ context_key: taskId ? `staff_report:${staffDispatchSubagentId}:${taskId}` : `staff_report:${staffDispatchSubagentId}`,
6567
+ });
6568
+ api.logger.info?.(`[ofiere-staff-report] memory written for chief ${staff.chief_agent_id} from staff ${staff.name}`);
6569
+
6570
+ // Best-effort webhook to dashboard (existing OPENCLAW_WEBHOOK_SECRET).
6571
+ const webhookUrl = process.env.OFIERE_DASHBOARD_WEBHOOK_URL || "";
6572
+ const webhookSecret = process.env.OPENCLAW_WEBHOOK_SECRET || "";
6573
+ if (webhookUrl && webhookSecret) {
6574
+ try {
6575
+ await fetch(webhookUrl, {
6576
+ method: "POST",
6577
+ headers: {
6578
+ "content-type": "application/json",
6579
+ authorization: `Bearer ${webhookSecret}`,
6580
+ },
6581
+ body: JSON.stringify({
6582
+ type: "staff_report",
6583
+ payload: {
6584
+ user_id: userId,
6585
+ chief_agent_id: staff.chief_agent_id,
6586
+ subagent_id: staffDispatchSubagentId,
6587
+ task_id: taskId,
6588
+ response: excerpt,
6589
+ },
6590
+ }),
6591
+ });
6592
+ } catch (wErr) {
6593
+ api.logger.debug?.(`[ofiere-staff-report] webhook post failed: ${wErr instanceof Error ? wErr.message : String(wErr)}`);
6594
+ }
6595
+ }
6596
+ } catch (sErr) {
6597
+ api.logger.warn?.(`[ofiere-staff-report] failed: ${sErr instanceof Error ? sErr.message : String(sErr)}`);
6598
+ }
6599
+ })();
6600
+ // Skip the standard chief memory writes — this run was the staff's,
6601
+ // not the chief's. The chief gets a single L4 report entry above.
6602
+ return;
6603
+ }
6604
+
6334
6605
  // ── Fast Stream: Write L1_focus + L2_episode in parallel ──
6335
6606
  const rawContent = `User: ${lastUser}\nAssistant: ${lastAssistant}`;
6336
6607
  const immediateWrites: PromiseLike<any>[] = [];