ofiere-openclaw-plugin 4.52.0 → 4.54.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/dist/src/tools.js CHANGED
@@ -233,7 +233,7 @@ function registerTaskOps(api, supabase, userId, resolveAgent, timezone) {
233
233
  `Actions:\n` +
234
234
  `- "list": List/filter tasks. Optional: status, agent_id, space_id, folder_id, task_id, limit\n` +
235
235
  `- "get": Get a single task by ID. Required: task_id\n` +
236
- `- "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` +
236
+ `- "create": Create a task in DRAFT mode. Required: title. Optional: agent_id, subagent_id, description, status, priority, space_id, folder_id, start_date, due_date, dispatch_immediately, tags, instructions, execution_plan, goals, constraints, system_prompt, recurrence_type, recurrence_interval, scheduled_time. ⚠️ EXECUTION SEMANTICS: omit start_date → task sits in agent's task list as draft (does NOT run). Set start_date to schedule. Pass dispatch_immediately:true to run now. ⚠️ 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` +
237
237
  `- "update": Update a task. Required: task_id. Optional: all create fields + progress + subagent_id\n` +
238
238
  `- "delete": Delete task + subtasks. Required: task_id\n` +
239
239
  `- "add_approval": Request approval on a task. Required: task_id, approver_name. Optional: approver_type (human|agent, auto-detected), due_date, comment\n` +
@@ -279,8 +279,12 @@ function registerTaskOps(api, supabase, userId, resolveAgent, timezone) {
279
279
  progress: { type: "number", description: "Progress percentage 0-100 (update only)" },
280
280
  space_id: { type: "string", description: "PM Space ID" },
281
281
  folder_id: { type: "string", description: "PM Folder ID" },
282
- start_date: { type: "string", description: "Start date (ISO 8601). Required for scheduled/recurring tasks." },
282
+ start_date: { type: "string", description: "Start date (ISO 8601). Required for scheduled/recurring tasks. Omit to create as draft." },
283
283
  due_date: { type: "string", description: "Due date (ISO 8601)" },
284
+ dispatch_immediately: {
285
+ type: "boolean",
286
+ description: "When true and start_date is not provided, sets start_date to NOW so the task dispatches on the next cron tick. Default: false (task is created as draft and waits for an explicit start_date).",
287
+ },
284
288
  scheduled_time: { type: "string", description: "Time to execute in HH:MM format (user's LOCAL time, e.g. 10:00 for 10 AM WIB). The system converts to UTC automatically." },
285
289
  recurrence_type: {
286
290
  type: "string",
@@ -666,15 +670,17 @@ async function handleCreateTask(supabase, userId, resolveAgent, params, fallback
666
670
  // which left the DB row with the unconverted value and the response with
667
671
  // the converted one — confusing and unsafe for the scheduler.
668
672
  //
669
- // Cycle 12 fix: when delegating to a staff (subagent_id present) without
670
- // an explicit start_date, default to now + 5s so the scheduler_event auto-
671
- // create branch below kicks in. Without this, the task sat PENDING forever
672
- // because processDirectTasks' date-string filter also skips it (separate
673
- // bug, fixed in task-dispatcher v21). The 5s buffer ensures the safety
674
- // net at line ~805 doesn't trip and shift the run to +60s.
673
+ // Cycle 13 (BUG 3+4): create is a DRAFT operation, NOT a dispatch trigger.
674
+ // No start_date rawStartDate stays null no scheduler_event auto-insert
675
+ // task sits in agent's task list as draft. To execute, caller must pass
676
+ // an explicit start_date OR set dispatch_immediately:true (which we then
677
+ // resolve to "now" ISO here). Cycle 12's silent +5s default for staff
678
+ // delegation is REMOVED it turned create into an unsafe execution
679
+ // trigger and bypassed the chief's approval flow.
675
680
  const explicitStartDate = params.start_date || null;
681
+ const dispatchNow = params.dispatch_immediately === true;
676
682
  const rawStartDate = explicitStartDate
677
- ?? (params.subagent_id ? new Date(Date.now() + 5_000).toISOString() : null);
683
+ ?? (dispatchNow ? new Date().toISOString() : null);
678
684
  const normalized = rawStartDate
679
685
  ? normalizeStartDate(rawStartDate, params.scheduled_time, TZ_OFFSET_HOURS)
680
686
  : null;
@@ -807,8 +813,18 @@ async function handleUpdateTask(supabase, userId, params) {
807
813
  if (params[f] !== undefined)
808
814
  updates[f] = params[f];
809
815
  }
810
- if (params.status === "DONE")
816
+ // Cycle 13 (BUG 2): keep status and completed_at consistent. Setting status
817
+ // to DONE stamps completed_at + progress=100. Setting status to anything
818
+ // other than DONE clears completed_at so the row never carries the
819
+ // "PENDING + completed_at" contradiction the dashboard reported.
820
+ if (params.status === "DONE") {
811
821
  updates.completed_at = new Date().toISOString();
822
+ if (params.progress === undefined)
823
+ updates.progress = 100;
824
+ }
825
+ else if (typeof params.status === "string") {
826
+ updates.completed_at = null;
827
+ }
812
828
  // ── Subagent + chief invariant ───────────────────────────────────────
813
829
  // Mirrors dashboard PATCH: chief change without explicit subagent_id
814
830
  // clears any existing subagent assignment; subagent_id alone validates
@@ -2771,11 +2787,68 @@ async function generatePMReportDirect(supabase, userId, scopeType, scopeId, agen
2771
2787
  }
2772
2788
  lines.push("");
2773
2789
  lines.push(`💡 Reply "details on #N" for full task result`);
2790
+ // Cycle 13 (BUG 1): render the "Generated" stamp in the user's IANA TZ
2791
+ // (from profiles.timezone) instead of UTC. The plugin can't import
2792
+ // dashboard helpers, so this uses Intl.DateTimeFormat directly.
2793
+ const userTz = await loadUserTimezoneForPlugin(supabase, userId);
2774
2794
  const now = new Date();
2775
2795
  lines.push("");
2776
- lines.push(`Generated: ${now.toISOString().replace("T", " ").slice(0, 16)} UTC`);
2796
+ lines.push(`Generated: ${formatInUserTzInline(now, userTz)}`);
2777
2797
  return { report: lines.join("\n"), scopeLabel, taskCount: all.length };
2778
2798
  }
2799
+ // ─── Plugin-local TZ helpers (Cycle 13, BUG 1) ──────────────────────────────
2800
+ // Mirror dashboard/lib/tz.ts; kept inline because the plugin is a separate
2801
+ // npm package loaded directly by the OpenClaw gateway and cannot import from
2802
+ // the dashboard.
2803
+ const PLUGIN_FALLBACK_TZ = "Asia/Jakarta";
2804
+ async function loadUserTimezoneForPlugin(supabase, userId) {
2805
+ if (!userId)
2806
+ return PLUGIN_FALLBACK_TZ;
2807
+ try {
2808
+ const { data } = await supabase
2809
+ .from("profiles")
2810
+ .select("timezone")
2811
+ .eq("id", userId)
2812
+ .maybeSingle();
2813
+ const tz = data?.timezone?.trim();
2814
+ return tz && isValidIanaZoneInline(tz) ? tz : PLUGIN_FALLBACK_TZ;
2815
+ }
2816
+ catch {
2817
+ return PLUGIN_FALLBACK_TZ;
2818
+ }
2819
+ }
2820
+ function formatInUserTzInline(date, timezone) {
2821
+ if (Number.isNaN(date.getTime()))
2822
+ return "";
2823
+ const tz = isValidIanaZoneInline(timezone) ? timezone : PLUGIN_FALLBACK_TZ;
2824
+ try {
2825
+ const fmt = new Intl.DateTimeFormat("en-US", {
2826
+ timeZone: tz,
2827
+ year: "numeric",
2828
+ month: "short",
2829
+ day: "numeric",
2830
+ hour: "2-digit",
2831
+ minute: "2-digit",
2832
+ hour12: false,
2833
+ timeZoneName: "short",
2834
+ });
2835
+ return fmt.format(date).replace(", ", " ");
2836
+ }
2837
+ catch {
2838
+ return date.toISOString();
2839
+ }
2840
+ }
2841
+ function isValidIanaZoneInline(tz) {
2842
+ if (!tz)
2843
+ return false;
2844
+ try {
2845
+ new Intl.DateTimeFormat("en-US", { timeZone: tz }).format(new Date());
2846
+ return true;
2847
+ }
2848
+ catch {
2849
+ return false;
2850
+ }
2851
+ }
2779
2852
  async function dispatchReportDirect(supabase, userId, agentId, report, channelTypes) {
2780
2853
  // Query agent's active channel bindings
2781
2854
  let query = supabase.from("channel_bindings").select("channel_type, channel_account_id, agent_id, channel_config, thread_id")
@@ -6389,6 +6462,436 @@ function registerTalentContextHook(api, supabase, userId, fallbackAgentId) {
6389
6462
  }
6390
6463
  }
6391
6464
  // ═══════════════════════════════════════════════════════════════════════════════
6465
+ // META-TOOL 17: OFIERE_OFFICE_OPS — Agent Office Canvas (widget dashboard)
6466
+ // ═══════════════════════════════════════════════════════════════════════════════
6467
+ async function emitOfficeWebhook(api, type, payload) {
6468
+ const webhookUrl = process.env.OFIERE_DASHBOARD_WEBHOOK_URL || "";
6469
+ const webhookSecret = process.env.OPENCLAW_WEBHOOK_SECRET || "";
6470
+ if (!webhookUrl || !webhookSecret)
6471
+ return;
6472
+ try {
6473
+ await fetch(webhookUrl, {
6474
+ method: "POST",
6475
+ headers: {
6476
+ "content-type": "application/json",
6477
+ authorization: `Bearer ${webhookSecret}`,
6478
+ },
6479
+ body: JSON.stringify({ type, payload }),
6480
+ });
6481
+ }
6482
+ catch (wErr) {
6483
+ api.logger.debug?.(`[ofiere-office] webhook post failed: ${wErr instanceof Error ? wErr.message : String(wErr)}`);
6484
+ }
6485
+ }
6486
+ function validateSection(s) {
6487
+ if (!s || typeof s !== "object")
6488
+ return "section is not an object";
6489
+ if (typeof s.title !== "string" || !s.title)
6490
+ return "section.title required";
6491
+ if (typeof s.order !== "number")
6492
+ return "section.order must be a number";
6493
+ if (!Array.isArray(s.widgets))
6494
+ return "section.widgets must be an array";
6495
+ for (const w of s.widgets) {
6496
+ const wErr = validateWidget(w);
6497
+ if (wErr)
6498
+ return `section "${s.title}": ${wErr}`;
6499
+ }
6500
+ return null;
6501
+ }
6502
+ function validateWidget(w) {
6503
+ if (!w || typeof w !== "object")
6504
+ return "widget is not an object";
6505
+ if (typeof w.type !== "string" || !w.type)
6506
+ return "widget.type required";
6507
+ if (typeof w.config !== "object" || w.config === null)
6508
+ return "widget.config must be an object";
6509
+ const ly = w.layout;
6510
+ if (!ly || typeof ly !== "object")
6511
+ return "widget.layout required";
6512
+ if (typeof ly.x !== "number" || typeof ly.y !== "number" || typeof ly.w !== "number" || typeof ly.h !== "number") {
6513
+ return "widget.layout must have numeric x, y, w, h";
6514
+ }
6515
+ return null;
6516
+ }
6517
+ function shortId(prefix) {
6518
+ return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
6519
+ }
6520
+ function registerOfficeOps(api, supabase, userId, resolveAgent) {
6521
+ api.registerTool((toolCtx) => ({
6522
+ name: "OFIERE_OFFICE_OPS",
6523
+ label: "Ofiere Office Operations",
6524
+ description: `Build and update your department Office — a widget-based dashboard canvas bound to a PM Space.\n\n` +
6525
+ `Actions:\n` +
6526
+ `- "get_office": Load the current layout + widget data for a given (space_id, agent_id). Optional: space_id, agent_id.\n` +
6527
+ `- "build_office": One-time initial build. Required: space_id, sections. Only callable while the office row is in 'initializing' state.\n` +
6528
+ `- "update_widgets": Modify the layout (add/remove/replace/patch widgets or sections). Required: office_id, operations. Locked items are skipped (returns warnings).\n` +
6529
+ `- "update_widget_data": Push fresh data for a single widget. Required: office_id, widget_id, data. Always succeeds regardless of lock state — locks protect layout, not data.\n\n` +
6530
+ `Layout shape:\n` +
6531
+ ` { version: 1, sections: [ { id, title, description?, order, locked?, collapsed?, columns, widgets: [ { id, type, config, layout: {x,y,w,h}, locked?, data_source? } ] } ] }\n\n` +
6532
+ `Widget catalog (the dashboard knows which types exist): kpi_card, bar_chart, line_chart, donut_chart, progress_bar, sparkline, heatmap, data_table, activity_feed, report_card, status_list, checklist, text_block, embed, quick_action, file_browser, staff_roster.\n\n` +
6533
+ `Operation shape (RFC-6902 flavored): { op: 'add'|'remove'|'replace'|'patch', path: '/sections/0/widgets/-' or '/sections/0/widgets/0', value?, widget?, config? }`,
6534
+ parameters: {
6535
+ type: "object",
6536
+ required: ["action"],
6537
+ properties: {
6538
+ action: {
6539
+ type: "string",
6540
+ description: "get_office, build_office, update_widgets, update_widget_data",
6541
+ },
6542
+ space_id: { type: "string", description: "PM space UUID" },
6543
+ agent_id: { type: "string", description: "Agent identifier (defaults to calling agent)" },
6544
+ office_id: { type: "string", description: "Office row UUID" },
6545
+ widget_id: { type: "string", description: "Widget short id" },
6546
+ sections: { type: "array", description: "Initial layout sections (build_office only)" },
6547
+ operations: { type: "array", description: "Patch operations (update_widgets only)" },
6548
+ data: { type: "object", description: "Widget data payload (update_widget_data only)" },
6549
+ },
6550
+ },
6551
+ async execute(_id, params) {
6552
+ const action = params.action;
6553
+ const ctxAgentHint = toolCtx?.agentAccountId || toolCtx?.agentId || "";
6554
+ async function resolveCallingAgent(explicitId) {
6555
+ if (explicitId && explicitId.trim())
6556
+ return resolveAgent(explicitId);
6557
+ if (ctxAgentHint)
6558
+ return resolveAgent(ctxAgentHint);
6559
+ return resolveAgent();
6560
+ }
6561
+ switch (action) {
6562
+ case "get_office": {
6563
+ const agentId = await resolveCallingAgent(params.agent_id);
6564
+ if (!agentId)
6565
+ return err("Could not resolve agent_id");
6566
+ let q = supabase
6567
+ .from("agent_office_layouts")
6568
+ .select("id, space_id, agent_id, status, is_finalized, layout, last_agent_update_at, error_message, updated_at")
6569
+ .eq("user_id", userId)
6570
+ .eq("agent_id", agentId);
6571
+ if (params.space_id)
6572
+ q = q.eq("space_id", params.space_id);
6573
+ const { data: rows, error } = await q.limit(1);
6574
+ if (error)
6575
+ return err(error.message);
6576
+ if (!rows || rows.length === 0)
6577
+ return err("No office found for this agent");
6578
+ const office = rows[0];
6579
+ const { data: widgetData } = await supabase
6580
+ .from("agent_office_data")
6581
+ .select("widget_id, data, updated_at")
6582
+ .eq("user_id", userId)
6583
+ .eq("office_id", office.id);
6584
+ const dataMap = {};
6585
+ for (const row of widgetData || []) {
6586
+ dataMap[row.widget_id] = row.data;
6587
+ }
6588
+ return ok({ office, widget_data: dataMap });
6589
+ }
6590
+ case "build_office": {
6591
+ if (!params.space_id)
6592
+ return err("Missing required: space_id");
6593
+ if (!Array.isArray(params.sections))
6594
+ return err("Missing required: sections (array)");
6595
+ const agentId = await resolveCallingAgent(params.agent_id);
6596
+ if (!agentId)
6597
+ return err("Could not resolve agent_id");
6598
+ const inputSections = params.sections;
6599
+ for (let i = 0; i < inputSections.length; i++) {
6600
+ const sErr = validateSection(inputSections[i]);
6601
+ if (sErr)
6602
+ return err(`Validation failed at section[${i}]: ${sErr}`);
6603
+ }
6604
+ const { data: existing, error: loadErr } = await supabase
6605
+ .from("agent_office_layouts")
6606
+ .select("id, status")
6607
+ .eq("user_id", userId)
6608
+ .eq("space_id", params.space_id)
6609
+ .eq("agent_id", agentId)
6610
+ .maybeSingle();
6611
+ if (loadErr)
6612
+ return err(`Office lookup failed: ${loadErr.message}`);
6613
+ if (!existing)
6614
+ return err("Office row not found — dashboard must call /api/office/initialize first");
6615
+ if (existing.status !== "initializing") {
6616
+ return err(`Office already built (status=${existing.status}). Use update_widgets to modify.`);
6617
+ }
6618
+ const normalized = inputSections.map((s, idx) => {
6619
+ const widgets = (s.widgets || []).map((w) => ({
6620
+ id: typeof w.id === "string" && w.id ? w.id : shortId("wgt"),
6621
+ type: w.type,
6622
+ config: w.config || {},
6623
+ layout: {
6624
+ x: w.layout.x,
6625
+ y: w.layout.y,
6626
+ w: w.layout.w,
6627
+ h: w.layout.h,
6628
+ minW: w.layout.minW,
6629
+ minH: w.layout.minH,
6630
+ maxW: w.layout.maxW,
6631
+ maxH: w.layout.maxH,
6632
+ },
6633
+ locked: w.locked === true,
6634
+ data_source: w.data_source || { type: "agent", refresh_interval: 3600, last_refreshed_at: null },
6635
+ }));
6636
+ return {
6637
+ id: typeof s.id === "string" && s.id ? s.id : shortId("sec"),
6638
+ title: s.title,
6639
+ description: s.description,
6640
+ order: typeof s.order === "number" ? s.order : idx,
6641
+ locked: s.locked === true,
6642
+ collapsed: s.collapsed === true,
6643
+ columns: typeof s.columns === "number" ? s.columns : 3,
6644
+ widgets,
6645
+ };
6646
+ });
6647
+ const layout = { version: 1, sections: normalized };
6648
+ const { error: updateErr } = await supabase
6649
+ .from("agent_office_layouts")
6650
+ .update({
6651
+ layout,
6652
+ status: "active",
6653
+ last_agent_update_at: new Date().toISOString(),
6654
+ updated_at: new Date().toISOString(),
6655
+ })
6656
+ .eq("id", existing.id)
6657
+ .eq("user_id", userId);
6658
+ if (updateErr)
6659
+ return err(`Office save failed: ${updateErr.message}`);
6660
+ const dataRows = normalized.flatMap((s) => s.widgets.map((w) => ({
6661
+ user_id: userId,
6662
+ office_id: existing.id,
6663
+ widget_id: w.id,
6664
+ data: {},
6665
+ })));
6666
+ if (dataRows.length > 0) {
6667
+ const { error: dataErr } = await supabase
6668
+ .from("agent_office_data")
6669
+ .upsert(dataRows, { onConflict: "office_id,widget_id" });
6670
+ if (dataErr) {
6671
+ api.logger.warn?.(`[ofiere-office] widget_data seed failed: ${dataErr.message}`);
6672
+ }
6673
+ }
6674
+ await emitOfficeWebhook(api, "office_update", {
6675
+ user_id: userId,
6676
+ office_id: existing.id,
6677
+ agent_id: agentId,
6678
+ });
6679
+ return ok({
6680
+ message: `Office built with ${normalized.length} sections and ${dataRows.length} widgets`,
6681
+ office_id: existing.id,
6682
+ section_count: normalized.length,
6683
+ widget_count: dataRows.length,
6684
+ });
6685
+ }
6686
+ case "update_widgets": {
6687
+ if (!params.office_id)
6688
+ return err("Missing required: office_id");
6689
+ if (!Array.isArray(params.operations))
6690
+ return err("Missing required: operations (array)");
6691
+ const operations = params.operations;
6692
+ const agentId = await resolveCallingAgent(params.agent_id);
6693
+ if (!agentId)
6694
+ return err("Could not resolve agent_id");
6695
+ const { data: office, error: loadErr } = await supabase
6696
+ .from("agent_office_layouts")
6697
+ .select("id, layout, is_finalized, agent_id")
6698
+ .eq("id", params.office_id)
6699
+ .eq("user_id", userId)
6700
+ .eq("agent_id", agentId)
6701
+ .maybeSingle();
6702
+ if (loadErr)
6703
+ return err(loadErr.message);
6704
+ if (!office)
6705
+ return err("Office not found or not owned by this agent");
6706
+ const layout = office.layout || { version: 1, sections: [] };
6707
+ const sections = Array.isArray(layout.sections) ? [...layout.sections] : [];
6708
+ const warnings = [];
6709
+ const newSeedDataRows = [];
6710
+ let applied = 0;
6711
+ let skipped = 0;
6712
+ for (let i = 0; i < operations.length; i++) {
6713
+ const op = operations[i];
6714
+ const path = op?.path || "";
6715
+ const opType = op?.op || "";
6716
+ const m = path.match(/^\/sections\/(\d+)(?:\/widgets\/(-|\d+))?(?:\/config)?$/);
6717
+ if (!m) {
6718
+ warnings.push(`op[${i}]: unsupported path "${path}"`);
6719
+ skipped++;
6720
+ continue;
6721
+ }
6722
+ const sIdx = parseInt(m[1], 10);
6723
+ const wTok = m[2];
6724
+ const isConfig = path.endsWith("/config");
6725
+ if (Number.isNaN(sIdx) || sIdx < 0 || sIdx >= sections.length) {
6726
+ warnings.push(`op[${i}]: section index ${sIdx} out of range`);
6727
+ skipped++;
6728
+ continue;
6729
+ }
6730
+ const section = sections[sIdx];
6731
+ if (section.locked) {
6732
+ warnings.push(`op[${i}]: section "${section.title}" is locked`);
6733
+ skipped++;
6734
+ continue;
6735
+ }
6736
+ if (wTok === undefined) {
6737
+ if (opType === "remove") {
6738
+ sections.splice(sIdx, 1);
6739
+ applied++;
6740
+ continue;
6741
+ }
6742
+ if (opType === "replace" && op.value) {
6743
+ const sErr = validateSection(op.value);
6744
+ if (sErr) {
6745
+ warnings.push(`op[${i}]: ${sErr}`);
6746
+ skipped++;
6747
+ continue;
6748
+ }
6749
+ sections[sIdx] = { ...op.value, id: section.id };
6750
+ applied++;
6751
+ continue;
6752
+ }
6753
+ warnings.push(`op[${i}]: section-level op "${opType}" not supported (use replace/remove)`);
6754
+ skipped++;
6755
+ continue;
6756
+ }
6757
+ if (wTok === "-") {
6758
+ if (opType !== "add") {
6759
+ warnings.push(`op[${i}]: path "/widgets/-" requires op=add`);
6760
+ skipped++;
6761
+ continue;
6762
+ }
6763
+ const w = op.widget || op.value;
6764
+ const wErr = validateWidget(w);
6765
+ if (wErr) {
6766
+ warnings.push(`op[${i}]: ${wErr}`);
6767
+ skipped++;
6768
+ continue;
6769
+ }
6770
+ const newW = {
6771
+ id: typeof w.id === "string" && w.id ? w.id : shortId("wgt"),
6772
+ type: w.type,
6773
+ config: w.config || {},
6774
+ layout: { ...w.layout },
6775
+ locked: w.locked === true,
6776
+ data_source: w.data_source || { type: "agent", refresh_interval: 3600, last_refreshed_at: null },
6777
+ };
6778
+ section.widgets = [...(section.widgets || []), newW];
6779
+ newSeedDataRows.push({ widget_id: newW.id });
6780
+ applied++;
6781
+ continue;
6782
+ }
6783
+ const wIdx = parseInt(wTok, 10);
6784
+ if (Number.isNaN(wIdx) || wIdx < 0 || wIdx >= (section.widgets || []).length) {
6785
+ warnings.push(`op[${i}]: widget index ${wIdx} out of range`);
6786
+ skipped++;
6787
+ continue;
6788
+ }
6789
+ const widget = section.widgets[wIdx];
6790
+ if (widget.locked) {
6791
+ warnings.push(`op[${i}]: widget "${widget.id}" is locked`);
6792
+ skipped++;
6793
+ continue;
6794
+ }
6795
+ if (opType === "remove") {
6796
+ section.widgets.splice(wIdx, 1);
6797
+ applied++;
6798
+ }
6799
+ else if (opType === "replace" && op.value) {
6800
+ const wErr = validateWidget(op.value);
6801
+ if (wErr) {
6802
+ warnings.push(`op[${i}]: ${wErr}`);
6803
+ skipped++;
6804
+ continue;
6805
+ }
6806
+ section.widgets[wIdx] = { ...op.value, id: widget.id };
6807
+ applied++;
6808
+ }
6809
+ else if ((opType === "patch" || (opType === "replace" && isConfig)) && op.config) {
6810
+ section.widgets[wIdx] = { ...widget, config: { ...widget.config, ...op.config } };
6811
+ applied++;
6812
+ }
6813
+ else {
6814
+ warnings.push(`op[${i}]: widget-level op "${opType}" not handled`);
6815
+ skipped++;
6816
+ }
6817
+ }
6818
+ const newLayout = { ...layout, sections };
6819
+ const { error: saveErr } = await supabase
6820
+ .from("agent_office_layouts")
6821
+ .update({
6822
+ layout: newLayout,
6823
+ last_agent_update_at: new Date().toISOString(),
6824
+ updated_at: new Date().toISOString(),
6825
+ })
6826
+ .eq("id", office.id)
6827
+ .eq("user_id", userId);
6828
+ if (saveErr)
6829
+ return err(`Office save failed: ${saveErr.message}`);
6830
+ if (newSeedDataRows.length > 0) {
6831
+ const seedRows = newSeedDataRows.map((r) => ({
6832
+ user_id: userId,
6833
+ office_id: office.id,
6834
+ widget_id: r.widget_id,
6835
+ data: {},
6836
+ }));
6837
+ await supabase
6838
+ .from("agent_office_data")
6839
+ .upsert(seedRows, { onConflict: "office_id,widget_id" });
6840
+ }
6841
+ await emitOfficeWebhook(api, "office_update", {
6842
+ user_id: userId,
6843
+ office_id: office.id,
6844
+ agent_id: agentId,
6845
+ });
6846
+ return ok({ applied, skipped, warnings });
6847
+ }
6848
+ case "update_widget_data": {
6849
+ if (!params.office_id)
6850
+ return err("Missing required: office_id");
6851
+ if (!params.widget_id)
6852
+ return err("Missing required: widget_id");
6853
+ if (typeof params.data !== "object" || params.data === null)
6854
+ return err("Missing required: data (object)");
6855
+ const agentId = await resolveCallingAgent(params.agent_id);
6856
+ if (!agentId)
6857
+ return err("Could not resolve agent_id");
6858
+ const { data: office, error: loadErr } = await supabase
6859
+ .from("agent_office_layouts")
6860
+ .select("id")
6861
+ .eq("id", params.office_id)
6862
+ .eq("user_id", userId)
6863
+ .eq("agent_id", agentId)
6864
+ .maybeSingle();
6865
+ if (loadErr)
6866
+ return err(loadErr.message);
6867
+ if (!office)
6868
+ return err("Office not found or not owned by this agent");
6869
+ const { error: upsertErr } = await supabase
6870
+ .from("agent_office_data")
6871
+ .upsert({
6872
+ user_id: userId,
6873
+ office_id: office.id,
6874
+ widget_id: params.widget_id,
6875
+ data: params.data,
6876
+ updated_at: new Date().toISOString(),
6877
+ }, { onConflict: "office_id,widget_id" });
6878
+ if (upsertErr)
6879
+ return err(`Widget data save failed: ${upsertErr.message}`);
6880
+ await emitOfficeWebhook(api, "office_data_update", {
6881
+ user_id: userId,
6882
+ office_id: office.id,
6883
+ agent_id: agentId,
6884
+ widget_id: params.widget_id,
6885
+ });
6886
+ return ok({ message: `Widget ${params.widget_id} data updated` });
6887
+ }
6888
+ default:
6889
+ return err(`Unknown action: ${action}. Use get_office, build_office, update_widgets, update_widget_data.`);
6890
+ }
6891
+ },
6892
+ }));
6893
+ }
6894
+ // ═══════════════════════════════════════════════════════════════════════════════
6392
6895
  // Public: Register All Meta-Tools
6393
6896
  // ═══════════════════════════════════════════════════════════════════════════════
6394
6897
  // This is the single entry point called by index.ts.
@@ -6415,6 +6918,7 @@ supabase, config) {
6415
6918
  registerBrainOps(api, supabase, userId, resolveAgent); // 14
6416
6919
  registerTalentOps(api, supabase, userId); // 15
6417
6920
  registerFrameworkOps(api, supabase, userId, resolveAgent); // 16
6921
+ registerOfficeOps(api, supabase, userId, resolveAgent); // 17
6418
6922
  // ── Register dynamic brain context hook ──
6419
6923
  registerBrainContextHook(api, supabase, userId, fallbackAgentId);
6420
6924
  // ── Register talent context hook ──
@@ -6424,7 +6928,7 @@ supabase, config) {
6424
6928
  // ── Register agent_end hook for server-side brain extraction ──
6425
6929
  registerBrainExtractionHook(api, supabase, userId, fallbackAgentId);
6426
6930
  // ── Count and log ──
6427
- const toolCount = 16;
6931
+ const toolCount = 17;
6428
6932
  const callerName = getCallingAgentName(api);
6429
6933
  const agentLabel = fallbackAgentId || callerName || "auto-detect";
6430
6934
  api.logger.info(`[ofiere] ${toolCount} meta-tools registered (agent: ${agentLabel})`);
@@ -6590,6 +7094,52 @@ function registerBrainExtractionHook(api, supabase, userId, fallbackAgentId) {
6590
7094
  catch (logErr) {
6591
7095
  api.logger.debug?.(`[ofiere-staff-report] dispatch_log update failed: ${logErr instanceof Error ? logErr.message : String(logErr)}`);
6592
7096
  }
7097
+ // Cycle 13 (BUG 2): flip the tasks row so the dashboard reflects
7098
+ // execution outcome. Without this, tasks stay at PENDING/IN_PROGRESS
7099
+ // forever and the proof-of-execution string lives only in
7100
+ // task_dispatch_log.response_preview (invisible to PM views).
7101
+ // Recurring tasks: stash output but leave status untouched so the
7102
+ // next cron tick can re-fire. One-shot: mark DONE, set progress=100,
7103
+ // stamp completed_at, persist proof in custom_fields.last_output.
7104
+ try {
7105
+ const { data: schedRow } = await supabase
7106
+ .from("scheduler_events")
7107
+ .select("recurrence_type")
7108
+ .eq("task_id", taskId)
7109
+ .maybeSingle();
7110
+ const isRecurring = !!(schedRow?.recurrence_type && schedRow.recurrence_type !== "none");
7111
+ const { data: curTask } = await supabase
7112
+ .from("tasks")
7113
+ .select("custom_fields, status")
7114
+ .eq("id", taskId)
7115
+ .eq("user_id", userId)
7116
+ .maybeSingle();
7117
+ const cf = (curTask?.custom_fields ?? {});
7118
+ cf.last_output = excerpt;
7119
+ cf.last_completed_session = sessionKey ?? null;
7120
+ cf.last_run_at = new Date().toISOString();
7121
+ if (isRecurring) {
7122
+ await supabase.from("tasks")
7123
+ .update({ custom_fields: cf, updated_at: new Date().toISOString() })
7124
+ .eq("id", taskId)
7125
+ .eq("user_id", userId);
7126
+ }
7127
+ else if (curTask?.status !== "FAILED") {
7128
+ await supabase.from("tasks")
7129
+ .update({
7130
+ status: "DONE",
7131
+ completed_at: new Date().toISOString(),
7132
+ progress: 100,
7133
+ custom_fields: cf,
7134
+ updated_at: new Date().toISOString(),
7135
+ })
7136
+ .eq("id", taskId)
7137
+ .eq("user_id", userId);
7138
+ }
7139
+ }
7140
+ catch (tErr) {
7141
+ api.logger.debug?.(`[ofiere-staff-report] tasks update failed: ${tErr instanceof Error ? tErr.message : String(tErr)}`);
7142
+ }
6593
7143
  }
6594
7144
  // BUG 6 fix (BUGSHOOT-2): mirror the assistant turn into the
6595
7145
  // dashboard's hidden conversation_messages so the dashboard can
@@ -6673,6 +7223,50 @@ function registerBrainExtractionHook(api, supabase, userId, fallbackAgentId) {
6673
7223
  catch (logErr) {
6674
7224
  api.logger.debug?.(`[ofiere-brain] dispatch_log update failed: ${logErr instanceof Error ? logErr.message : String(logErr)}`);
6675
7225
  }
7226
+ // Cycle 13 (BUG 2): flip the tasks row for chief-direct (non-staff)
7227
+ // dispatches too — same logic as the staff path above. Recurring
7228
+ // tasks keep their status; one-shots flip to DONE with the proof
7229
+ // string stored in custom_fields.last_output.
7230
+ try {
7231
+ const excerpt = lastAssistant.slice(0, 1500);
7232
+ const { data: schedRow } = await supabase
7233
+ .from("scheduler_events")
7234
+ .select("recurrence_type")
7235
+ .eq("task_id", dispatchTaskIdFromCtx)
7236
+ .maybeSingle();
7237
+ const isRecurring = !!(schedRow?.recurrence_type && schedRow.recurrence_type !== "none");
7238
+ const { data: curTask } = await supabase
7239
+ .from("tasks")
7240
+ .select("custom_fields, status")
7241
+ .eq("id", dispatchTaskIdFromCtx)
7242
+ .eq("user_id", userId)
7243
+ .maybeSingle();
7244
+ const cf = (curTask?.custom_fields ?? {});
7245
+ cf.last_output = excerpt;
7246
+ cf.last_completed_session = sessionKey ?? null;
7247
+ cf.last_run_at = new Date().toISOString();
7248
+ if (isRecurring) {
7249
+ await supabase.from("tasks")
7250
+ .update({ custom_fields: cf, updated_at: new Date().toISOString() })
7251
+ .eq("id", dispatchTaskIdFromCtx)
7252
+ .eq("user_id", userId);
7253
+ }
7254
+ else if (curTask?.status !== "FAILED") {
7255
+ await supabase.from("tasks")
7256
+ .update({
7257
+ status: "DONE",
7258
+ completed_at: new Date().toISOString(),
7259
+ progress: 100,
7260
+ custom_fields: cf,
7261
+ updated_at: new Date().toISOString(),
7262
+ })
7263
+ .eq("id", dispatchTaskIdFromCtx)
7264
+ .eq("user_id", userId);
7265
+ }
7266
+ }
7267
+ catch (tErr) {
7268
+ api.logger.debug?.(`[ofiere-brain] tasks update failed: ${tErr instanceof Error ? tErr.message : String(tErr)}`);
7269
+ }
6676
7270
  }
6677
7271
  // Cycle 7b BUGSHOOT-4 — trivial-skip moved here from line ~6593.
6678
7272
  // Brain L1/L2/L3/L4 extraction skips trivial chit-chat to reduce