ofiere-openclaw-plugin 4.37.2 → 4.38.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.37.2",
3
+ "version": "4.38.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"],
@@ -0,0 +1,144 @@
1
+ // src/agent-tier.ts — Plugin-side agent tier resolver.
2
+ // Manual override (agent_tier_overrides) wins over auto-detection.
3
+ // Mirrors dashboard/lib/agentTier.ts contracts so the dashboard UI
4
+ // and plugin enforcement agree on every agent's tier.
5
+
6
+ import type { SupabaseClient } from "@supabase/supabase-js";
7
+
8
+ export type AgentTier = "c-suite" | "staff" | null;
9
+ export type TierSource =
10
+ | "manual"
11
+ | "executiveRole"
12
+ | "roster"
13
+ | "subagent"
14
+ | "departmentRole"
15
+ | "none";
16
+
17
+ export interface TierResolution {
18
+ tier: AgentTier;
19
+ source: TierSource;
20
+ }
21
+
22
+ // Hard-coded executive roster ids — match dashboard/lib/agentRoster.ts.
23
+ // Anything in this set defaults to c-suite when no manual override exists.
24
+ const ROSTER_IDS = new Set<string>([
25
+ "ofie",
26
+ "daisy",
27
+ "ivy",
28
+ "celia",
29
+ "thalia",
30
+ "sasha",
31
+ "agent-zero",
32
+ ]);
33
+
34
+ interface CacheEntry { value: TierResolution; expires: number; }
35
+ const TTL_MS = 5 * 60 * 1000;
36
+ const cache = new Map<string, CacheEntry>();
37
+ const k = (userId: string, agentId: string) => `${userId}::${agentId}`;
38
+
39
+ export function invalidateAgentTier(userId: string, agentId?: string): void {
40
+ if (!agentId) {
41
+ for (const key of cache.keys()) if (key.startsWith(`${userId}::`)) cache.delete(key);
42
+ return;
43
+ }
44
+ cache.delete(k(userId, agentId));
45
+ }
46
+
47
+ export async function resolveAgentTier(
48
+ supabase: SupabaseClient,
49
+ agentId: string,
50
+ userId: string,
51
+ ): Promise<TierResolution> {
52
+ const id = (agentId || "").trim();
53
+ if (!id || !userId) return { tier: null, source: "none" };
54
+
55
+ const key = k(userId, id);
56
+ const hit = cache.get(key);
57
+ if (hit && hit.expires > Date.now()) return hit.value;
58
+
59
+ let result: TierResolution = { tier: null, source: "none" };
60
+
61
+ // 1. Manual override
62
+ try {
63
+ const { data } = await supabase
64
+ .from("agent_tier_overrides")
65
+ .select("tier")
66
+ .eq("user_id", userId)
67
+ .eq("agent_id", id)
68
+ .maybeSingle();
69
+ if (data?.tier === "c-suite" || data?.tier === "staff") {
70
+ result = { tier: data.tier, source: "manual" };
71
+ cache.set(key, { value: result, expires: Date.now() + TTL_MS });
72
+ return result;
73
+ }
74
+ } catch {
75
+ // Table missing in older installs — fall through
76
+ }
77
+
78
+ // 2. C-Suite: hardcoded roster
79
+ if (ROSTER_IDS.has(id)) {
80
+ result = { tier: "c-suite", source: "roster" };
81
+ cache.set(key, { value: result, expires: Date.now() + TTL_MS });
82
+ return result;
83
+ }
84
+
85
+ // 3. agent_architectures.executive_role / department_role
86
+ try {
87
+ const { data } = await supabase
88
+ .from("agent_architectures")
89
+ .select("executive_role, department_role")
90
+ .eq("user_id", userId)
91
+ .eq("agent_id", id)
92
+ .maybeSingle();
93
+ if (data?.department_role === "chief" ||
94
+ (data?.executive_role && String(data.executive_role).trim())) {
95
+ result = { tier: "c-suite", source: "executiveRole" };
96
+ cache.set(key, { value: result, expires: Date.now() + TTL_MS });
97
+ return result;
98
+ }
99
+ if (data?.department_role === "staff") {
100
+ result = { tier: "staff", source: "departmentRole" };
101
+ cache.set(key, { value: result, expires: Date.now() + TTL_MS });
102
+ return result;
103
+ }
104
+ } catch {
105
+ // Table missing — fall through
106
+ }
107
+
108
+ // 4. Staff: registered as subagent
109
+ try {
110
+ const { data } = await supabase
111
+ .from("agent_subagents")
112
+ .select("id")
113
+ .eq("user_id", userId)
114
+ .eq("id", id)
115
+ .maybeSingle();
116
+ if (data?.id) {
117
+ result = { tier: "staff", source: "subagent" };
118
+ cache.set(key, { value: result, expires: Date.now() + TTL_MS });
119
+ return result;
120
+ }
121
+ } catch {
122
+ // ignore
123
+ }
124
+
125
+ cache.set(key, { value: result, expires: Date.now() + TTL_MS });
126
+ return result;
127
+ }
128
+
129
+ export async function getAgentTier(
130
+ supabase: SupabaseClient,
131
+ agentId: string,
132
+ userId: string,
133
+ ): Promise<AgentTier> {
134
+ return (await resolveAgentTier(supabase, agentId, userId)).tier;
135
+ }
136
+
137
+ export function isDocKindValidForTier(
138
+ docKind: "sop" | "framework",
139
+ tier: AgentTier,
140
+ ): boolean {
141
+ if (tier === "c-suite") return docKind === "framework";
142
+ if (tier === "staff") return docKind === "sop";
143
+ return false;
144
+ }
@@ -0,0 +1,106 @@
1
+ // src/attach-token.ts — HMAC confirmation token for agent self-attach.
2
+ // Mirrors dashboard/lib/attachmentToken.ts EXACTLY so tokens issued
3
+ // here verify against the dashboard and vice-versa.
4
+ //
5
+ // Format: base64url(payload).base64url(sig)
6
+ // payload = JSON({ u, k, t, d, dk, exp })
7
+ // sig = HMAC_SHA256(secret, payload)
8
+
9
+ import { createHmac, timingSafeEqual } from "crypto";
10
+
11
+ const TOKEN_TTL_MS = 5 * 60 * 1000;
12
+
13
+ function getSecret(): string {
14
+ const s = process.env.OFIERE_ATTACH_SECRET;
15
+ if (!s) throw new Error("OFIERE_ATTACH_SECRET is not configured");
16
+ return s;
17
+ }
18
+
19
+ function b64urlEncode(buf: Buffer | string): string {
20
+ const b = typeof buf === "string" ? Buffer.from(buf, "utf8") : buf;
21
+ return b.toString("base64").replace(/=+$/g, "").replace(/\+/g, "-").replace(/\//g, "_");
22
+ }
23
+
24
+ function b64urlDecode(s: string): Buffer {
25
+ const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - (s.length % 4));
26
+ return Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64");
27
+ }
28
+
29
+ interface TokenPayload {
30
+ u: string;
31
+ k: string;
32
+ t: string;
33
+ d: string[];
34
+ dk: "sop" | "framework";
35
+ exp: number;
36
+ }
37
+
38
+ export interface IssueArgs {
39
+ userId: string;
40
+ targetKind: string;
41
+ targetId: string;
42
+ docKind: "sop" | "framework";
43
+ docIds: string[];
44
+ ttlMs?: number;
45
+ }
46
+
47
+ export function issueAttachmentToken(args: IssueArgs): string {
48
+ const exp = Date.now() + (args.ttlMs ?? TOKEN_TTL_MS);
49
+ const payload: TokenPayload = {
50
+ u: args.userId,
51
+ k: args.targetKind,
52
+ t: args.targetId,
53
+ d: [...args.docIds].sort(),
54
+ dk: args.docKind,
55
+ exp,
56
+ };
57
+ const json = JSON.stringify(payload);
58
+ const sig = createHmac("sha256", getSecret()).update(json).digest();
59
+ return `${b64urlEncode(json)}.${b64urlEncode(sig)}`;
60
+ }
61
+
62
+ export interface VerifyArgs {
63
+ token: string;
64
+ userId: string;
65
+ targetKind: string;
66
+ targetId: string;
67
+ docKind: "sop" | "framework";
68
+ docIds: string[];
69
+ }
70
+
71
+ export type VerifyResult =
72
+ | { ok: true }
73
+ | { ok: false; reason: "malformed" | "bad_signature" | "expired" | "mismatch" };
74
+
75
+ export function verifyAttachmentToken(args: VerifyArgs): VerifyResult {
76
+ const parts = args.token.split(".");
77
+ if (parts.length !== 2) return { ok: false, reason: "malformed" };
78
+ let payload: TokenPayload;
79
+ try {
80
+ payload = JSON.parse(b64urlDecode(parts[0]).toString("utf8"));
81
+ } catch {
82
+ return { ok: false, reason: "malformed" };
83
+ }
84
+
85
+ const expectedJson = JSON.stringify(payload);
86
+ const expectedSig = createHmac("sha256", getSecret()).update(expectedJson).digest();
87
+ const providedSig = b64urlDecode(parts[1]);
88
+ if (expectedSig.length !== providedSig.length) return { ok: false, reason: "bad_signature" };
89
+ if (!timingSafeEqual(expectedSig, providedSig)) return { ok: false, reason: "bad_signature" };
90
+
91
+ if (typeof payload.exp !== "number" || payload.exp < Date.now()) {
92
+ return { ok: false, reason: "expired" };
93
+ }
94
+ if (payload.u !== args.userId) return { ok: false, reason: "mismatch" };
95
+ if (payload.k !== args.targetKind || payload.t !== args.targetId) return { ok: false, reason: "mismatch" };
96
+ if (payload.dk !== args.docKind) return { ok: false, reason: "mismatch" };
97
+
98
+ const sortedRequested = [...args.docIds].sort();
99
+ if (
100
+ payload.d.length !== sortedRequested.length ||
101
+ payload.d.some((v, i) => v !== sortedRequested[i])
102
+ ) {
103
+ return { ok: false, reason: "mismatch" };
104
+ }
105
+ return { ok: true };
106
+ }
@@ -0,0 +1,391 @@
1
+ // src/attachments.ts — Plugin-side SOP/Framework attachment plumbing.
2
+ // Three responsibilities:
3
+ // 1. before_prompt_build hook: read the most-recent conversation for the
4
+ // caller's agent and inject any attached SOPs/Frameworks as system
5
+ // context (cached per-agent like the brain hook).
6
+ // 2. propose_attach: validate target tier + doc kind, return token-cost
7
+ // summary + HMAC confirmation token (5 min ttl).
8
+ // 3. commit_attach: re-verify token, write attachment ids to the row.
9
+
10
+ import type { SupabaseClient } from "@supabase/supabase-js";
11
+ import { renderAttachmentBlock } from "./sop-render.js";
12
+ import {
13
+ resolveAgentTier,
14
+ isDocKindValidForTier,
15
+ invalidateAgentTier,
16
+ } from "./agent-tier.js";
17
+ import { issueAttachmentToken, verifyAttachmentToken } from "./attach-token.js";
18
+
19
+ interface ToolResult {
20
+ content: Array<{ type: "text"; text: string }>;
21
+ }
22
+ function ok(data: unknown): ToolResult {
23
+ return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
24
+ }
25
+ function err(message: string): ToolResult {
26
+ return { content: [{ type: "text" as const, text: `Error: ${message}` }] };
27
+ }
28
+
29
+ export type TargetKind = "conversation" | "task" | "scheduler_event";
30
+ export type DocKind = "sop" | "framework";
31
+
32
+ const VALID_TARGETS: ReadonlyArray<TargetKind> = ["conversation", "task", "scheduler_event"];
33
+ function isValidTargetKind(v: unknown): v is TargetKind {
34
+ return typeof v === "string" && (VALID_TARGETS as readonly string[]).includes(v);
35
+ }
36
+
37
+ // ── Token cost (char/4 heuristic, matches dashboard) ──
38
+ function estimateTokens(content: unknown): number {
39
+ if (content == null) return 0;
40
+ const str = typeof content === "string" ? content : JSON.stringify(content);
41
+ return Math.ceil(str.length / 4);
42
+ }
43
+
44
+ // ── Attachment write logic ──
45
+ async function applyAttachmentWrite(args: {
46
+ supabase: SupabaseClient;
47
+ userId: string;
48
+ targetKind: TargetKind;
49
+ targetId: string;
50
+ docKind: DocKind;
51
+ docIds: string[];
52
+ }): Promise<{ ok: boolean; error?: string }> {
53
+ const { supabase, userId, targetKind, targetId, docKind, docIds } = args;
54
+ if (!docIds.length) return { ok: true };
55
+
56
+ if (targetKind === "conversation") {
57
+ const col = docKind === "sop" ? "attached_sop_ids" : "attached_framework_ids";
58
+ const { data: cur, error: readErr } = await supabase
59
+ .from("conversations")
60
+ .select(col)
61
+ .eq("user_id", userId)
62
+ .eq("id", targetId)
63
+ .maybeSingle();
64
+ if (readErr) return { ok: false, error: readErr.message };
65
+ const existing = ((cur as Record<string, string[] | undefined> | null)?.[col]) || [];
66
+ const merged = Array.from(new Set([...existing, ...docIds]));
67
+ const { error: writeErr } = await supabase
68
+ .from("conversations")
69
+ .update({ [col]: merged, updated_at: new Date().toISOString() })
70
+ .eq("user_id", userId)
71
+ .eq("id", targetId);
72
+ if (writeErr) return { ok: false, error: writeErr.message };
73
+ return { ok: true };
74
+ }
75
+
76
+ if (targetKind === "task" || targetKind === "scheduler_event") {
77
+ const tbl = targetKind === "task" ? "tasks" : "scheduler_events";
78
+ const fieldKey = docKind === "sop" ? "sop_ids" : "framework_ids";
79
+ const { data: cur, error: readErr } = await supabase
80
+ .from(tbl)
81
+ .select("custom_fields")
82
+ .eq("user_id", userId)
83
+ .eq("id", targetId)
84
+ .maybeSingle();
85
+ if (readErr) return { ok: false, error: readErr.message };
86
+ const cf = (cur?.custom_fields as Record<string, unknown> | null) || {};
87
+ const existing: string[] = Array.isArray(cf?.[fieldKey]) ? (cf[fieldKey] as string[]) : [];
88
+ const merged = Array.from(new Set([...existing, ...docIds]));
89
+ const nextCf = { ...cf, [fieldKey]: merged };
90
+ const { error: writeErr } = await supabase
91
+ .from(tbl)
92
+ .update({ custom_fields: nextCf, updated_at: new Date().toISOString() })
93
+ .eq("user_id", userId)
94
+ .eq("id", targetId);
95
+ if (writeErr) return { ok: false, error: writeErr.message };
96
+ return { ok: true };
97
+ }
98
+
99
+ return { ok: false, error: "unsupported target_kind" };
100
+ }
101
+
102
+ // ── Load target row (mainly to read its agent_id for tier check) ──
103
+ async function loadTargetAgentId(args: {
104
+ supabase: SupabaseClient;
105
+ userId: string;
106
+ targetKind: TargetKind;
107
+ targetId: string;
108
+ }): Promise<string | null> {
109
+ const { supabase, userId, targetKind, targetId } = args;
110
+ const tbl =
111
+ targetKind === "conversation" ? "conversations" :
112
+ targetKind === "task" ? "tasks" : "scheduler_events";
113
+ const { data } = await supabase
114
+ .from(tbl)
115
+ .select("agent_id")
116
+ .eq("user_id", userId)
117
+ .eq("id", targetId)
118
+ .maybeSingle();
119
+ return (data?.agent_id as string) || null;
120
+ }
121
+
122
+ // ── Validate that every doc id belongs to userId in the right table ──
123
+ async function validateDocIds(args: {
124
+ supabase: SupabaseClient;
125
+ userId: string;
126
+ docKind: DocKind;
127
+ docIds: string[];
128
+ }): Promise<{ ok: boolean; matched: string[] }> {
129
+ const { supabase, userId, docKind, docIds } = args;
130
+ if (!docIds.length) return { ok: true, matched: [] };
131
+ const tbl = docKind === "sop" ? "agent_sops" : "frameworks";
132
+ const { data } = await supabase
133
+ .from(tbl)
134
+ .select("id")
135
+ .eq("user_id", userId)
136
+ .in("id", docIds);
137
+ const matched = (data || []).map((r: { id: string }) => r.id);
138
+ return { ok: matched.length === docIds.length, matched };
139
+ }
140
+
141
+ // ── propose_attach: validate + compute cost + issue token ──
142
+ export async function handleProposeAttach(args: {
143
+ supabase: SupabaseClient;
144
+ userId: string;
145
+ docKind: DocKind;
146
+ params: Record<string, unknown>;
147
+ }): Promise<ToolResult> {
148
+ const { supabase, userId, docKind, params } = args;
149
+ if (!isValidTargetKind(params.target_kind)) {
150
+ return err("Invalid target_kind. Use one of: conversation, task, scheduler_event");
151
+ }
152
+ const targetKind = params.target_kind as TargetKind;
153
+ const targetId = params.target_id as string;
154
+ if (!targetId) return err("Missing target_id");
155
+ if (!Array.isArray(params.doc_ids) || params.doc_ids.length === 0) {
156
+ return err("Missing doc_ids[]");
157
+ }
158
+ const docIds = (params.doc_ids as unknown[]).filter(
159
+ (x): x is string => typeof x === "string",
160
+ );
161
+
162
+ const targetAgentId = await loadTargetAgentId({
163
+ supabase, userId, targetKind, targetId,
164
+ });
165
+ if (!targetAgentId) return err("target_not_found_or_no_agent");
166
+
167
+ const tierResolution = await resolveAgentTier(supabase, targetAgentId, userId);
168
+ if (!tierResolution.tier) {
169
+ return err("agent_unclassified — set tier in dashboard agent settings or via override");
170
+ }
171
+ if (!isDocKindValidForTier(docKind, tierResolution.tier)) {
172
+ return err(
173
+ `tier_mismatch — assigned agent is ${tierResolution.tier}; ` +
174
+ (docKind === "sop"
175
+ ? "SOPs only attach to staff"
176
+ : "Frameworks only attach to c-suite"),
177
+ );
178
+ }
179
+
180
+ const v = await validateDocIds({ supabase, userId, docKind, docIds });
181
+ if (!v.ok) return err("unknown_doc — one or more doc_ids do not belong to you");
182
+
183
+ // Cost summary
184
+ const tbl = docKind === "sop" ? "agent_sops" : "frameworks";
185
+ const { data: rows } = await supabase
186
+ .from(tbl)
187
+ .select("id, title, content")
188
+ .eq("user_id", userId)
189
+ .in("id", docIds);
190
+ const breakdown = (rows || []).map((r: { id: string; title: string; content: string }) => ({
191
+ id: r.id,
192
+ title: r.title || (docKind === "sop" ? "Untitled SOP" : "Untitled Framework"),
193
+ tokens: estimateTokens(r.content),
194
+ kind: docKind,
195
+ }));
196
+ const totalTokens = breakdown.reduce((acc, b) => acc + b.tokens, 0);
197
+
198
+ let token: string;
199
+ try {
200
+ token = issueAttachmentToken({
201
+ userId, targetKind, targetId, docKind, docIds,
202
+ });
203
+ } catch (e) {
204
+ return err(`token_issue_failed: ${e instanceof Error ? e.message : String(e)}`);
205
+ }
206
+
207
+ const noun = docKind === "sop" ? "SOP" : "Framework";
208
+ const plural = docIds.length === 1 ? noun : `${noun}s`;
209
+ const summary = `Adding ${docIds.length} ${plural} (~${totalTokens.toLocaleString()} tokens) to ${targetKind} ${targetId}.`;
210
+
211
+ return ok({
212
+ requires_confirmation: true,
213
+ target_kind: targetKind,
214
+ target_id: targetId,
215
+ doc_kind: docKind,
216
+ doc_ids: docIds,
217
+ tier: tierResolution.tier,
218
+ tier_source: tierResolution.source,
219
+ token_cost: totalTokens,
220
+ breakdown,
221
+ confirmation_token: token,
222
+ confirmation_prompt:
223
+ `${summary} Ask the user: "Proceed? (~${totalTokens.toLocaleString()} tokens)" — only call commit_attach AFTER user approves.`,
224
+ });
225
+ }
226
+
227
+ // ── commit_attach: verify token + write ──
228
+ export async function handleCommitAttach(args: {
229
+ supabase: SupabaseClient;
230
+ userId: string;
231
+ docKind: DocKind;
232
+ params: Record<string, unknown>;
233
+ }): Promise<ToolResult> {
234
+ const { supabase, userId, docKind, params } = args;
235
+ if (!isValidTargetKind(params.target_kind)) return err("Invalid target_kind");
236
+ const targetKind = params.target_kind as TargetKind;
237
+ const targetId = params.target_id as string;
238
+ if (!targetId) return err("Missing target_id");
239
+ if (!Array.isArray(params.doc_ids) || params.doc_ids.length === 0) {
240
+ return err("Missing doc_ids[]");
241
+ }
242
+ const docIds = (params.doc_ids as unknown[]).filter(
243
+ (x): x is string => typeof x === "string",
244
+ );
245
+ const token = params.confirmation_token as string;
246
+ if (!token) return err("Missing confirmation_token (call propose_attach first)");
247
+
248
+ const v = verifyAttachmentToken({
249
+ token, userId, targetKind, targetId, docKind, docIds,
250
+ });
251
+ if (!v.ok) return err(`token_${v.reason}`);
252
+
253
+ const wr = await applyAttachmentWrite({
254
+ supabase, userId, targetKind, targetId, docKind, docIds,
255
+ });
256
+ if (!wr.ok) return err(wr.error || "write_failed");
257
+
258
+ // Invalidate the per-agent attachment cache so the next prompt build picks up the new ids
259
+ attachmentCache.clear();
260
+
261
+ return ok({
262
+ ok: true,
263
+ attached: docIds.length,
264
+ target_kind: targetKind,
265
+ target_id: targetId,
266
+ doc_kind: docKind,
267
+ });
268
+ }
269
+
270
+ // ── before_prompt_build hook ──
271
+ const attachmentCache = new Map<string, { text: string; at: number }>();
272
+ const ATTACH_CACHE_TTL_MS = 600_000;
273
+ const ATTACH_CACHE_STALE_MS = 900_000;
274
+
275
+ function isSystemName(name: string): boolean {
276
+ const s = name.toLowerCase().trim();
277
+ return s === "" || s === "ofiere" || s === "openclaw" || s === "system" || s === "plugin" || s === "gateway" || s.includes("plugin");
278
+ }
279
+
280
+ async function buildAttachmentBlock(args: {
281
+ supabase: SupabaseClient;
282
+ userId: string;
283
+ agentId: string;
284
+ }): Promise<string> {
285
+ const { supabase, userId, agentId } = args;
286
+
287
+ // Find the most recently active conversation for this agent. The dashboard
288
+ // bumps `updated_at` whenever a message is sent or attachments change, so
289
+ // this gives us the right run target most of the time.
290
+ const { data: convRow } = await supabase
291
+ .from("conversations")
292
+ .select("id, attached_sop_ids, attached_framework_ids")
293
+ .eq("user_id", userId)
294
+ .eq("agent_id", agentId)
295
+ .order("updated_at", { ascending: false })
296
+ .limit(1)
297
+ .maybeSingle();
298
+
299
+ const sopIds: string[] = (convRow?.attached_sop_ids as string[] | null) || [];
300
+ const fwIds: string[] = (convRow?.attached_framework_ids as string[] | null) || [];
301
+ if (!sopIds.length && !fwIds.length) return "";
302
+
303
+ const [sopsRes, fwsRes] = await Promise.all([
304
+ sopIds.length
305
+ ? supabase
306
+ .from("agent_sops")
307
+ .select("title, content")
308
+ .eq("user_id", userId)
309
+ .in("id", sopIds)
310
+ : Promise.resolve({ data: [] as Array<{ title: string; content: string }> }),
311
+ fwIds.length
312
+ ? supabase
313
+ .from("frameworks")
314
+ .select("title, content")
315
+ .eq("user_id", userId)
316
+ .in("id", fwIds)
317
+ : Promise.resolve({ data: [] as Array<{ title: string; content: string }> }),
318
+ ]);
319
+
320
+ const sops = (sopsRes.data || []).map((s) => ({
321
+ title: s.title || "Untitled SOP",
322
+ content: typeof s.content === "string" ? s.content : JSON.stringify(s.content ?? ""),
323
+ }));
324
+ const frameworks = (fwsRes.data || []).map((f) => ({
325
+ title: f.title || "Untitled Framework",
326
+ content: typeof f.content === "string" ? f.content : JSON.stringify(f.content ?? ""),
327
+ }));
328
+
329
+ return renderAttachmentBlock({ sops, frameworks });
330
+ }
331
+
332
+ export function registerAttachmentContextHook(args: {
333
+ api: any;
334
+ supabase: SupabaseClient;
335
+ userId: string;
336
+ fallbackAgentId: string;
337
+ resolveAgent: (id?: string) => Promise<string | null>;
338
+ }): void {
339
+ const { api, supabase, userId, fallbackAgentId, resolveAgent } = args;
340
+
341
+ try {
342
+ api.on("before_prompt_build", async (_event: any, ctx: any) => {
343
+ try {
344
+ const ctxAgentId = ctx?.agentId || "";
345
+ let resolvedAgentId = fallbackAgentId;
346
+ if (ctxAgentId && !isSystemName(ctxAgentId)) {
347
+ try {
348
+ const r = await resolveAgent(ctxAgentId);
349
+ if (r) resolvedAgentId = r;
350
+ } catch { /* fall through */ }
351
+ }
352
+ if (!resolvedAgentId) return;
353
+
354
+ const cacheKey = resolvedAgentId;
355
+ const cached = attachmentCache.get(cacheKey);
356
+ if (cached) {
357
+ const age = Date.now() - cached.at;
358
+ if (age < ATTACH_CACHE_TTL_MS) {
359
+ return cached.text ? { appendSystemContext: cached.text } : undefined;
360
+ }
361
+ if (age < ATTACH_CACHE_STALE_MS) {
362
+ // Refresh in background
363
+ (async () => {
364
+ try {
365
+ const fresh = await buildAttachmentBlock({ supabase, userId, agentId: resolvedAgentId });
366
+ attachmentCache.set(cacheKey, { text: fresh, at: Date.now() });
367
+ } catch { /* ignore */ }
368
+ })();
369
+ return cached.text ? { appendSystemContext: cached.text } : undefined;
370
+ }
371
+ }
372
+
373
+ const block = await buildAttachmentBlock({ supabase, userId, agentId: resolvedAgentId });
374
+ attachmentCache.set(cacheKey, { text: block, at: Date.now() });
375
+ return block ? { appendSystemContext: block } : undefined;
376
+ } catch (e) {
377
+ api.logger?.debug?.(`[ofiere-attach] before_prompt_build error: ${e instanceof Error ? e.message : e}`);
378
+ }
379
+ });
380
+ } catch {
381
+ api.logger?.debug?.("[ofiere] Could not register attachment context hook — appendSystemContext may not be supported");
382
+ }
383
+ }
384
+
385
+ // Public for tests / future hooks that need to drop the cache
386
+ export function invalidateAttachmentCache(): void {
387
+ attachmentCache.clear();
388
+ }
389
+
390
+ // Re-export tier invalidation so callers don't need to import two modules
391
+ export { invalidateAgentTier };
package/src/prompt.ts CHANGED
@@ -24,9 +24,10 @@ const TOOL_SUMMARIES: Record<string, string> = {
24
24
  OFIERE_CONSTELLATION_OPS: "Create/edit/delete OpenClaw agents from chat",
25
25
  OFIERE_FILE_OPS: "Space files: upload, read, move, share, delete",
26
26
  OFIERE_PLAN_OPS: "Visual execution plan builder (DAG drafts → real tasks)",
27
- OFIERE_SOP_OPS: "Standard Operating Procedures for department chiefs",
27
+ OFIERE_SOP_OPS: "Standard Operating Procedures (Staff agents). Includes propose_attach/commit_attach for self-attaching SOPs to runs",
28
28
  OFIERE_BRAIN_OPS: "Agent memory, knowledge graph, self-improvement (TMT/MAGMA)",
29
29
  OFIERE_TALENT_OPS: "Talents: list, get, activate, deactivate cognitive skill presets",
30
+ OFIERE_FRAMEWORK_OPS: "Corporate Frameworks (C-Suite agents). Includes propose_attach/commit_attach for self-attaching Frameworks to runs",
30
31
  };
