ofiere-openclaw-plugin 4.46.0 → 4.48.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.46.0",
3
+ "version": "4.48.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,7 +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, loadChiefStaffDefaults, loadDispatchContextBySession, resolveStaffModels, type SubagentRow, type DispatchContextRow } from "./staffPersona.js";
18
+ import { loadSubagentRow, buildStaffPersonaBlock, readDispatchSubagentId, loadChiefStaffDefaults, loadChiefAgentConfig, loadDispatchContextBySession, resolveStaffModels, type SubagentRow, type DispatchContextRow } from "./staffPersona.js";
19
19
 
20
20
  interface ToolResult {
21
21
  content: Array<{ type: "text"; text: string }>;
@@ -453,8 +453,11 @@ export function registerAttachmentContextHook(args: {
453
453
  if (subagentId) {
454
454
  staffRow = await loadSubagentRow(supabase, userId, subagentId);
455
455
  if (staffRow && staffRow.chief_agent_id === resolvedAgentId) {
456
- const chiefDefaults = await loadChiefStaffDefaults(supabase, userId, staffRow.chief_agent_id).catch(() => null);
457
- const resolved = resolveStaffModels(staffRow, chiefDefaults);
456
+ const [chiefDefaults, chiefConfig] = await Promise.all([
457
+ loadChiefStaffDefaults(supabase, userId, staffRow.chief_agent_id).catch(() => null),
458
+ loadChiefAgentConfig(supabase, userId, staffRow.chief_agent_id).catch(() => null),
459
+ ]);
460
+ const resolved = resolveStaffModels(staffRow, chiefDefaults, chiefConfig);
458
461
  staffPrefix = buildStaffPersonaBlock(staffRow, resolved) + "\n\n---\n\n";
459
462
  api.logger?.debug?.(
460
463
  `[ofiere-staff] subagent ${subagentId} (${staffRow.name}) reporting to ${resolvedAgentId} — persona injected; resolved models: ${JSON.stringify(resolved)}`,
@@ -74,6 +74,7 @@ export interface DispatchContextRow {
74
74
  attached_sop_ids: string[];
75
75
  attached_framework_ids: string[];
76
76
  task_id: string | null;
77
+ conversation_id: string | null;
77
78
  }
78
79
 
79
80
  export async function loadDispatchContextBySession(
@@ -84,7 +85,7 @@ export async function loadDispatchContextBySession(
84
85
  if (!sessionKey) return null;
85
86
  const { data, error } = await supabase
86
87
  .from("dispatch_context")
87
- .select("subagent_id, attached_sop_ids, attached_framework_ids, task_id")
88
+ .select("subagent_id, attached_sop_ids, attached_framework_ids, task_id, conversation_id")
88
89
  .eq("user_id", userId)
89
90
  .eq("session_key", sessionKey)
90
91
  .order("created_at", { ascending: false })
@@ -100,6 +101,7 @@ export async function loadDispatchContextBySession(
100
101
  ? (data.attached_framework_ids as unknown[]).filter((x): x is string => typeof x === "string")
101
102
  : [],
102
103
  task_id: (data.task_id as string | null) ?? null,
104
+ conversation_id: (data.conversation_id as string | null) ?? null,
103
105
  };
104
106
  }
105
107
 
@@ -125,7 +127,7 @@ export interface ResolvedStaffModels {
125
127
  tool_call_model: string | null;
126
128
  function_call_model: string | null;
127
129
  vision_model: string | null;
128
- source: Record<StaffModelSlot, "staff" | "chief_default" | "chief_fallback" | null>;
130
+ source: Record<StaffModelSlot, "staff" | "chief_default" | "chief_primary" | "chief_fallback" | null>;
129
131
  }
130
132
 
131
133
  const SLOTS: StaffModelSlot[] = [
@@ -136,6 +138,17 @@ const SLOTS: StaffModelSlot[] = [
136
138
  "vision_model",
137
139
  ];
138
140
 
141
+ // Cycle 7b BUGSHOOT-3 — chief's own ofie_agent_config row, used as the third
142
+ // fallback tier when neither the staff slot nor the chief STAFF MODEL slot is
143
+ // set. Mirrors the dispatcher's loadStaffPrimaryModel chain.
144
+ export interface ChiefAgentConfigRow {
145
+ agent_id: string;
146
+ primary_model: string | null;
147
+ coding_model: string | null;
148
+ tool_call_model: string | null;
149
+ vision_model: string | null;
150
+ }
151
+
139
152
  export async function loadChiefStaffDefaults(
140
153
  supabase: SupabaseClient,
141
154
  userId: string,
@@ -153,19 +166,42 @@ export async function loadChiefStaffDefaults(
153
166
  return data as ChiefStaffDefaultsRow;
154
167
  }
155
168
 
169
+ export async function loadChiefAgentConfig(
170
+ supabase: SupabaseClient,
171
+ userId: string,
172
+ chiefAgentId: string,
173
+ ): Promise<ChiefAgentConfigRow | null> {
174
+ const { data, error } = await supabase
175
+ .from("ofie_agent_config")
176
+ .select("agent_id, primary_model, coding_model, tool_call_model, vision_model")
177
+ .eq("user_id", userId)
178
+ .eq("agent_id", chiefAgentId)
179
+ .maybeSingle();
180
+ if (error || !data) return null;
181
+ return data as ChiefAgentConfigRow;
182
+ }
183
+
156
184
  /**
157
- * Resolution chain at delegate_to_staff time:
158
- * staff.<slot> (per-staff override on agent_subagents)
159
- * ?? chief_staff_defaults.<slot> (chief-level "STAFF MODEL")
160
- * ?? null (caller falls back to the chief's own model — that lookup
161
- * happens in the OpenClaw runtime, not here).
185
+ * Resolution chain at delegate_to_staff time (BUGSHOOT-3):
186
+ * 1. staff.<slot> (per-staff override)
187
+ * 2. chief_staff_defaults.<slot> (chief-level STAFF MODEL)
188
+ * 3. ofie_agent_config.<slot> (chief's OWN slot, NEW)
189
+ * 4. ofie_agent_config.primary_model (chief's primary, last-resort sub)
190
+ * 5. null (gateway picks gateway default)
191
+ *
192
+ * Tier 3 was added in BUGSHOOT-3 because the prior chain stopped at tier 2 and
193
+ * relied on an implicit "gateway default" — which is opaque and led to BUG 10
194
+ * when the staff override held a model id the gateway rejected. With tier 3,
195
+ * an empty staff slot deterministically inherits the chief's actual model.
162
196
  *
163
- * The chief's own model is intentionally not touched. Empty staff slot +
164
- * empty chief STAFF MODEL = inherit chief's primary_model (gateway default).
197
+ * Tier 4 (slot primary_model fallback) handles the common case where chief
198
+ * has only a primary_model set and no per-slot routing — a staff with
199
+ * coding_model blank still inherits chief's primary_model rather than null.
165
200
  */
166
201
  export function resolveStaffModels(
167
202
  staff: SubagentRow,
168
203
  chiefDefaults: ChiefStaffDefaultsRow | null,
204
+ chiefConfig?: ChiefAgentConfigRow | null,
169
205
  ): ResolvedStaffModels {
170
206
  const out: ResolvedStaffModels = {
171
207
  primary_model: null,
@@ -181,15 +217,26 @@ export function resolveStaffModels(
181
217
  vision_model: null,
182
218
  },
183
219
  };
220
+ const chiefPrimary = typeof chiefConfig?.primary_model === "string" && chiefConfig.primary_model.trim()
221
+ ? chiefConfig.primary_model.trim()
222
+ : null;
184
223
  for (const slot of SLOTS) {
185
224
  const staffVal = (staff as any)[slot] as string | null | undefined;
186
225
  const chiefVal = chiefDefaults ? (chiefDefaults as any)[slot] as string | null | undefined : null;
226
+ const chiefSlotVal = chiefConfig ? (chiefConfig as any)[slot] as string | null | undefined : null;
187
227
  if (staffVal && staffVal.trim()) {
188
228
  out[slot] = staffVal;
189
229
  out.source[slot] = "staff";
190
230
  } else if (chiefVal && chiefVal.trim()) {
191
231
  out[slot] = chiefVal;
192
232
  out.source[slot] = "chief_default";
233
+ } else if (chiefSlotVal && chiefSlotVal.trim()) {
234
+ out[slot] = chiefSlotVal;
235
+ out.source[slot] = "chief_primary";
236
+ } else if (chiefPrimary) {
237
+ // Sub-fallback: chief has a primary_model but no per-slot override.
238
+ out[slot] = chiefPrimary;
239
+ out.source[slot] = "chief_primary";
193
240
  } else {
194
241
  out[slot] = null;
195
242
  out.source[slot] = "chief_fallback";
@@ -223,8 +270,11 @@ export function buildStaffPersonaBlock(
223
270
  lines.push(``, `## Operating Instructions`, staff.instructions.trim());
224
271
  }
225
272
  if (resolved && (resolved.primary_model || resolved.coding_model || resolved.tool_call_model || resolved.function_call_model || resolved.vision_model)) {
226
- const sourceLabel = (s: "staff" | "chief_default" | "chief_fallback" | null) =>
227
- s === "staff" ? "per-staff override" : s === "chief_default" ? "chief STAFF MODEL" : "inherits chief";
273
+ const sourceLabel = (s: "staff" | "chief_default" | "chief_primary" | "chief_fallback" | null) =>
274
+ s === "staff" ? "per-staff override"
275
+ : s === "chief_default" ? "chief STAFF MODEL"
276
+ : s === "chief_primary" ? "chief's own model"
277
+ : "inherits chief (gateway default)";
228
278
  const rows: string[] = [];
229
279
  if (resolved.primary_model) rows.push(`- primary: \`${resolved.primary_model}\` (${sourceLabel(resolved.source.primary_model)})`);
230
280
  if (resolved.coding_model) rows.push(`- coding: \`${resolved.coding_model}\` (${sourceLabel(resolved.source.coding_model)})`);
package/src/tools.ts CHANGED
@@ -19,7 +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
+ import { loadSubagentRow, readDispatchSubagentId, loadDispatchContextBySession, type DispatchContextRow } from "./staffPersona.js";
23
23
 
24
24
  // ─── Tool result shape (matches OpenClaw SDK) ────────────────────────────────
25
25
 
@@ -458,12 +458,145 @@ async function handleListTasks(
458
458
  }
459
459
  }
460
460
 
461
+ // Cycle 7b BUGSHOOT-2 (BUG 5) — IANA timezone offset in hours. Works regardless
462
+ // of the VPS clock TZ because Intl.DateTimeFormat resolves the IANA zone
463
+ // directly without going through `new Date(local-string)` parsing.
464
+ function getTimezoneOffsetHours(tz: string): number {
465
+ try {
466
+ const parts = new Intl.DateTimeFormat("en-US", {
467
+ timeZone: tz,
468
+ timeZoneName: "shortOffset",
469
+ }).formatToParts(new Date());
470
+ const offsetPart = parts.find((p) => p.type === "timeZoneName");
471
+ const txt = offsetPart?.value || "GMT+0";
472
+ const m = txt.match(/GMT([+-]\d{1,2})(?::?(\d{2}))?/i);
473
+ if (!m) return 0;
474
+ const hours = parseInt(m[1], 10);
475
+ const minutes = m[2] ? parseInt(m[2], 10) : 0;
476
+ return hours + (hours < 0 ? -minutes / 60 : minutes / 60);
477
+ } catch {
478
+ return 7;
479
+ }
480
+ }
481
+
482
+ // Cycle 7b BUGSHOOT-2 (BUG 5) — load the caller's IANA timezone from their
483
+ // profile row. The dashboard writes it on sign-in (browser-detected). Falls
484
+ // back to the plugin-config default if unset/missing.
485
+ async function loadUserTimezone(
486
+ supabase: SupabaseClient,
487
+ userId: string,
488
+ fallback: string,
489
+ ): Promise<string> {
490
+ try {
491
+ const { data } = await supabase
492
+ .from("profiles")
493
+ .select("timezone")
494
+ .eq("id", userId)
495
+ .maybeSingle();
496
+ const tz = typeof data?.timezone === "string" && data.timezone.trim()
497
+ ? data.timezone.trim()
498
+ : null;
499
+ return tz || fallback;
500
+ } catch {
501
+ return fallback;
502
+ }
503
+ }
504
+
505
+ // Cycle 7b BUGSHOOT-2 (BUG 5) — normalize a start_date string to UTC against
506
+ // the user's IANA timezone. Returns the canonical UTC ISO string, the epoch
507
+ // for next_run_at, and the local clock components for scheduler_events.
508
+ //
509
+ // Three input shapes are handled:
510
+ // - explicit UTC marker (Z, +00, +0000, +00:00, UTC) → no conversion
511
+ // - has time component, no UTC marker → treat as user-local, subtract offset
512
+ // - date only → default to 09:00 user-local, subtract offset
513
+ //
514
+ // Critical: parses date/time COMPONENTS from the raw string via regex, NOT via
515
+ // `new Date(str)`. JS Date parsing of naive strings is TZ-dependent on the
516
+ // host clock — the VPS may be WIB or UTC, and we cannot trust either.
517
+ function normalizeStartDate(
518
+ startDate: string,
519
+ explicitScheduledTime: string | undefined,
520
+ tzOffsetHours: number,
521
+ ): {
522
+ startDateUTC: string;
523
+ nextRunAtEpoch: number;
524
+ scheduledTimeLocal: string;
525
+ scheduledDateLocal: string;
526
+ } | null {
527
+ const trimmed = startDate.trim();
528
+ const dateMatch = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})/);
529
+ if (!dateMatch) return null;
530
+ const year = parseInt(dateMatch[1], 10);
531
+ const month = parseInt(dateMatch[2], 10);
532
+ const day = parseInt(dateMatch[3], 10);
533
+
534
+ const utcMarker = /Z$|[+-]00:?00$|UTC$/i.test(trimmed);
535
+ const timeMatch = trimmed.match(/[T ](\d{2}):(\d{2})(?::(\d{2}))?/);
536
+ const offsetMin = Math.round(tzOffsetHours * 60);
537
+ const pad = (n: number) => String(n).padStart(2, "0");
538
+
539
+ // explicit scheduled_time: agent passed local time alongside a date.
540
+ if (explicitScheduledTime) {
541
+ const stMatch = explicitScheduledTime.match(/^(\d{2}):(\d{2})/);
542
+ const localH = stMatch ? parseInt(stMatch[1], 10) : 0;
543
+ const localM = stMatch ? parseInt(stMatch[2], 10) : 0;
544
+ const utcMs = Date.UTC(year, month - 1, day, localH, localM, 0) - offsetMin * 60 * 1000;
545
+ return {
546
+ startDateUTC: new Date(utcMs).toISOString(),
547
+ nextRunAtEpoch: Math.floor(utcMs / 1000),
548
+ scheduledTimeLocal: `${pad(localH)}:${pad(localM)}`,
549
+ scheduledDateLocal: `${year}-${pad(month)}-${pad(day)}`,
550
+ };
551
+ }
552
+
553
+ if (utcMarker && timeMatch) {
554
+ // Input is already UTC. Use components verbatim.
555
+ const utcH = parseInt(timeMatch[1], 10);
556
+ const utcM = parseInt(timeMatch[2], 10);
557
+ const utcS = timeMatch[3] ? parseInt(timeMatch[3], 10) : 0;
558
+ const utcMs = Date.UTC(year, month - 1, day, utcH, utcM, utcS);
559
+ // Recover local clock by adding the user's offset.
560
+ const localMs = utcMs + offsetMin * 60 * 1000;
561
+ const local = new Date(localMs);
562
+ return {
563
+ startDateUTC: new Date(utcMs).toISOString(),
564
+ nextRunAtEpoch: Math.floor(utcMs / 1000),
565
+ scheduledTimeLocal: `${pad(local.getUTCHours())}:${pad(local.getUTCMinutes())}`,
566
+ scheduledDateLocal: `${local.getUTCFullYear()}-${pad(local.getUTCMonth() + 1)}-${pad(local.getUTCDate())}`,
567
+ };
568
+ }
569
+
570
+ if (timeMatch) {
571
+ // Local-no-Z input: user-local time, subtract offset to get UTC.
572
+ const localH = parseInt(timeMatch[1], 10);
573
+ const localM = parseInt(timeMatch[2], 10);
574
+ const localS = timeMatch[3] ? parseInt(timeMatch[3], 10) : 0;
575
+ const utcMs = Date.UTC(year, month - 1, day, localH, localM, localS) - offsetMin * 60 * 1000;
576
+ return {
577
+ startDateUTC: new Date(utcMs).toISOString(),
578
+ nextRunAtEpoch: Math.floor(utcMs / 1000),
579
+ scheduledTimeLocal: `${pad(localH)}:${pad(localM)}`,
580
+ scheduledDateLocal: `${year}-${pad(month)}-${pad(day)}`,
581
+ };
582
+ }
583
+
584
+ // Date-only input — default to 09:00 user-local.
585
+ const utcMs = Date.UTC(year, month - 1, day, 9, 0, 0) - offsetMin * 60 * 1000;
586
+ return {
587
+ startDateUTC: new Date(utcMs).toISOString(),
588
+ nextRunAtEpoch: Math.floor(utcMs / 1000),
589
+ scheduledTimeLocal: "09:00",
590
+ scheduledDateLocal: `${year}-${pad(month)}-${pad(day)}`,
591
+ };
592
+ }
593
+
461
594
  async function handleCreateTask(
462
595
  supabase: SupabaseClient,
463
596
  userId: string,
464
597
  resolveAgent: (id?: string) => Promise<string | null>,
465
598
  params: Record<string, unknown>,
466
- timezone: string = "Asia/Jakarta",
599
+ fallbackTimezone: string = "Asia/Jakarta",
467
600
  ): Promise<ToolResult> {
468
601
  try {
469
602
  if (!params.title) return err("Missing required field: title");
@@ -471,6 +604,11 @@ async function handleCreateTask(
471
604
  const id = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
472
605
  const now = new Date().toISOString();
473
606
 
607
+ // BUG 5 fix: resolve user's IANA timezone from their profile row. Plugin
608
+ // config default is only used when the profile row is missing/unset.
609
+ const timezone = await loadUserTimezone(supabase, userId, fallbackTimezone);
610
+ const TZ_OFFSET_HOURS = getTimezoneOffsetHours(timezone);
611
+
474
612
  // Handle explicit "none"/"unassigned"
475
613
  const rawAgentId = params.agent_id as string | undefined;
476
614
  const isUnassigned =
@@ -576,6 +714,17 @@ async function handleCreateTask(
576
714
  }
577
715
  }
578
716
 
717
+ // BUG 5 fix (cycle 7b BUGSHOOT-2): normalize start_date BEFORE the INSERT
718
+ // so DB and tool-response carry the SAME canonical UTC ISO. Old code
719
+ // INSERTed the raw input then reassigned insertData.start_date afterward,
720
+ // which left the DB row with the unconverted value and the response with
721
+ // the converted one — confusing and unsafe for the scheduler.
722
+ const rawStartDate = (params.start_date as string) || null;
723
+ const normalized = rawStartDate
724
+ ? normalizeStartDate(rawStartDate, params.scheduled_time as string | undefined, TZ_OFFSET_HOURS)
725
+ : null;
726
+ const canonicalStartDate = normalized?.startDateUTC ?? rawStartDate;
727
+
579
728
  const insertData: Record<string, unknown> = {
580
729
  id,
581
730
  user_id: userId,
@@ -588,8 +737,8 @@ async function handleCreateTask(
588
737
  priority: params.priority !== undefined ? params.priority : 1,
589
738
  space_id: resolvedSpaceId,
590
739
  folder_id: resolvedFolderId,
591
- start_date: (params.start_date as string) || null,
592
- due_date: (params.due_date as string) || (params.start_date as string) || null,
740
+ start_date: canonicalStartDate,
741
+ due_date: (params.due_date as string) || canonicalStartDate || null,
593
742
  tags: (params.tags as string[]) || [],
594
743
  progress: 0,
595
744
  sort_order: 0,
@@ -615,113 +764,15 @@ async function handleCreateTask(
615
764
  }
616
765
 
617
766
  // ── Auto-create scheduler event if task has a start_date ──────────────
618
- // This bridges the plugin scheduler so the pg_cron task-dispatcher
619
- // Edge Function picks up the task at the right time.
620
- //
621
- // TIMEZONE: scheduled_time is treated as the user's LOCAL time (default WIB, UTC+7).
622
- // We convert to UTC epoch for next_run_at so the edge function fires correctly.
623
- const startDate = params.start_date as string | undefined;
767
+ // start_date is already normalized above. Scheduler event uses the same
768
+ // canonical UTC ISO + the local-clock components for the dashboard UI.
769
+ const startDate = rawStartDate;
624
770
  const effectiveAgentId = (insertData.agent_id as string) || assignee;
625
-
626
- // Resolve timezone offset from IANA timezone string (configurable per user)
627
- const getTimezoneOffsetHours = (tz: string): number => {
628
- try {
629
- const now = new Date();
630
- const utcStr = now.toLocaleString('en-US', { timeZone: 'UTC' });
631
- const tzStr = now.toLocaleString('en-US', { timeZone: tz });
632
- const diffMs = new Date(tzStr).getTime() - new Date(utcStr).getTime();
633
- return Math.round(diffMs / 3600000);
634
- } catch {
635
- return 7; // Fallback to WIB (UTC+7)
636
- }
637
- };
638
- const TZ_OFFSET_HOURS = getTimezoneOffsetHours(timezone);
639
- if (startDate && effectiveAgentId) {
771
+ if (startDate && effectiveAgentId && normalized) {
640
772
  try {
641
- // Parse start_date robustly it can be:
642
- // "2026-04-19" (date only)
643
- // "2026-04-19T18:45:00" (local datetime)
644
- // "2026-04-19 11:45:00+00" (Supabase timestamptz)
645
- // "2026-04-19T11:45:00.000Z" (ISO UTC)
646
- const parsedDate = new Date(startDate);
647
- const explicitScheduledTime = params.scheduled_time as string | undefined;
648
-
649
- let nextRunAtEpoch: number;
650
- let scheduledTimeFinal: string;
651
- let scheduledDateFinal: string;
652
-
653
- if (!isNaN(parsedDate.getTime())) {
654
- // Valid date — check if it includes a meaningful time component
655
- const hasTimeInfo = /[T ]\d{2}:\d{2}/.test(startDate);
656
- // BUG 4 fix (cycle 7b BUGSHOOT-1): if the input string already carries
657
- // an explicit UTC marker (Z, +00, +0000, +00:00, UTC), the time
658
- // component is NOT user-local — skip the local→UTC subtraction.
659
- // Without this, a UTC-Z input like "2026-05-10T16:25:00Z" got mistaken
660
- // for local 16:25 and stored as 09:25Z (off by TZ_OFFSET_HOURS).
661
- const isUtcInput = /Z$|[+-]00:?00$|UTC$/i.test(startDate.trim());
662
-
663
- if (explicitScheduledTime) {
664
- // Agent explicitly passed a scheduled_time — treat as user’s local time
665
- const dateStr = parsedDate.toISOString().split("T")[0]; // YYYY-MM-DD
666
- const [localH, localM] = explicitScheduledTime.split(":").map(Number);
667
- const utcH = localH - TZ_OFFSET_HOURS;
668
- const dt = new Date(`${dateStr}T00:00:00Z`);
669
- dt.setUTCHours(utcH, localM, 0, 0);
670
- nextRunAtEpoch = Math.floor(dt.getTime() / 1000);
671
- scheduledTimeFinal = explicitScheduledTime; // Store as user's local time
672
- scheduledDateFinal = dateStr;
673
- // Normalize start_date to ISO UTC for consistent downstream parsing
674
- insertData.start_date = dt.toISOString();
675
- } else if (isUtcInput) {
676
- // Input is already UTC — no local→UTC conversion. Use parsedDate
677
- // directly. scheduled_time is documented as user-local, so add the
678
- // offset to recover the local-clock value.
679
- nextRunAtEpoch = Math.floor(parsedDate.getTime() / 1000);
680
- const localHour = (parsedDate.getUTCHours() + TZ_OFFSET_HOURS + 24) % 24;
681
- scheduledTimeFinal = `${String(localHour).padStart(2, "0")}:${String(parsedDate.getUTCMinutes()).padStart(2, "0")}`;
682
- scheduledDateFinal = parsedDate.toISOString().split("T")[0];
683
- insertData.start_date = parsedDate.toISOString();
684
- } else if (hasTimeInfo) {
685
- // start_date already contains time — assume it’s in the user’s local timezone
686
- // Extract the local hour:minute from the string, NOT from UTC parsing
687
- const timeMatch = startDate.match(/(\d{2}):(\d{2})/);
688
- if (timeMatch) {
689
- const localH = parseInt(timeMatch[1], 10);
690
- const localM = parseInt(timeMatch[2], 10);
691
- const dateStr = parsedDate.toISOString().split("T")[0];
692
- const dt = new Date(`${dateStr}T00:00:00Z`);
693
- dt.setUTCHours(localH - TZ_OFFSET_HOURS, localM, 0, 0);
694
- nextRunAtEpoch = Math.floor(dt.getTime() / 1000);
695
- scheduledTimeFinal = `${String(localH).padStart(2, "0")}:${String(localM).padStart(2, "0")}`;
696
- scheduledDateFinal = dateStr;
697
- // Normalize start_date to ISO UTC
698
- insertData.start_date = dt.toISOString();
699
- } else {
700
- // Can't extract time — fall back to UTC parsing
701
- nextRunAtEpoch = Math.floor(parsedDate.getTime() / 1000);
702
- scheduledTimeFinal = `${String(parsedDate.getUTCHours()).padStart(2, "0")}:${String(parsedDate.getUTCMinutes()).padStart(2, "0")}`;
703
- scheduledDateFinal = parsedDate.toISOString().split("T")[0];
704
- }
705
- } else {
706
- // Date only, no time — default to 09:00 user local time
707
- const dateStr = parsedDate.toISOString().split("T")[0];
708
- const dt = new Date(`${dateStr}T00:00:00Z`);
709
- dt.setUTCHours(9 - TZ_OFFSET_HOURS, 0, 0, 0); // 09:00 local = UTC - offset
710
- nextRunAtEpoch = Math.floor(dt.getTime() / 1000);
711
- scheduledTimeFinal = "09:00"; // Stored as user's local time
712
- scheduledDateFinal = dateStr;
713
- // Normalize start_date to ISO UTC
714
- insertData.start_date = dt.toISOString();
715
- }
716
- } else {
717
- // Unparseable date — fallback to now + 60s
718
- nextRunAtEpoch = Math.floor(Date.now() / 1000) + 60;
719
- scheduledTimeFinal = "00:00";
720
- scheduledDateFinal = new Date().toISOString().split("T")[0];
721
- }
722
-
723
- // Safety net: if computed time is in the past, schedule for now + 60s
773
+ let { nextRunAtEpoch } = normalized;
724
774
  const nowEpoch = Math.floor(Date.now() / 1000);
775
+ // Safety net: if computed time is in the past, schedule for now + 60s
725
776
  if (nextRunAtEpoch <= nowEpoch) {
726
777
  nextRunAtEpoch = nowEpoch + 60;
727
778
  }
@@ -734,8 +785,8 @@ async function handleCreateTask(
734
785
  subagent_id: subagentId,
735
786
  title: params.title,
736
787
  description: (params.description as string) || (params.instructions as string) || null,
737
- scheduled_date: scheduledDateFinal,
738
- scheduled_time: scheduledTimeFinal,
788
+ scheduled_date: normalized.scheduledDateLocal,
789
+ scheduled_time: normalized.scheduledTimeLocal,
739
790
  duration_minutes: 30,
740
791
  recurrence_type: (params.recurrence_type as string) || "none",
741
792
  recurrence_interval: (params.recurrence_interval as number) || 1,
@@ -6573,7 +6624,24 @@ function registerBrainExtractionHook(
6573
6624
  // scoped memory note ("Staff X reported on task Y: ...") and POST a
6574
6625
  // webhook so the dashboard can surface the report. The staff itself
6575
6626
  // gets no memory entry (req 3 — staff has no memory of its own).
6576
- const staffDispatchSubagentId = readDispatchSubagentId(ctx, event);
6627
+ //
6628
+ // Cycle 7b BUGSHOOT-2 (BUG 7) — metadata path is dead since BUG 2 made
6629
+ // the dispatcher stop sending `metadata` on chat.send. Fall back to
6630
+ // the dispatch_context row keyed by sessionKey before giving up.
6631
+ let staffDispatchSubagentId = readDispatchSubagentId(ctx, event);
6632
+ let dispatchCtxRow: DispatchContextRow | null = null;
6633
+ let dispatchTaskIdFromCtx: string | null = null;
6634
+ let dispatchConversationIdFromCtx: string | null = null;
6635
+ if (sessionKey) {
6636
+ dispatchCtxRow = await loadDispatchContextBySession(supabase, userId, sessionKey).catch(() => null);
6637
+ if (dispatchCtxRow) {
6638
+ if (!staffDispatchSubagentId && dispatchCtxRow.subagent_id) {
6639
+ staffDispatchSubagentId = dispatchCtxRow.subagent_id;
6640
+ }
6641
+ dispatchTaskIdFromCtx = dispatchCtxRow.task_id;
6642
+ dispatchConversationIdFromCtx = dispatchCtxRow.conversation_id;
6643
+ }
6644
+ }
6577
6645
  if (staffDispatchSubagentId) {
6578
6646
  (async () => {
6579
6647
  try {
@@ -6586,9 +6654,12 @@ function registerBrainExtractionHook(
6586
6654
  api.logger.warn?.(`[ofiere-staff-report] chief_mismatch (subagent ${staffDispatchSubagentId} reports to ${staff.chief_agent_id}, run was on ${resolvedAgentId}) — skipping report`);
6587
6655
  return;
6588
6656
  }
6657
+ // BUG 7 fix (BUGSHOOT-2): include dispatch_context.task_id as a
6658
+ // fallback now that metadata is dead on the wire.
6589
6659
  const taskId =
6590
6660
  ctx?.metadata?.task_id || ctx?.metadata?.dispatch?.task_id ||
6591
- event?.metadata?.task_id || event?.context?.metadata?.task_id || null;
6661
+ event?.metadata?.task_id || event?.context?.metadata?.task_id ||
6662
+ dispatchTaskIdFromCtx || null;
6592
6663
  const excerpt = lastAssistant.slice(0, 1500);
6593
6664
  const reportBody = taskId
6594
6665
  ? `Staff ${staff.name}${staff.role ? ` (${staff.role})` : ""} reported on task ${taskId}:\n\n${excerpt}`
@@ -6604,6 +6675,54 @@ function registerBrainExtractionHook(
6604
6675
  });
6605
6676
  api.logger.info?.(`[ofiere-staff-report] memory written for chief ${staff.chief_agent_id} from staff ${staff.name}`);
6606
6677
 
6678
+ // BUG 9 fix (BUGSHOOT-2): mark task_dispatch_log row complete
6679
+ // with the assistant's response preview. Without this the log
6680
+ // stays at status='dispatched' forever, breaking dashboard
6681
+ // dispatch-history views and audit trails.
6682
+ if (taskId) {
6683
+ try {
6684
+ await supabase.from("task_dispatch_log")
6685
+ .update({
6686
+ status: "completed",
6687
+ response_preview: excerpt.slice(0, 500),
6688
+ completed_at: new Date().toISOString(),
6689
+ })
6690
+ .eq("task_id", taskId)
6691
+ .eq("status", "dispatched");
6692
+ } catch (logErr) {
6693
+ api.logger.debug?.(`[ofiere-staff-report] dispatch_log update failed: ${logErr instanceof Error ? logErr.message : String(logErr)}`);
6694
+ }
6695
+ }
6696
+
6697
+ // BUG 6 fix (BUGSHOOT-2): mirror the assistant turn into the
6698
+ // dashboard's hidden conversation_messages so the dashboard can
6699
+ // render staff-run history. Dispatcher pre-created the
6700
+ // conversations row and stamped its id on dispatch_context.
6701
+ if (dispatchConversationIdFromCtx) {
6702
+ try {
6703
+ await supabase.from("conversation_messages").insert({
6704
+ conversation_id: dispatchConversationIdFromCtx,
6705
+ user_id: userId,
6706
+ role: "user",
6707
+ content: lastUser.slice(0, 4000),
6708
+ created_at: new Date(Date.now() - 1000).toISOString(),
6709
+ });
6710
+ await supabase.from("conversation_messages").insert({
6711
+ conversation_id: dispatchConversationIdFromCtx,
6712
+ user_id: userId,
6713
+ role: "assistant",
6714
+ content: excerpt,
6715
+ created_at: new Date().toISOString(),
6716
+ });
6717
+ await supabase.from("conversations")
6718
+ .update({ updated_at: new Date().toISOString() })
6719
+ .eq("id", dispatchConversationIdFromCtx)
6720
+ .eq("user_id", userId);
6721
+ } catch (cmErr) {
6722
+ api.logger.debug?.(`[ofiere-staff-report] conversation_messages insert failed: ${cmErr instanceof Error ? cmErr.message : String(cmErr)}`);
6723
+ }
6724
+ }
6725
+
6607
6726
  // Best-effort webhook to dashboard (existing OPENCLAW_WEBHOOK_SECRET).
6608
6727
  const webhookUrl = process.env.OFIERE_DASHBOARD_WEBHOOK_URL || "";
6609
6728
  const webhookSecret = process.env.OPENCLAW_WEBHOOK_SECRET || "";
@@ -6639,6 +6758,24 @@ function registerBrainExtractionHook(
6639
6758
  return;
6640
6759
  }
6641
6760
 
6761
+ // BUG 9 fix (BUGSHOOT-2): mark task_dispatch_log row complete for
6762
+ // non-staff scheduled dispatches too. Without this the log row stays
6763
+ // at 'dispatched' even after the chief finished the run.
6764
+ if (dispatchTaskIdFromCtx) {
6765
+ try {
6766
+ await supabase.from("task_dispatch_log")
6767
+ .update({
6768
+ status: "completed",
6769
+ response_preview: lastAssistant.slice(0, 500),
6770
+ completed_at: new Date().toISOString(),
6771
+ })
6772
+ .eq("task_id", dispatchTaskIdFromCtx)
6773
+ .eq("status", "dispatched");
6774
+ } catch (logErr) {
6775
+ api.logger.debug?.(`[ofiere-brain] dispatch_log update failed: ${logErr instanceof Error ? logErr.message : String(logErr)}`);
6776
+ }
6777
+ }
6778
+
6642
6779
  // ── Fast Stream: Write L1_focus + L2_episode in parallel ──
6643
6780
  const rawContent = `User: ${lastUser}\nAssistant: ${lastAssistant}`;
6644
6781
  const immediateWrites: PromiseLike<any>[] = [];