ofiere-openclaw-plugin 4.55.0 → 4.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,104 +1,104 @@
1
- # Ofiere PM Plugin for OpenClaw
2
-
3
- Manage your Ofiere PM dashboard directly from OpenClaw agents. Create tasks, manage projects, build workflows, store knowledge — all synced to the dashboard in real time.
4
-
5
- ## Quick Install (One-Click)
6
-
7
- ```bash
8
- curl -sSL https://ofiere.com/scripts/install.sh | bash -s -- \
9
- --supabase-url "https://xxx.supabase.co" \
10
- --service-key "eyJ..." \
11
- --user-id "your-uuid"
12
- ```
13
-
14
- Only 3 parameters needed. All agents get the plugin automatically.
15
-
16
- ## Uninstall
17
-
18
- ```bash
19
- curl -sSL https://ofiere.com/scripts/uninstall.sh | bash
20
- ```
21
-
22
- ## How It Works
23
-
24
- Once configured, the plugin connects to your Supabase database at gateway startup and registers 9 PM meta-tools directly into the agent. There's no separate MCP server process — it runs inside the OpenClaw gateway for maximum reliability.
25
-
26
- Changes made by the agent are immediately visible on the Ofiere dashboard (Vercel) via Supabase real-time subscriptions.
27
-
28
- ## AI Meta-Tools
29
-
30
- The plugin uses a scalable meta-tool architecture. Each tool handles one domain with an `action` parameter to select the operation.
31
-
32
- | Tool | Actions | Description |
33
- |---|---|---|
34
- | `OFIERE_TASK_OPS` | `list`, `create`, `update`, `delete` | Rich task management with execution plans, goals, constraints |
35
- | `OFIERE_AGENT_OPS` | `list` | Query available agents for task assignment |
36
- | `OFIERE_PROJECT_OPS` | `list_spaces`, `create_space`, `create_folder`, `bulk_create_tasks`, etc. | Full project hierarchy: spaces → folders → tasks |
37
- | `OFIERE_SCHEDULE_OPS` | `list`, `create`, `update`, `delete` | Calendar events with recurrence |
38
- | `OFIERE_KNOWLEDGE_OPS` | `search`, `list`, `create`, `update`, `delete` | Knowledge library with full content retrieval |
39
- | `OFIERE_WORKFLOW_OPS` | `list`, `get`, `create`, `update`, `delete`, `list_runs`, `trigger` | Visual workflow builder with 16 node types |
40
- | `OFIERE_NOTIFY_OPS` | `list`, `mark_read`, `create` | In-app notifications |
41
- | `OFIERE_MEMORY_OPS` | `list_conversations`, `search_knowledge` | Conversation history & agent memory |
42
- | `OFIERE_PROMPT_OPS` | `list`, `get`, `create`, `update`, `delete` | System prompt chunk management |
43
-
44
- ### Example
45
-
46
- ```
47
- // Create a task with execution plan
48
- OFIERE_TASK_OPS({ action: "create", title: "Deploy v2", agent_id: "ivy",
49
- execution_plan: [{ step: 1, action: "Build", detail: "Run production build" }] })
50
-
51
- // Create a workflow with nodes
52
- OFIERE_WORKFLOW_OPS({ action: "create", name: "Deploy Pipeline",
53
- nodes: [
54
- { type: "agent_step", data: { label: "Build", task: "Run npm build" } },
55
- { type: "human_approval", data: { label: "Review", instructions: "Check build output" } },
56
- { type: "output", data: { label: "Done" } }
57
- ],
58
- edges: [
59
- { source: "agent_step-...", target: "human_approval-..." },
60
- { source: "human_approval-...", target: "output-..." }
61
- ]
62
- })
63
-
64
- // Search knowledge library
65
- OFIERE_KNOWLEDGE_OPS({ action: "search", query: "API rate limits" })
66
- ```
67
-
68
- ## CLI Commands
69
-
70
- ```bash
71
- openclaw ofiere setup # Configure Supabase connection and agent identity
72
- openclaw ofiere status # View current configuration
73
- openclaw ofiere doctor # Test connection and list agents
74
- ```
75
-
76
- ## Configuration
77
-
78
- Set via `openclaw ofiere setup` or environment variables:
79
-
80
- | Option | Env Var | Description |
81
- |---|---|---|
82
- | `supabaseUrl` | `OFIERE_SUPABASE_URL` | Supabase project URL |
83
- | `serviceRoleKey` | `OFIERE_SERVICE_ROLE_KEY` | Supabase service role key |
84
- | `userId` | `OFIERE_USER_ID` | Your user UUID |
85
- | `agentId` | `OFIERE_AGENT_ID` | This agent's ID (optional — auto-detected) |
86
- | `enabled` | — | Enable/disable the plugin (default: `true`) |
87
-
88
- ## Architecture
89
-
90
- ```
91
- OpenClaw Agent (VPS)
92
- │ plugin runs IN-PROCESS
93
- Ofiere Plugin ──► Supabase (shared database)
94
-
95
- Ofiere Dashboard ─────┘ (Vercel, real-time)
96
- ```
97
-
98
- Both the agent plugin and the Vercel dashboard talk to the same Supabase instance. When the agent creates/updates a task, the dashboard sees it instantly through Supabase real-time subscriptions.
99
-
100
- ## Links
101
-
102
- - [Ofiere Dashboard](https://github.com/gilanggemar/Ofiere)
103
- - [OpenClaw](https://openclaw.ai)
104
- - [Supabase](https://supabase.com)
1
+ # Ofiere PM Plugin for OpenClaw
2
+
3
+ Manage your Ofiere PM dashboard directly from OpenClaw agents. Create tasks, manage projects, build workflows, store knowledge — all synced to the dashboard in real time.
4
+
5
+ ## Quick Install (One-Click)
6
+
7
+ ```bash
8
+ curl -sSL https://ofiere.com/scripts/install.sh | bash -s -- \
9
+ --supabase-url "https://xxx.supabase.co" \
10
+ --service-key "eyJ..." \
11
+ --user-id "your-uuid"
12
+ ```
13
+
14
+ Only 3 parameters needed. All agents get the plugin automatically.
15
+
16
+ ## Uninstall
17
+
18
+ ```bash
19
+ curl -sSL https://ofiere.com/scripts/uninstall.sh | bash
20
+ ```
21
+
22
+ ## How It Works
23
+
24
+ Once configured, the plugin connects to your Supabase database at gateway startup and registers 9 PM meta-tools directly into the agent. There's no separate MCP server process — it runs inside the OpenClaw gateway for maximum reliability.
25
+
26
+ Changes made by the agent are immediately visible on the Ofiere dashboard (Vercel) via Supabase real-time subscriptions.
27
+
28
+ ## AI Meta-Tools
29
+
30
+ The plugin uses a scalable meta-tool architecture. Each tool handles one domain with an `action` parameter to select the operation.
31
+
32
+ | Tool | Actions | Description |
33
+ |---|---|---|
34
+ | `OFIERE_TASK_OPS` | `list`, `create`, `update`, `delete` | Rich task management with execution plans, goals, constraints |
35
+ | `OFIERE_AGENT_OPS` | `list` | Query available agents for task assignment |
36
+ | `OFIERE_PROJECT_OPS` | `list_spaces`, `create_space`, `create_folder`, `bulk_create_tasks`, etc. | Full project hierarchy: spaces → folders → tasks |
37
+ | `OFIERE_SCHEDULE_OPS` | `list`, `create`, `update`, `delete` | Calendar events with recurrence |
38
+ | `OFIERE_KNOWLEDGE_OPS` | `search`, `list`, `create`, `update`, `delete` | Knowledge library with full content retrieval |
39
+ | `OFIERE_WORKFLOW_OPS` | `list`, `get`, `create`, `update`, `delete`, `list_runs`, `trigger` | Visual workflow builder with 16 node types |
40
+ | `OFIERE_NOTIFY_OPS` | `list`, `mark_read`, `create` | In-app notifications |
41
+ | `OFIERE_MEMORY_OPS` | `list_conversations`, `search_knowledge` | Conversation history & agent memory |
42
+ | `OFIERE_PROMPT_OPS` | `list`, `get`, `create`, `update`, `delete` | System prompt chunk management |
43
+
44
+ ### Example
45
+
46
+ ```
47
+ // Create a task with execution plan
48
+ OFIERE_TASK_OPS({ action: "create", title: "Deploy v2", agent_id: "ivy",
49
+ execution_plan: [{ step: 1, action: "Build", detail: "Run production build" }] })
50
+
51
+ // Create a workflow with nodes
52
+ OFIERE_WORKFLOW_OPS({ action: "create", name: "Deploy Pipeline",
53
+ nodes: [
54
+ { type: "agent_step", data: { label: "Build", task: "Run npm build" } },
55
+ { type: "human_approval", data: { label: "Review", instructions: "Check build output" } },
56
+ { type: "output", data: { label: "Done" } }
57
+ ],
58
+ edges: [
59
+ { source: "agent_step-...", target: "human_approval-..." },
60
+ { source: "human_approval-...", target: "output-..." }
61
+ ]
62
+ })
63
+
64
+ // Search knowledge library
65
+ OFIERE_KNOWLEDGE_OPS({ action: "search", query: "API rate limits" })
66
+ ```
67
+
68
+ ## CLI Commands
69
+
70
+ ```bash
71
+ openclaw ofiere setup # Configure Supabase connection and agent identity
72
+ openclaw ofiere status # View current configuration
73
+ openclaw ofiere doctor # Test connection and list agents
74
+ ```
75
+
76
+ ## Configuration
77
+
78
+ Set via `openclaw ofiere setup` or environment variables:
79
+
80
+ | Option | Env Var | Description |
81
+ |---|---|---|
82
+ | `supabaseUrl` | `OFIERE_SUPABASE_URL` | Supabase project URL |
83
+ | `serviceRoleKey` | `OFIERE_SERVICE_ROLE_KEY` | Supabase service role key |
84
+ | `userId` | `OFIERE_USER_ID` | Your user UUID |
85
+ | `agentId` | `OFIERE_AGENT_ID` | This agent's ID (optional — auto-detected) |
86
+ | `enabled` | — | Enable/disable the plugin (default: `true`) |
87
+
88
+ ## Architecture
89
+
90
+ ```
91
+ OpenClaw Agent (VPS)
92
+ │ plugin runs IN-PROCESS
93
+ Ofiere Plugin ──► Supabase (shared database)
94
+
95
+ Ofiere Dashboard ─────┘ (Vercel, real-time)
96
+ ```
97
+
98
+ Both the agent plugin and the Vercel dashboard talk to the same Supabase instance. When the agent creates/updates a task, the dashboard sees it instantly through Supabase real-time subscriptions.
99
+
100
+ ## Links
101
+
102
+ - [Ofiere Dashboard](https://github.com/gilanggemar/Ofiere)
103
+ - [OpenClaw](https://openclaw.ai)
104
+ - [Supabase](https://supabase.com)
@@ -0,0 +1,169 @@
1
+ // src/gateOps.ts — OFIERE_GATE_OPS meta-tool
2
+ // Approve / reject manual gates blocking task dispatch in the Planning Tab.
3
+ //
4
+ // Phase 0 finding: plugin factory carries no implicit agentId in closure.
5
+ // The CALLING chief / staff agent MUST pass `agent_id` explicitly for approve/reject
6
+ // so the audit trail records "agent:<id>" in approved_by / rejected_by.
7
+ // Fallback order: args.agent_id → fallbackAgentId (gateway OFIERE_AGENT_ID env) → "user:<userId>".
8
+ function ok(data) {
9
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
10
+ }
11
+ function err(message) {
12
+ return { content: [{ type: "text", text: `Error: ${message}` }] };
13
+ }
14
+ // Bounded BFS — collect transitive successors so the agent sees cascade impact
15
+ // before rejecting. Mirrors dashboard/lib/ofie/tool-executor.ts _collectTransitiveSuccessors.
16
+ async function collectTransitiveSuccessors(supabase, userId, rootTaskId) {
17
+ const visited = new Set([rootTaskId]);
18
+ const out = [];
19
+ let frontier = [rootTaskId];
20
+ for (let depth = 0; depth < 20 && frontier.length > 0; depth++) {
21
+ const { data: deps } = await supabase
22
+ .from("pm_dependencies")
23
+ .select("successor_id")
24
+ .in("predecessor_id", frontier)
25
+ .eq("user_id", userId);
26
+ const next = [];
27
+ for (const d of (deps || [])) {
28
+ const sid = d.successor_id;
29
+ if (!sid || visited.has(sid))
30
+ continue;
31
+ visited.add(sid);
32
+ next.push(sid);
33
+ }
34
+ if (next.length === 0)
35
+ break;
36
+ const { data: tasks } = await supabase
37
+ .from("tasks")
38
+ .select("id, title")
39
+ .in("id", next)
40
+ .eq("user_id", userId);
41
+ for (const t of (tasks || []))
42
+ out.push({ id: t.id, title: t.title });
43
+ frontier = next;
44
+ }
45
+ return out;
46
+ }
47
+ export function registerGateOps(api, supabase, userId, fallbackAgentId) {
48
+ api.registerTool({
49
+ name: "OFIERE_GATE_OPS",
50
+ label: "Ofiere Gate Operations",
51
+ description: `Approve or reject manual gates blocking task dispatch in the Planning Tab.\n\n` +
52
+ `Actions:\n` +
53
+ `- "list_pending": List manual gates pending approval. Optional: agent_id (filter to gates whose blocked task is assigned to this agent).\n` +
54
+ `- "get": Inspect a specific gate + see the cascade impact of rejecting. Required: gate_id. ALWAYS call before "reject" so you understand what will fail.\n` +
55
+ `- "approve": Approve a gate so the blocked task can dispatch. Required: gate_id, agent_id (the calling chief/staff). Optional: comment.\n` +
56
+ `- "reject": Reject a gate — FAILS the blocked task and BLOCKS all transitive successors (cascade). Required: gate_id, agent_id, reason (min 5 chars).\n\n` +
57
+ `Why agent_id is required: plugin runtime has no implicit agent identity. The agent invoking this tool MUST pass its own id so approval audit trail records 'agent:<id>' instead of 'user:<owner_uuid>'. Falls back to gateway OFIERE_AGENT_ID env when absent.`,
58
+ parameters: {
59
+ type: "object",
60
+ required: ["action"],
61
+ properties: {
62
+ action: { type: "string", enum: ["list_pending", "get", "approve", "reject"], description: "list_pending | get | approve | reject" },
63
+ gate_id: { type: "string", description: "Gate UUID (required for get, approve, reject)" },
64
+ agent_id: { type: "string", description: "Calling agent id (required for approve, reject — recorded as 'agent:<id>' in approved_by / rejected_by). Also optional filter for list_pending." },
65
+ comment: { type: "string", description: "Optional approval comment" },
66
+ reason: { type: "string", description: "Rejection reason (required for reject, min 5 chars)" },
67
+ },
68
+ },
69
+ async execute(_id, params) {
70
+ try {
71
+ const action = params.action;
72
+ const resolveActor = () => {
73
+ const id = params.agent_id || fallbackAgentId;
74
+ return id ? `agent:${id}` : `user:${userId}`;
75
+ };
76
+ switch (action) {
77
+ case "list_pending": {
78
+ let q = supabase
79
+ .from("pm_gates")
80
+ .select("id, plan_id, plan_node_id, gate_label, task_id_blocked, created_at, tasks!inner(title, agent_id)")
81
+ .eq("user_id", userId)
82
+ .is("approved_at", null)
83
+ .is("rejected_at", null)
84
+ .order("created_at", { ascending: true });
85
+ if (params.agent_id)
86
+ q = q.eq("tasks.agent_id", params.agent_id);
87
+ const { data, error } = await q;
88
+ if (error)
89
+ return err(error.message);
90
+ return ok({ pending: data || [], count: (data || []).length });
91
+ }
92
+ case "get": {
93
+ if (!params.gate_id)
94
+ return err("Missing required: gate_id");
95
+ const { data: gate, error } = await supabase
96
+ .from("pm_gates")
97
+ .select("*, tasks!inner(id, title, agent_id, status)")
98
+ .eq("id", params.gate_id)
99
+ .eq("user_id", userId)
100
+ .maybeSingle();
101
+ if (error)
102
+ return err(error.message);
103
+ if (!gate)
104
+ return err("Gate not found");
105
+ const blockedTaskId = gate.task_id_blocked;
106
+ const downstream = await collectTransitiveSuccessors(supabase, userId, blockedTaskId);
107
+ const blockedTitle = gate.tasks?.title || "blocked task";
108
+ return ok({
109
+ gate,
110
+ impact_on_reject: {
111
+ failed_task: { id: blockedTaskId, title: blockedTitle },
112
+ blocked_downstream: downstream,
113
+ summary: `Rejecting will FAIL '${blockedTitle}' and BLOCK ${downstream.length} downstream task${downstream.length === 1 ? "" : "s"}.`,
114
+ },
115
+ });
116
+ }
117
+ case "approve": {
118
+ if (!params.gate_id)
119
+ return err("Missing required: gate_id");
120
+ if (!params.agent_id && !fallbackAgentId)
121
+ return err("Missing required: agent_id (calling agent identity — plugin has no implicit agent context)");
122
+ const actor = resolveActor();
123
+ const { data, error } = await supabase
124
+ .from("pm_gates")
125
+ .update({
126
+ approved_at: new Date().toISOString(),
127
+ approved_by: actor,
128
+ approval_comment: params.comment ?? null,
129
+ })
130
+ .eq("id", params.gate_id)
131
+ .eq("user_id", userId)
132
+ .is("approved_at", null)
133
+ .is("rejected_at", null)
134
+ .select("id");
135
+ if (error)
136
+ return err(error.message);
137
+ if (!data || data.length === 0)
138
+ return err("Gate not found or already resolved");
139
+ return ok({ approved: true, gate_id: params.gate_id, approved_by: actor });
140
+ }
141
+ case "reject": {
142
+ if (!params.gate_id)
143
+ return err("Missing required: gate_id");
144
+ if (!params.agent_id && !fallbackAgentId)
145
+ return err("Missing required: agent_id (calling agent identity — plugin has no implicit agent context)");
146
+ const reason = (params.reason || "").trim();
147
+ if (reason.length < 5)
148
+ return err("Missing required: reason (min 5 chars) — rejection requires explanation");
149
+ const actor = resolveActor();
150
+ const { data, error } = await supabase.rpc("cascade_reject_gate", {
151
+ p_gate_id: params.gate_id,
152
+ p_user_id: userId,
153
+ p_reason: reason,
154
+ p_actor: actor,
155
+ });
156
+ if (error)
157
+ return err(error.message);
158
+ return ok({ rejected: true, gate_id: params.gate_id, rejected_by: actor, result: data });
159
+ }
160
+ default:
161
+ return err(`Unknown gate action: ${action}`);
162
+ }
163
+ }
164
+ catch (e) {
165
+ return err(e instanceof Error ? e.message : String(e));
166
+ }
167
+ },
168
+ });
169
+ }
@@ -0,0 +1,145 @@
1
+ // MIRROR — sync manually with dashboard/lib/pm/planExecute.ts
2
+ // Plugin is a separate npm package and cannot import from dashboard/.
3
+ // If you edit either file, update both. Helper algorithm must stay byte-identical
4
+ // across runtimes so pm_dependencies + pm_gates rows have the same shape regardless
5
+ // of whether the plan was executed via dashboard / native ofiere_plan_ops / plugin OFIERE_PLAN_OPS.
6
+ /**
7
+ * Walk plan tree → emit concrete predecessor edges + manual-gate rows.
8
+ *
9
+ * Predecessor resolution (per task/milestone node, walking UP from succ):
10
+ * - Root + idx=0 → no predecessor
11
+ * - Root + idx>0 → leaves of rootSibling[idx-1]
12
+ * - Parent.parallel = true + parent is task → predecessor = parent
13
+ * - Parent.parallel = true + parent is gate → recurse upward through gate
14
+ * - Non-parallel parent + idx>0 → leaves of parent.children[idx-1]
15
+ * - Non-parallel parent + idx=0 → parent (recurse if gate)
16
+ *
17
+ * Group-id rule: if any gate on the traversal path has
18
+ * `gateCondition='any_predecessor_complete'`, all resolved sources for this
19
+ * successor share one UUID-flavored group_id (dispatcher treats as OR-join).
20
+ *
21
+ * Manual-gate emission: for each task/milestone, walk UP through `parentOf`
22
+ * chain and emit one pm_gates row for every `manual` gate ancestor. Nested
23
+ * manual gates produce N rows; dispatcher AND-joins them.
24
+ */
25
+ export function expandPlanToDependencyEdges(rootNodes, idMap) {
26
+ const edges = [];
27
+ const gates = [];
28
+ const emptyGates = [];
29
+ const seenEdgeKey = new Set();
30
+ const seenGateKey = new Set();
31
+ const parentOf = new Map();
32
+ const indexInParent = new Map();
33
+ const allNodes = [];
34
+ function indexTree(nodes, parent) {
35
+ nodes.forEach((n, i) => {
36
+ parentOf.set(n.id, parent);
37
+ indexInParent.set(n.id, i);
38
+ allNodes.push(n);
39
+ if (Array.isArray(n.children) && n.children.length > 0)
40
+ indexTree(n.children, n);
41
+ });
42
+ }
43
+ indexTree(rootNodes, null);
44
+ // Resolve a node (possibly a gate) to its task-leaf set, tracking gates traversed.
45
+ function leavesOf(n, gatesAbove) {
46
+ if (n.type === 'task' || n.type === 'milestone') {
47
+ const tid = idMap.get(n.id);
48
+ return tid ? [{ taskId: tid, gateChain: gatesAbove }] : [];
49
+ }
50
+ if (n.type === 'gate') {
51
+ const nextGates = [...gatesAbove, n];
52
+ const out = [];
53
+ for (const c of (n.children || []))
54
+ out.push(...leavesOf(c, nextGates));
55
+ if (out.length === 0)
56
+ emptyGates.push(n.id);
57
+ return out;
58
+ }
59
+ return [];
60
+ }
61
+ // Walk UP from succ to find predecessor leaves + whether any gate on the path is OR-join.
62
+ function predecessorsFor(succ) {
63
+ let orJoin = false;
64
+ let cursor = succ;
65
+ while (cursor) {
66
+ const parent = parentOf.get(cursor.id) ?? null;
67
+ const idx = indexInParent.get(cursor.id) ?? 0;
68
+ if (!parent) {
69
+ // root-level sibling chain
70
+ if (idx === 0)
71
+ return { sources: [], orJoin };
72
+ return { sources: leavesOf(rootNodes[idx - 1], []), orJoin };
73
+ }
74
+ // OR-detection happens whenever we traverse into a gate ancestor
75
+ if (parent.type === 'gate' && parent.gateCondition === 'any_predecessor_complete') {
76
+ orJoin = true;
77
+ }
78
+ if (parent.parallel) {
79
+ // parallel fan-out: predecessor IS the parent
80
+ if (parent.type === 'gate') {
81
+ cursor = parent;
82
+ continue;
83
+ }
84
+ const pid = idMap.get(parent.id);
85
+ return pid ? { sources: [{ taskId: pid, gateChain: [] }], orJoin } : { sources: [], orJoin };
86
+ }
87
+ if (idx > 0) {
88
+ // non-parallel: pred is the previous sibling (may itself be a gate)
89
+ return { sources: leavesOf(parent.children[idx - 1], []), orJoin };
90
+ }
91
+ // first child of non-parallel parent → pred is the parent (recurse if gate)
92
+ if (parent.type === 'gate') {
93
+ cursor = parent;
94
+ continue;
95
+ }
96
+ const pid = idMap.get(parent.id);
97
+ return pid ? { sources: [{ taskId: pid, gateChain: [] }], orJoin } : { sources: [], orJoin };
98
+ }
99
+ return { sources: [], orJoin };
100
+ }
101
+ for (const node of allNodes) {
102
+ if (node.type !== 'task' && node.type !== 'milestone')
103
+ continue;
104
+ const succTaskId = idMap.get(node.id);
105
+ if (!succTaskId)
106
+ continue;
107
+ const { sources, orJoin: orJoinFromWalk } = predecessorsFor(node);
108
+ // OR-join also fires if the resolved leaves passed THROUGH an OR gate on their way up
109
+ // (e.g., succ is a root-sibling downstream of an OR gate — leavesOf walks INTO the gate).
110
+ const orJoinFromLeaves = sources.some(s => s.gateChain.some(g => g.type === 'gate' && g.gateCondition === 'any_predecessor_complete'));
111
+ const orJoin = orJoinFromWalk || orJoinFromLeaves;
112
+ const groupId = (orJoin && sources.length > 1) ? `grp-${succTaskId}-${node.id.slice(-6)}` : null;
113
+ for (const s of sources) {
114
+ const key = `${s.taskId}->${succTaskId}|${groupId ?? ''}`;
115
+ if (seenEdgeKey.has(key))
116
+ continue;
117
+ seenEdgeKey.add(key);
118
+ edges.push({
119
+ predTaskId: s.taskId,
120
+ succTaskId,
121
+ groupId,
122
+ lagDays: 0,
123
+ dependencyType: 'finish_to_start',
124
+ });
125
+ }
126
+ // Manual-gate rows: walk UP through every ancestor gate; emit one row per manual gate.
127
+ let cur = parentOf.get(node.id) || null;
128
+ while (cur) {
129
+ if (cur.type === 'gate' && cur.gateCondition === 'manual') {
130
+ const k = `${cur.id}::${succTaskId}`;
131
+ if (!seenGateKey.has(k)) {
132
+ seenGateKey.add(k);
133
+ gates.push({
134
+ planNodeId: cur.id,
135
+ gateLabel: cur.title || 'Manual gate',
136
+ gateCondition: 'manual',
137
+ taskIdBlocked: succTaskId,
138
+ });
139
+ }
140
+ }
141
+ cur = parentOf.get(cur.id) || null;
142
+ }
143
+ }
144
+ return { edges, gates, emptyGates };
145
+ }