31
32
 
32
33
  // ─── Tier B: Full Tool Documentation ────────────────────────────────────────
@@ -105,10 +106,11 @@ Actions: "list", "get", "create", "update", "delete", "add_nodes", "execute"
105
106
  - Node types: task, gate, milestone
106
107
  - Execution maps ALL fields: execution_steps, goals, constraints, system_prompt`,
107
108
 
108
- OFIERE_SOP_OPS: `Standard Operating Procedures for department chiefs.
109
- Actions: "list_templates", "create", "list", "get", "update", "delete", "list_subagents", "apply_template"
109
+ OFIERE_SOP_OPS: `Standard Operating Procedures.
110
+ Actions: "list_templates", "create", "list", "get", "update", "delete", "list_subagents", "apply_template", "propose_attach", "commit_attach"
110
111
  - sop_data: { title, purpose, scope, applicability, prerequisites:{ conditions, required_tools, required_permissions, safety_warnings }, procedure_steps[], expected_outputs[], escalation_rules[], acceptance_criteria[], rollback_procedure, notes }
111
112
  - Legacy field names still accepted (objective→purpose, steps→procedure_steps, deliverables→expected_outputs, escalationRules→escalation_rules, successCriteria→acceptance_criteria) — prefer new names.
113
+ - propose_attach / commit_attach: attach SOPs to a run (target_kind: conversation|task|scheduler_event). Tier rule: SOPs only attach to STAFF-tier targets; for c-suite use OFIERE_FRAMEWORK_OPS instead. propose_attach returns a token-cost summary + confirmation_token (5-min ttl). You MUST surface the cost and ask the user before calling commit_attach. The user must explicitly approve.
112
114
  - See SOP PROTOCOL section for when to load vs skip`,
113
115
 
114
116
  OFIERE_BRAIN_OPS: `Agent memory, knowledge graph, self-improvement (TMT/MAGMA).
@@ -124,6 +126,11 @@ Actions: "list", "get", "activate", "deactivate"
124
126
  - deactivate: Disable a talent (status → inactive). Required: talent_id
125
127
  - Active talents inject their execution_protocol and guardrails into your system prompt automatically
126
128
  - Talents chain other tools: SPHINX chains KNOWLEDGE_OPS+BRAIN_OPS, PRISM chains BRAIN_OPS trajectories, ATLAS chains PLAN_OPS+TASK_OPS+SOP_OPS`,
129
+
130
+ OFIERE_FRAMEWORK_OPS: `Corporate Frameworks — strategic mandates, KPIs, risk governance.
131
+ Actions: "create", "list", "get", "update", "delete", "propose_attach", "commit_attach"
132
+ - content: JSON string with { mission, vision, core_principles[], decision_authority, budget_constraints, resource_limits, tool_stack[], strategic_objectives[], risk_appetite, compliance_requirements[], escalation_matrix[], team_composition (STRING — paragraph or newline-joined, NOT an array), integration_points[], review_frequency, last_reviewed, next_review, notes }
133
+ - propose_attach / commit_attach: attach Frameworks to a run (target_kind: conversation|task|scheduler_event). Tier rule: Frameworks only attach to C-SUITE-tier targets; for staff use OFIERE_SOP_OPS instead. propose_attach returns a token-cost summary + confirmation_token (5-min ttl). You MUST surface the cost and ask the user before calling commit_attach. The user must explicitly approve.`,
127
134
  };
128
135
 
129
136
  export function getSystemPrompt(state: {
@@ -0,0 +1,216 @@
1
+ // src/sop-render.ts — Plugin-side markdown renderer for SOPs and Frameworks.
2
+ // Mirrors dashboard/lib/sopRender.ts. Output is what gets prepended/appended to
3
+ // the system prompt during `before_prompt_build`.
4
+
5
+ interface SOPCheckItem { text?: string; checked?: boolean; }
6
+ interface SOPStep {
7
+ name?: string; action?: string; owner?: string; output?: string;
8
+ decision_logic?: string; failure_state?: string; fallback_action?: string;
9
+ estimated_duration?: string;
10
+ }
11
+ interface SOPEscalationRule { trigger?: string; escalateTo?: string; priority?: string; }
12
+ interface SOPPrerequisites {
13
+ conditions?: SOPCheckItem[]; required_tools?: string[];
14
+ required_permissions?: string[]; safety_warnings?: string[];
15
+ }
16
+ export interface SOPData {
17
+ title?: string;
18
+ purpose?: string; scope?: string; applicability?: string;
19
+ prerequisites?: SOPPrerequisites;
20
+ procedure_steps?: SOPStep[];
21
+ expected_outputs?: string[];
22
+ acceptance_criteria?: SOPCheckItem[];
23
+ escalation_rules?: SOPEscalationRule[];
24
+ rollback_procedure?: string;
25
+ notes?: string;
26
+ }
27
+
28
+ interface FrameworkObjective {
29
+ objective?: string; target?: string; measurement?: string; frequency?: string;
30
+ }
31
+ interface FrameworkEscalation {
32
+ trigger?: string; escalate_to?: string; priority?: string; response_sla?: string;
33
+ }
34
+ export interface FrameworkData {
35
+ title?: string;
36
+ mission?: string; vision?: string;
37
+ core_principles?: string[];
38
+ decision_authority?: string; budget_constraints?: string;
39
+ resource_limits?: string; tool_stack?: string[];
40
+ strategic_objectives?: FrameworkObjective[];
41
+ risk_appetite?: string; compliance_requirements?: string[];
42
+ escalation_matrix?: FrameworkEscalation[];
43
+ team_composition?: string; integration_points?: string[];
44
+ review_frequency?: string; last_reviewed?: string; next_review?: string;
45
+ notes?: string;
46
+ }
47
+
48
+ function bullet(items: (string | undefined | null)[]): string {
49
+ const cleaned = items.map((s) => (s ?? "").toString().trim()).filter(Boolean);
50
+ return cleaned.length ? cleaned.map((s) => `- ${s}`).join("\n") : "";
51
+ }
52
+
53
+ function section(label: string, body: string): string {
54
+ const trimmed = body.trim();
55
+ return trimmed ? `### ${label}\n${trimmed}` : "";
56
+ }
57
+
58
+ function joinSections(parts: string[]): string {
59
+ return parts.filter(Boolean).join("\n\n");
60
+ }
61
+
62
+ function safeParse<T = unknown>(s: string): T | null {
63
+ try { return JSON.parse(s) as T; } catch { return null; }
64
+ }
65
+
66
+ export function renderSopMarkdown(input: string | SOPData | null | undefined): string {
67
+ if (input == null) return "";
68
+ const data: SOPData = typeof input === "string"
69
+ ? (safeParse<SOPData>(input) || {})
70
+ : input;
71
+
72
+ const parts: string[] = [];
73
+ if (data.purpose) parts.push(section("Purpose", data.purpose));
74
+ if (data.scope) parts.push(section("Scope", data.scope));
75
+ if (data.applicability) parts.push(section("Applicability", data.applicability));
76
+
77
+ const prereq = data.prerequisites;
78
+ if (prereq) {
79
+ const pieces: string[] = [];
80
+ const cond = (prereq.conditions || []).map((c) => c?.text).filter(Boolean) as string[];
81
+ if (cond.length) pieces.push(`**Conditions:**\n${bullet(cond)}`);
82
+ if (prereq.required_tools?.length) pieces.push(`**Required tools:**\n${bullet(prereq.required_tools)}`);
83
+ if (prereq.required_permissions?.length) pieces.push(`**Required permissions:**\n${bullet(prereq.required_permissions)}`);
84
+ if (prereq.safety_warnings?.length) pieces.push(`**Safety warnings:**\n${bullet(prereq.safety_warnings)}`);
85
+ if (pieces.length) parts.push(section("Prerequisites", pieces.join("\n\n")));
86
+ }
87
+
88
+ if (data.procedure_steps?.length) {
89
+ const steps = data.procedure_steps.map((step, i) => {
90
+ const lines: string[] = [`${i + 1}. **${step.name || `Step ${i + 1}`}**`];
91
+ if (step.action) lines.push(` - Action: ${step.action}`);
92
+ if (step.owner) lines.push(` - Owner: ${step.owner}`);
93
+ if (step.output) lines.push(` - Output: ${step.output}`);
94
+ if (step.decision_logic) lines.push(` - Decision logic: ${step.decision_logic}`);
95
+ if (step.failure_state) lines.push(` - Failure state: ${step.failure_state}`);
96
+ if (step.fallback_action) lines.push(` - Fallback: ${step.fallback_action}`);
97
+ if (step.estimated_duration) lines.push(` - ETA: ${step.estimated_duration}`);
98
+ return lines.join("\n");
99
+ }).join("\n");
100
+ parts.push(section("Procedure", steps));
101
+ }
102
+
103
+ if (data.expected_outputs?.length) parts.push(section("Expected Outputs", bullet(data.expected_outputs)));
104
+ if (data.acceptance_criteria?.length) {
105
+ parts.push(section("Acceptance Criteria", bullet(data.acceptance_criteria.map((c) => c?.text || ""))));
106
+ }
107
+
108
+ if (data.escalation_rules?.length) {
109
+ const rules = data.escalation_rules
110
+ .filter((r) => r.trigger || r.escalateTo)
111
+ .map((r) => `- [${r.priority || "P2"}] ${r.trigger || "(unspecified trigger)"} → ${r.escalateTo || "(unspecified)"}`)
112
+ .join("\n");
113
+ if (rules) parts.push(section("Escalation", rules));
114
+ }
115
+
116
+ if (data.rollback_procedure) parts.push(section("Rollback", data.rollback_procedure));
117
+ if (data.notes) parts.push(section("Notes", data.notes));
118
+
119
+ return joinSections(parts);
120
+ }
121
+
122
+ export function renderFrameworkMarkdown(input: string | FrameworkData | null | undefined): string {
123
+ if (input == null) return "";
124
+ const data: FrameworkData = typeof input === "string"
125
+ ? (safeParse<FrameworkData>(input) || {})
126
+ : input;
127
+
128
+ const parts: string[] = [];
129
+ if (data.mission) parts.push(section("Mission", data.mission));
130
+ if (data.vision) parts.push(section("Vision", data.vision));
131
+ if (data.core_principles?.length) parts.push(section("Core Principles", bullet(data.core_principles)));
132
+
133
+ const authParts: string[] = [];
134
+ if (data.decision_authority) authParts.push(`**Decision authority:** ${data.decision_authority}`);
135
+ if (data.budget_constraints) authParts.push(`**Budget constraints:** ${data.budget_constraints}`);
136
+ if (data.resource_limits) authParts.push(`**Resource limits:** ${data.resource_limits}`);
137
+ if (data.tool_stack?.length) authParts.push(`**Tool stack:**\n${bullet(data.tool_stack)}`);
138
+ if (authParts.length) parts.push(section("Authority & Constraints", authParts.join("\n\n")));
139
+
140
+ if (data.strategic_objectives?.length) {
141
+ const rows = data.strategic_objectives
142
+ .filter((o) => o.objective || o.target)
143
+ .map((o) => {
144
+ const bits = [
145
+ o.objective ? `**${o.objective}**` : "",
146
+ o.target ? `target: ${o.target}` : "",
147
+ o.measurement ? `measure: ${o.measurement}` : "",
148
+ o.frequency ? `cadence: ${o.frequency}` : "",
149
+ ].filter(Boolean);
150
+ return `- ${bits.join(" · ")}`;
151
+ })
152
+ .join("\n");
153
+ if (rows) parts.push(section("Strategic Objectives (KPIs)", rows));
154
+ }
155
+
156
+ const riskParts: string[] = [];
157
+ if (data.risk_appetite) riskParts.push(`**Risk appetite:** ${data.risk_appetite}`);
158
+ if (data.compliance_requirements?.length) riskParts.push(`**Compliance:**\n${bullet(data.compliance_requirements)}`);
159
+ if (riskParts.length) parts.push(section("Risk & Compliance", riskParts.join("\n\n")));
160
+
161
+ if (data.escalation_matrix?.length) {
162
+ const rows = data.escalation_matrix
163
+ .filter((e) => e.trigger || e.escalate_to)
164
+ .map((e) => {
165
+ const sla = e.response_sla ? ` (SLA ${e.response_sla})` : "";
166
+ return `- [${e.priority || "P2"}] ${e.trigger || "(unspecified)"} → ${e.escalate_to || "(unspecified)"}${sla}`;
167
+ })
168
+ .join("\n");
169
+ if (rows) parts.push(section("Escalation Matrix", rows));
170
+ }
171
+
172
+ const teamParts: string[] = [];
173
+ if (data.team_composition) teamParts.push(`**Team:** ${data.team_composition}`);
174
+ if (data.integration_points?.length) teamParts.push(`**Integration points:**\n${bullet(data.integration_points)}`);
175
+ if (teamParts.length) parts.push(section("Team & Integrations", teamParts.join("\n\n")));
176
+
177
+ if (data.review_frequency || data.next_review) {
178
+ const lines = [
179
+ data.review_frequency ? `**Review cadence:** ${data.review_frequency}` : "",
180
+ data.last_reviewed ? `**Last reviewed:** ${data.last_reviewed}` : "",
181
+ data.next_review ? `**Next review:** ${data.next_review}` : "",
182
+ ].filter(Boolean);
183
+ if (lines.length) parts.push(section("Review Cycle", lines.join("\n")));
184
+ }
185
+
186
+ if (data.notes) parts.push(section("Notes", data.notes));
187
+
188
+ return joinSections(parts);
189
+ }
190
+
191
+ export function renderAttachmentBlock(args: {
192
+ sops?: Array<{ title: string; content: string }>;
193
+ frameworks?: Array<{ title: string; content: string }>;
194
+ }): string {
195
+ const blocks: string[] = [];
196
+
197
+ if (args.frameworks?.length) {
198
+ const sections = args.frameworks.map((fw) => {
199
+ const md = renderFrameworkMarkdown(fw.content);
200
+ if (!md) return `## ${fw.title}\n_(empty)_`;
201
+ return `## ${fw.title}\n${md}`;
202
+ }).join("\n\n");
203
+ blocks.push(`[FRAMEWORKS — ATTACHED]\nThese frameworks are active for this run. Orient strategic decisions toward them.\n\n${sections}`);
204
+ }
205
+
206
+ if (args.sops?.length) {
207
+ const sections = args.sops.map((sop) => {
208
+ const md = renderSopMarkdown(sop.content);
209
+ if (!md) return `## ${sop.title}\n_(empty)_`;
210
+ return `## ${sop.title}\n${md}`;
211
+ }).join("\n\n");
212
+ blocks.push(`[SOPS — ATTACHED]\nThese SOPs are active for this run. Follow the procedure steps and escalation rules.\n\n${sections}`);
213
+ }
214
+
215
+ return blocks.join("\n\n");
216
+ }
package/src/tools.ts CHANGED
@@ -13,6 +13,11 @@
13
13
  import type { SupabaseClient } from "@supabase/supabase-js";
14
14
  import type { OfiereConfig } from "./types.js";
15
15
  import { resolveAgentId } from "./agent-resolver.js";
16
+ import {
17
+ handleProposeAttach,
18
+ handleCommitAttach,
19
+ registerAttachmentContextHook,
20
+ } from "./attachments.js";
16
21
 
17
22
  // ─── Tool result shape (matches OpenClaw SDK) ────────────────────────────────
18
23
 
@@ -4843,7 +4848,10 @@ function registerSOPOps(
4843
4848
  `- "update": Modify SOP. Required: sop_id. Optional: title, sop_data, status, department\n` +
4844
4849
  `- "delete": Remove SOP. Required: sop_id\n` +
4845
4850
  `- "list_subagents": List subagents for a chief. Required: chief_agent_id\n` +
4846
- `- "apply_template": Create SOP from template. Required: agent_id, template_id. Optional: title, department\n\n` +
4851
+ `- "apply_template": Create SOP from template. Required: agent_id, template_id. Optional: title, department\n` +
4852
+ `- "propose_attach": Propose attaching SOPs to a run target (conversation/task/scheduler_event). Returns token cost + confirmation_token. Required: target_kind, target_id, doc_ids[]. The user MUST be asked to approve before commit.\n` +
4853
+ `- "commit_attach": Commit a proposed attachment. Required: target_kind, target_id, doc_ids[], confirmation_token (from propose_attach). Only call AFTER user approves the token cost.\n` +
4854
+ `Tier rule: SOPs only attach to targets whose assigned agent is Staff. Use OFIERE_FRAMEWORK_OPS for c-suite targets.\n\n` +
4847
4855
  `sop_data structure (JSON object):\n` +
4848
4856
  `{\n` +
4849
4857
  ` title: string,\n` +
@@ -4863,11 +4871,15 @@ function registerSOPOps(
4863
4871
  type: "object",
4864
4872
  required: ["action"],
4865
4873
  properties: {
4866
- action: { type: "string", enum: ["list_templates", "create", "list", "get", "update", "delete", "list_subagents", "apply_template"], description: "Valid actions: list_templates, create, list, get, update, delete, list_subagents, apply_template" },
4874
+ action: { type: "string", enum: ["list_templates", "create", "list", "get", "update", "delete", "list_subagents", "apply_template", "propose_attach", "commit_attach"], description: "Valid actions: list_templates, create, list, get, update, delete, list_subagents, apply_template, propose_attach, commit_attach" },
4867
4875
  sop_id: { type: "string", description: "SOP ID (required for get, update, delete)" },
4868
4876
  agent_id: { type: "string", description: "Agent ID (required for create, list filter, apply_template)" },
4869
4877
  chief_agent_id: { type: "string", description: "Chief agent ID (required for list_subagents)" },
4870
4878
  template_id: { type: "string", description: "Template ID (required for apply_template)" },
4879
+ target_kind: { type: "string", enum: ["conversation", "task", "scheduler_event"], description: "Run target kind (for propose_attach/commit_attach)" },
4880
+ target_id: { type: "string", description: "Run target id (for propose_attach/commit_attach)" },
4881
+ doc_ids: { type: "array", items: { type: "string" }, description: "SOP ids to attach (for propose_attach/commit_attach)" },
4882
+ confirmation_token: { type: "string", description: "Token returned by propose_attach. Required for commit_attach." },
4871
4883
  title: { type: "string", description: "SOP title" },
4872
4884
  department: { type: "string", description: "Department name (e.g. Marketing & Revenue)" },
4873
4885
  status: { type: "string", enum: ["draft", "active", "archived", "under_review"], description: "SOP status" },
@@ -4921,7 +4933,9 @@ function registerSOPOps(
4921
4933
  case "delete": return handleSOPDelete(supabase, userId, params);
4922
4934
  case "list_subagents": return handleSOPListSubagents(supabase, userId, params);
4923
4935
  case "apply_template": return handleSOPApplyTemplate(supabase, userId, resolveAgent, params);
4924
- default: return err(`Unknown action "${action}". Valid: list_templates, create, list, get, update, delete, list_subagents, apply_template`);
4936
+ case "propose_attach": return handleProposeAttach({ supabase, userId, docKind: "sop", params });
4937
+ case "commit_attach": return handleCommitAttach({ supabase, userId, docKind: "sop", params });
4938
+ default: return err(`Unknown action "${action}". Valid: list_templates, create, list, get, update, delete, list_subagents, apply_template, propose_attach, commit_attach`);
4925
4939
  }
4926
4940
  },
4927
4941
  });
@@ -5237,7 +5251,10 @@ function registerFrameworkOps(
5237
5251
  `- "list": List frameworks. Optional: agent_id to filter\n` +
5238
5252
  `- "get": Get full framework details. Required: framework_id\n` +
5239
5253
  `- "update": Modify framework. Required: framework_id. Optional: title, content, status, department, category\n` +
5240
- `- "delete": Remove framework. Required: framework_id\n\n` +
5254
+ `- "delete": Remove framework. Required: framework_id\n` +
5255
+ `- "propose_attach": Propose attaching Frameworks to a run target. Returns token cost + confirmation_token. Required: target_kind, target_id, doc_ids[]. The user MUST be asked to approve before commit.\n` +
5256
+ `- "commit_attach": Commit a proposed attachment. Required: target_kind, target_id, doc_ids[], confirmation_token (from propose_attach). Only call AFTER user approves the token cost.\n` +
5257
+ `Tier rule: Frameworks only attach to targets whose assigned agent is C-Suite. Use OFIERE_SOP_OPS for staff targets.\n\n` +
5241
5258
  `content: A JSON string containing the full framework body. Example:\n` +
5242
5259
  `"{\\"mission\\":\\"Drive brand growth\\",\\"vision\\":\\"Market leader by 2027\\",\\"core_principles\\":[\\"Data-driven\\",\\"Customer-first\\"],` +
5243
5260
  `\\"decision_authority\\":\\"CMO approves >$10k spend\\",\\"budget_constraints\\":\\"$50k monthly cap\\",` +
@@ -5254,7 +5271,7 @@ function registerFrameworkOps(
5254
5271
  type: "object",
5255
5272
  required: ["action"],
5256
5273
  properties: {
5257
- action: { type: "string", enum: ["create", "list", "get", "update", "delete"], description: "Framework action" },
5274
+ action: { type: "string", enum: ["create", "list", "get", "update", "delete", "propose_attach", "commit_attach"], description: "Framework action" },
5258
5275
  framework_id: { type: "string", description: "Framework ID (required for get, update, delete)" },
5259
5276
  agent_id: { type: "string", description: "Agent ID (required for create, optional filter for list)" },
5260
5277
  title: { type: "string", description: "Framework title" },
@@ -5262,6 +5279,10 @@ function registerFrameworkOps(
5262
5279
  department: { type: "string" },
5263
5280
  category: { type: "string" },
5264
5281
  status: { type: "string", enum: ["draft", "active", "under_review", "archived"] },
5282
+ target_kind: { type: "string", enum: ["conversation", "task", "scheduler_event"], description: "Run target kind (for propose_attach/commit_attach)" },
5283
+ target_id: { type: "string", description: "Run target id (for propose_attach/commit_attach)" },
5284
+ doc_ids: { type: "array", items: { type: "string" }, description: "Framework ids to attach (for propose_attach/commit_attach)" },
5285
+ confirmation_token: { type: "string", description: "Token returned by propose_attach. Required for commit_attach." },
5265
5286
  },
5266
5287
  },
5267
5288
  async execute(_id: string, params: Record<string, unknown>) {
@@ -5272,7 +5293,9 @@ function registerFrameworkOps(
5272
5293
  case "get": return handleFWGet(supabase, userId, params);
5273
5294
  case "update": return handleFWUpdate(supabase, userId, params);
5274
5295
  case "delete": return handleFWDelete(supabase, userId, params);
5275
- default: return err(`Unknown action "${action}". Valid: create, list, get, update, delete`);
5296
+ case "propose_attach": return handleProposeAttach({ supabase, userId, docKind: "framework", params });
5297
+ case "commit_attach": return handleCommitAttach({ supabase, userId, docKind: "framework", params });
5298
+ default: return err(`Unknown action "${action}". Valid: create, list, get, update, delete, propose_attach, commit_attach`);
5276
5299
  }
5277
5300
  },
5278
5301
  });
@@ -6127,6 +6150,9 @@ export function registerTools(
6127
6150
  // ── Register talent context hook ──
6128
6151
  registerTalentContextHook(api, supabase, userId, fallbackAgentId);
6129
6152
 
6153
+ // ── Register attachment context hook (SOPs/Frameworks injection) ──
6154
+ registerAttachmentContextHook({ api, supabase, userId, fallbackAgentId, resolveAgent });
6155
+
6130
6156
  // ── Register agent_end hook for server-side brain extraction ──
6131
6157
  registerBrainExtractionHook(api, supabase, userId, fallbackAgentId);
6132
6158