@cohortapp/agent-sdk 2.3.2 → 2.4.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/framework-features.json +30 -0
- package/lib/backlog.mjs +136 -0
- package/lib/cadences.mjs +63 -2
- package/lib/cadences.test.mjs +105 -0
- package/lib/capability/inventory.mjs +542 -0
- package/lib/capability/inventory.test.mjs +232 -0
- package/lib/capability/probe.mjs +255 -0
- package/lib/channels/contract.mjs +37 -1
- package/lib/channels/contract.test.mjs +25 -1
- package/lib/claude-bin.mjs +37 -3
- package/lib/claude-bin.test.mjs +42 -8
- package/lib/execution/disposition.mjs +501 -0
- package/lib/execution/disposition.test.mjs +482 -0
- package/lib/execution/drive.mjs +352 -0
- package/lib/execution/drive.test.mjs +270 -0
- package/lib/execution/effects.mjs +340 -0
- package/lib/execution/effects.test.mjs +193 -0
- package/lib/execution/index.mjs +152 -0
- package/lib/execution/intake.mjs +581 -0
- package/lib/execution/intake.test.mjs +343 -0
- package/lib/execution/journal.mjs +374 -0
- package/lib/execution/journal.test.mjs +261 -0
- package/lib/execution/match.mjs +331 -0
- package/lib/execution/match.test.mjs +235 -0
- package/lib/execution/pipeline.mjs +341 -0
- package/lib/execution/pipeline.test.mjs +389 -0
- package/lib/execution/route.mjs +332 -0
- package/lib/execution/route.test.mjs +186 -0
- package/lib/execution/surface-policy.mjs +446 -0
- package/lib/execution/surface-policy.test.mjs +162 -0
- package/lib/goals/admission.mjs +209 -0
- package/lib/goals/admission.test.mjs +139 -0
- package/lib/goals/classify.mjs +206 -0
- package/lib/goals/classify.test.mjs +109 -0
- package/lib/goals/collaborate.mjs +415 -0
- package/lib/goals/collaborate.test.mjs +324 -0
- package/lib/goals/gaps.mjs +111 -0
- package/lib/goals/gaps.test.mjs +284 -0
- package/lib/goals/loop.mjs +537 -0
- package/lib/goals/loop.test.mjs +719 -0
- package/lib/identity/persona.mjs +247 -0
- package/lib/identity/persona.test.mjs +117 -0
- package/lib/kpi.mjs +469 -0
- package/lib/kpi.test.mjs +244 -0
- package/lib/mandate/audit.mjs +168 -0
- package/lib/mandate/audit.test.mjs +195 -0
- package/lib/mandate/cache.mjs +162 -0
- package/lib/mandate/derive.mjs +317 -0
- package/lib/mandate/derive.test.mjs +224 -0
- package/lib/mandate/model.mjs +352 -0
- package/lib/mandate/model.test.mjs +145 -0
- package/lib/mandate/refresh.mjs +187 -0
- package/lib/mandate/refresh.test.mjs +293 -0
- package/lib/mcp/server.test.mjs +4 -4
- package/lib/org/approvals.mjs +14 -2
- package/lib/org/client.mjs +58 -22
- package/lib/org/client.test.mjs +3 -1
- package/lib/org/inbound/directedness.mjs +720 -0
- package/lib/org/inbound/directedness.test.mjs +543 -0
- package/lib/org/inbound/facts.mjs +501 -0
- package/lib/org/inbound/facts.test.mjs +375 -0
- package/lib/org/inbound/hydrate.mjs +535 -0
- package/lib/org/inbound/hydrate.test.mjs +326 -0
- package/lib/org/inbound/index.mjs +233 -0
- package/lib/org/inbound/index.test.mjs +324 -0
- package/lib/org/inbound/io.mjs +141 -0
- package/lib/org/inbound/project.mjs +201 -0
- package/lib/org/inbound/project.test.mjs +287 -0
- package/lib/org/inbound/surfaces.mjs +257 -0
- package/lib/org/knowledge.mjs +10 -1
- package/lib/org/knowledge.test.mjs +8 -1
- package/lib/org/leases.mjs +5 -0
- package/lib/org/mesh.mjs +17 -2
- package/lib/org/messaging.mjs +40 -4
- package/lib/org/messaging.test.mjs +40 -0
- package/lib/org/param-contract.mjs +694 -0
- package/lib/org/param-contract.test.mjs +451 -0
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +8 -0
- package/lib/org/protocol.test.mjs +5 -1
- package/lib/org/push.mjs +1025 -0
- package/lib/org/push.test.mjs +690 -0
- package/lib/org/tool-surface.mjs +138 -38
- package/lib/org/tool-surface.test.mjs +13 -8
- package/lib/org/typing.mjs +341 -0
- package/lib/org/typing.test.mjs +291 -0
- package/lib/plan/compile.mjs +510 -0
- package/lib/plan/compile.test.mjs +286 -0
- package/lib/plan/emit.mjs +256 -0
- package/lib/plan/emit.test.mjs +246 -0
- package/lib/plan/explain.mjs +226 -0
- package/lib/plan/explain.test.mjs +188 -0
- package/lib/plan/schema.mjs +140 -0
- package/lib/resource-governor.mjs +47 -1
- package/lib/resource-governor.test.mjs +21 -1
- package/lib/setup/enroll-from-cohort.mjs +84 -16
- package/lib/setup/enroll-from-cohort.test.mjs +43 -1
- package/lib/setup/sections/identity.mjs +15 -4
- package/lib/setup/sections/identity.test.mjs +94 -0
- package/lib/setup/sections/inventory.mjs +178 -0
- package/lib/setup/sections/inventory.test.mjs +198 -0
- package/lib/setup/sections/mandate.mjs +392 -0
- package/lib/setup/sections/mandate.test.mjs +373 -0
- package/lib/setup/sections/subagents.mjs +427 -0
- package/lib/setup/sections/subagents.test.mjs +429 -0
- package/lib/setup/sections/verify.mjs +121 -0
- package/lib/setup/sections/verify.test.mjs +175 -0
- package/lib/setup/sot.mjs +2 -0
- package/lib/subagents/cli.mjs +463 -0
- package/lib/subagents/cli.test.mjs +389 -0
- package/lib/subagents/client.mjs +373 -0
- package/lib/subagents/client.test.mjs +309 -0
- package/lib/subagents/gap.mjs +268 -0
- package/lib/subagents/gap.test.mjs +234 -0
- package/lib/subagents/lock.mjs +296 -0
- package/lib/subagents/lock.test.mjs +248 -0
- package/lib/subagents/manifest.mjs +224 -0
- package/lib/subagents/manifest.test.mjs +175 -0
- package/lib/subagents/refs.mjs +274 -0
- package/lib/subagents/refs.test.mjs +204 -0
- package/lib/subagents/resolve.mjs +455 -0
- package/lib/subagents/resolve.test.mjs +422 -0
- package/lib/subagents/schema.mjs +467 -0
- package/lib/subagents/schema.test.mjs +306 -0
- package/package.json +8 -3
- package/plugins/maestro-skills/.claude-plugin/marketplace.json +16 -0
- package/policies/ai-disclosure.yaml +42 -2
- package/scaffold/CLAUDE.md +16 -2
- package/schedules/triggers/goal-steward.md +79 -0
- package/scripts/ci/conformance-org-api.mjs +792 -0
- package/scripts/ci/conformance-org-api.test.mjs +417 -0
- package/scripts/daemon/agent-daemon.mjs +36 -4
- package/scripts/daemon/cadence-handlers.mjs +145 -1
- package/scripts/daemon/goal-steward-cadence.test.mjs +243 -0
- package/scripts/daemon/inbox-deferral.mjs +45 -2
- package/scripts/daemon/inbox-deferral.test.mjs +56 -0
- package/scripts/daemon/inbox-wake.mjs +282 -0
- package/scripts/daemon/inbox-wake.test.mjs +199 -0
- package/scripts/daemon/prompt-builder.mjs +41 -1
- package/scripts/daemon/typing-registry.mjs +55 -2
- package/scripts/daemon/typing-registry.test.mjs +25 -0
- package/scripts/local-triggers/generate-plists.test.mjs +5 -5
- package/scripts/setup/gen-subagent-manifest.mjs +95 -0
- package/scripts/setup/gen-subagent-manifest.test.mjs +124 -0
- package/scripts/setup/generate-plan.mjs +108 -0
- package/scripts/setup/init-capability-manifest.mjs +70 -0
- package/scripts/setup/init-skill-marketplace.mjs +155 -0
- package/scripts/setup/init-skill-marketplace.test.mjs +193 -0
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/org/inbound/hydrate.mjs — per-family body + context hydration.
|
|
3
|
+
*
|
|
4
|
+
* The chain is an AUDIT log, not a content feed: hq redacts every payload down
|
|
5
|
+
* to ids, counts and flags, so a projected event arrives with `text: ""`. An
|
|
6
|
+
* empty inbound is WORSE than no inbound — the classifier sees nothing to
|
|
7
|
+
* classify and the agent "replies" to a blank message. This module re-joins the
|
|
8
|
+
* content, and it does so through the agent's OWN entitled reads:
|
|
9
|
+
*
|
|
10
|
+
* | surface | read | ACL hq applies |
|
|
11
|
+
* |----------------|-----------------------------------------|-----------------------------|
|
|
12
|
+
* | dm / mention | messaging.history (already in facts) | assertChannelAccess |
|
|
13
|
+
* | thread_reply | messaging.history (already in facts) | assertChannelAccess |
|
|
14
|
+
* | call | (payload only — topic + callId) | — |
|
|
15
|
+
* | task_* | board.taskComments | org-scoped task |
|
|
16
|
+
* | file_comment | file.listComments | assertChannelVisible |
|
|
17
|
+
* | doc_comment | files.comments | doc ACL (NOT_FOUND if none) |
|
|
18
|
+
* | decision | decision.listComments | org-scoped |
|
|
19
|
+
* | escalation | escalation.list (already in facts) | org-scoped |
|
|
20
|
+
* | approval | approval.get (already in facts) | org-scoped |
|
|
21
|
+
* | handoff | (payload only — intent + itemId) | — |
|
|
22
|
+
* | email | email.thread / email.message | OWN MAILBOX ONLY |
|
|
23
|
+
*
|
|
24
|
+
* Because every one of those is the same read the agent could make by hand,
|
|
25
|
+
* hydration can never widen what the agent may see — it only re-joins content
|
|
26
|
+
* it was always entitled to. And it runs ONLY for candidates already ruled
|
|
27
|
+
* directed, so an event that is not mine costs zero body reads.
|
|
28
|
+
*
|
|
29
|
+
* Fail-open, never silent: a hydration that fails returns `{ok:false, reason}`
|
|
30
|
+
* and the caller drops the item rather than delivering it blank; `io.log`
|
|
31
|
+
* carries the failure.
|
|
32
|
+
*
|
|
33
|
+
* @module lib/org/inbound/hydrate
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
"use strict";
|
|
37
|
+
|
|
38
|
+
import { arrayOf, resultOf } from "./io.mjs";
|
|
39
|
+
|
|
40
|
+
/** A blank-safe string. */
|
|
41
|
+
function s(v) {
|
|
42
|
+
return v == null ? "" : String(v);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Trim to a sane inbox size without cutting mid-word where avoidable. */
|
|
46
|
+
export function clip(text, max = 4000) {
|
|
47
|
+
const t = s(text);
|
|
48
|
+
if (t.length <= max) return t;
|
|
49
|
+
const cut = t.slice(0, max);
|
|
50
|
+
const sp = cut.lastIndexOf(" ");
|
|
51
|
+
return `${sp > max * 0.8 ? cut.slice(0, sp) : cut}…`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Render a comment list into a compact prior-thread context block. */
|
|
55
|
+
export function renderThread(comments, { limit = 8, max = 2000 } = {}) {
|
|
56
|
+
const rows = (Array.isArray(comments) ? comments : []).slice(-limit);
|
|
57
|
+
if (rows.length === 0) return null;
|
|
58
|
+
const lines = rows.map((c) => {
|
|
59
|
+
const who = s(c.authorName || c.authorId || c.author || "someone");
|
|
60
|
+
const body = s(c.body || c.text).replace(/\s+/g, " ").trim();
|
|
61
|
+
return `${who}: ${body}`;
|
|
62
|
+
});
|
|
63
|
+
return clip(lines.join("\n"), max) || null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @typedef {Object} Hydrated
|
|
68
|
+
* @property {boolean} ok
|
|
69
|
+
* @property {string} [reason] why hydration failed (drop reason)
|
|
70
|
+
* @property {string} text the body the classifier/responder reads
|
|
71
|
+
* @property {string} [subjectDetail] appended to the surface's subject
|
|
72
|
+
* @property {string} [threadId]
|
|
73
|
+
* @property {string|null} [threadContext]
|
|
74
|
+
* @property {{id:string,name:string}} [from]
|
|
75
|
+
* @property {string} [channelLabel]
|
|
76
|
+
* @property {string} [channelId]
|
|
77
|
+
* @property {boolean} [isReply]
|
|
78
|
+
*/
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Hydrate ONE directed candidate. `cache` is a per-pull Map so two candidates on
|
|
82
|
+
* the same task/decision/doc share a single read.
|
|
83
|
+
*
|
|
84
|
+
* @param {Object} o
|
|
85
|
+
* @param {import("./directedness.mjs").Candidate} o.candidate
|
|
86
|
+
* @param {{surface:string, reason:string}} o.verdict
|
|
87
|
+
* @param {import("./facts.mjs").Facts} o.facts
|
|
88
|
+
* @param {import("./io.mjs").InboundIo} o.io
|
|
89
|
+
* @param {Map<string,any>} [o.cache]
|
|
90
|
+
* @returns {Promise<Hydrated>}
|
|
91
|
+
*/
|
|
92
|
+
export async function hydrate(o = {}) {
|
|
93
|
+
const { candidate: c, verdict, facts, io } = o;
|
|
94
|
+
const cache = o.cache instanceof Map ? o.cache : new Map();
|
|
95
|
+
if (!c) return { ok: false, reason: "no_candidate", text: "" };
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
switch (c.family) {
|
|
99
|
+
case "messaging":
|
|
100
|
+
return hydrateMessage(c, facts);
|
|
101
|
+
case "calling":
|
|
102
|
+
return hydrateCall(c, facts);
|
|
103
|
+
case "board":
|
|
104
|
+
return await hydrateTask(c, verdict, facts, io, cache);
|
|
105
|
+
case "file":
|
|
106
|
+
return await hydrateChatFile(c, facts, io, cache);
|
|
107
|
+
case "files":
|
|
108
|
+
return await hydrateDoc(c, facts, io, cache);
|
|
109
|
+
case "decision":
|
|
110
|
+
return await hydrateDecision(c, facts, io, cache);
|
|
111
|
+
case "escalation":
|
|
112
|
+
return hydrateEscalation(c, facts);
|
|
113
|
+
case "approval":
|
|
114
|
+
return hydrateApproval(c, facts);
|
|
115
|
+
case "handoff":
|
|
116
|
+
return hydrateHandoff(c, facts);
|
|
117
|
+
case "email":
|
|
118
|
+
return await hydrateEmail(c, facts, io, cache);
|
|
119
|
+
default:
|
|
120
|
+
return { ok: false, reason: "no_hydrator", text: "" };
|
|
121
|
+
}
|
|
122
|
+
} catch (err) {
|
|
123
|
+
// Fail-open but NEVER silent: one bad row must not take the pull down.
|
|
124
|
+
if (io && io.log) {
|
|
125
|
+
io.log("warn", `[inbound] hydrate ${c.family} threw`, {
|
|
126
|
+
error: err && err.message ? err.message : String(err),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return { ok: false, reason: "hydrate_threw", text: "" };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Per-family hydrators
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
/** Messaging: the body already came back on the ACL'd history page. */
|
|
138
|
+
function hydrateMessage(c, facts) {
|
|
139
|
+
const page = facts.channelPages instanceof Map ? facts.channelPages.get(s(c.ids.channelId)) : null;
|
|
140
|
+
const msg = page && page.byId ? page.byId.get(s(c.ids.messageId)) : null;
|
|
141
|
+
if (!msg) return { ok: false, reason: "message_not_in_page", text: "" };
|
|
142
|
+
const body = s(msg.body ?? msg.text).trim();
|
|
143
|
+
if (!body) return { ok: false, reason: "empty_body", text: "" };
|
|
144
|
+
|
|
145
|
+
const chan = facts.channels instanceof Map ? facts.channels.get(s(c.ids.channelId)) : null;
|
|
146
|
+
const root = s(msg.threadRootId || msg.thread_root_id);
|
|
147
|
+
let threadContext = null;
|
|
148
|
+
if (root && page) {
|
|
149
|
+
const siblings = page.messages.filter(
|
|
150
|
+
(m) => s(m.threadRootId || m.thread_root_id) === root || s(m.id) === root,
|
|
151
|
+
);
|
|
152
|
+
threadContext = renderThread(
|
|
153
|
+
siblings
|
|
154
|
+
.filter((m) => s(m.id) !== s(c.ids.messageId))
|
|
155
|
+
.map((m) => ({ authorName: m.authorName || m.authorId, body: m.body ?? m.text })),
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
ok: true,
|
|
161
|
+
text: clip(body),
|
|
162
|
+
threadId: root,
|
|
163
|
+
threadContext,
|
|
164
|
+
isReply: Boolean(root),
|
|
165
|
+
from: { id: s(msg.authorId || c.actor), name: s(msg.authorName || msg.authorId || c.actor) },
|
|
166
|
+
channelId: s(c.ids.channelId),
|
|
167
|
+
channelLabel: s((chan && (chan.name || chan.slug)) || c.ids.channelId),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Calls: the invite topic is the whole content; there is no body to fetch. */
|
|
172
|
+
function hydrateCall(c, facts) {
|
|
173
|
+
const chan = facts.channels instanceof Map ? facts.channels.get(s(c.ids.channelId)) : null;
|
|
174
|
+
const topic = s(c.ids.topic);
|
|
175
|
+
return {
|
|
176
|
+
ok: true,
|
|
177
|
+
text: topic ? `Call invite: ${topic}` : "Call invite",
|
|
178
|
+
subjectDetail: topic,
|
|
179
|
+
threadId: s(c.ids.callId),
|
|
180
|
+
from: { id: s(c.actor), name: s(c.actor) },
|
|
181
|
+
channelId: s(c.ids.channelId),
|
|
182
|
+
channelLabel: s((chan && (chan.name || chan.slug)) || c.ids.channelId || "cohort"),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Board: the task card from `board.context` plus its comment thread. */
|
|
187
|
+
async function hydrateTask(c, verdict, facts, io, cache) {
|
|
188
|
+
const taskId = s(c.ids.taskId);
|
|
189
|
+
const task = facts.tasks instanceof Map ? facts.tasks.get(taskId) : null;
|
|
190
|
+
const comments = await cached(cache, `taskComments:${taskId}`, async () => {
|
|
191
|
+
const frame = await io.call("board.taskComments", { taskId });
|
|
192
|
+
return frame && frame.ok === true ? arrayOf(resultOf(frame), "comments") : [];
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const title = s(task && task.title) || taskId;
|
|
196
|
+
const latest = comments.length ? comments[comments.length - 1] : null;
|
|
197
|
+
const verb = describeBoardKind(c.kind);
|
|
198
|
+
const lines = [`${verb} — "${title}"`];
|
|
199
|
+
if (task && task.col) lines.push(`Column: ${task.col}${task.priority ? ` · priority ${task.priority}` : ""}`);
|
|
200
|
+
if (task && task.detail) lines.push(`Detail: ${clip(task.detail, 600)}`);
|
|
201
|
+
if (task && task.dueAt) lines.push(`Due: ${s(task.dueAt)}`);
|
|
202
|
+
if (latest) lines.push(`Latest comment (${s(latest.authorName || latest.authorId)}): ${clip(latest.body, 800)}`);
|
|
203
|
+
if (c.payload && c.payload.reason) lines.push(`Reason: ${clip(c.payload.reason, 400)}`);
|
|
204
|
+
if (verdict && verdict.reason === "reviewer") lines.push("You are the REVIEWER on this item.");
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
ok: true,
|
|
208
|
+
text: clip(lines.join("\n")),
|
|
209
|
+
subjectDetail: title,
|
|
210
|
+
threadId: taskId,
|
|
211
|
+
threadContext: renderThread(comments.slice(0, -1)),
|
|
212
|
+
isReply: comments.length > 1,
|
|
213
|
+
from: { id: s(c.actor), name: s((latest && latest.authorName) || c.actor) },
|
|
214
|
+
channelId: s(c.ids.channelId || (task && task.channelId) || ""),
|
|
215
|
+
channelLabel: `task/${title}`,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** A chat-attached file's comment thread (`file.listComments`). */
|
|
220
|
+
async function hydrateChatFile(c, facts, io, cache) {
|
|
221
|
+
const fileKey = s(c.ids.fileKey);
|
|
222
|
+
const channelId = s(c.ids.channelId);
|
|
223
|
+
const comments = await cached(cache, `fileComments:${fileKey}`, async () => {
|
|
224
|
+
const frame = await io.call("file.listComments", {
|
|
225
|
+
fileKey,
|
|
226
|
+
...(channelId ? { channelId } : {}),
|
|
227
|
+
});
|
|
228
|
+
return frame && frame.ok === true ? arrayOf(resultOf(frame), "comments") : [];
|
|
229
|
+
});
|
|
230
|
+
const latest = comments.length ? comments[comments.length - 1] : null;
|
|
231
|
+
if (!latest) return { ok: false, reason: "file_comment_unreadable", text: "" };
|
|
232
|
+
|
|
233
|
+
const chan = facts.channels instanceof Map ? facts.channels.get(channelId) : null;
|
|
234
|
+
return {
|
|
235
|
+
ok: true,
|
|
236
|
+
text: clip(`Comment on file ${fileKey}\n${s(latest.authorName || latest.authorId)}: ${s(latest.body)}`),
|
|
237
|
+
subjectDetail: fileKey,
|
|
238
|
+
threadId: fileKey,
|
|
239
|
+
threadContext: renderThread(comments.slice(0, -1)),
|
|
240
|
+
isReply: comments.length > 1,
|
|
241
|
+
from: { id: s(latest.authorId || c.actor), name: s(latest.authorName || latest.authorId || c.actor) },
|
|
242
|
+
channelId,
|
|
243
|
+
channelLabel: s((chan && (chan.name || chan.slug)) || `file/${fileKey}`),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** A workspace doc's comment thread (`files.comments`). */
|
|
248
|
+
async function hydrateDoc(c, facts, io, cache) {
|
|
249
|
+
const fileId = s(c.ids.fileId);
|
|
250
|
+
const comments = await cached(cache, `docComments:${fileId}`, async () => {
|
|
251
|
+
const frame = await io.call("files.comments", { fileId, limit: 50 });
|
|
252
|
+
return frame && frame.ok === true ? arrayOf(resultOf(frame), "comments") : [];
|
|
253
|
+
});
|
|
254
|
+
const acl = facts.fileAcl instanceof Map ? facts.fileAcl.get(fileId) : null;
|
|
255
|
+
const name = s(c.ids.name || (acl && acl.name) || fileId);
|
|
256
|
+
const latest = comments.length ? comments[comments.length - 1] : null;
|
|
257
|
+
const head = describeFilesKind(c.kind, name);
|
|
258
|
+
const text = latest
|
|
259
|
+
? `${head}\n${s(latest.authorName || latest.authorId)}: ${s(latest.body)}`
|
|
260
|
+
: head;
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
ok: true,
|
|
264
|
+
text: clip(text),
|
|
265
|
+
subjectDetail: name,
|
|
266
|
+
threadId: fileId,
|
|
267
|
+
threadContext: renderThread(comments.slice(0, -1)),
|
|
268
|
+
isReply: comments.length > 1,
|
|
269
|
+
from: { id: s((latest && latest.authorId) || c.actor), name: s((latest && latest.authorName) || c.actor) },
|
|
270
|
+
channelId: "",
|
|
271
|
+
channelLabel: `doc/${name}`,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** A decision, its rationale, and its comment thread. */
|
|
276
|
+
async function hydrateDecision(c, facts, io, cache) {
|
|
277
|
+
const decisionId = s(c.ids.decisionId);
|
|
278
|
+
const d = facts.decisions instanceof Map ? facts.decisions.get(decisionId) : null;
|
|
279
|
+
const comments = await cached(cache, `decisionComments:${decisionId}`, async () => {
|
|
280
|
+
const frame = await io.call("decision.listComments", { decisionId });
|
|
281
|
+
return frame && frame.ok === true ? arrayOf(resultOf(frame), "comments") : [];
|
|
282
|
+
});
|
|
283
|
+
const title = s((d && d.title) || c.ids.title || decisionId);
|
|
284
|
+
const latest = comments.length ? comments[comments.length - 1] : null;
|
|
285
|
+
|
|
286
|
+
const lines = [`${describeDecisionKind(c.kind)} — "${title}"`];
|
|
287
|
+
if (d && d.status) lines.push(`Status: ${s(d.status)}${d.scope ? ` · scope ${s(d.scope)}` : ""}`);
|
|
288
|
+
if (d && d.body) lines.push(`Body: ${clip(d.body, 800)}`);
|
|
289
|
+
if (d && d.rationale) lines.push(`Rationale: ${clip(typeof d.rationale === "string" ? d.rationale : JSON.stringify(d.rationale), 600)}`);
|
|
290
|
+
if (latest) lines.push(`Latest comment (${s(latest.authorName || latest.authorId)}): ${clip(latest.body, 800)}`);
|
|
291
|
+
if (c.kind === "decision.adjustment_requested") lines.push("An ADJUSTMENT was requested — this needs a response.");
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
ok: true,
|
|
295
|
+
text: clip(lines.join("\n")),
|
|
296
|
+
subjectDetail: title,
|
|
297
|
+
threadId: decisionId,
|
|
298
|
+
threadContext: renderThread(comments.slice(0, -1)),
|
|
299
|
+
isReply: comments.length > 1,
|
|
300
|
+
from: { id: s(c.ids.authorId || c.actor), name: s((latest && latest.authorName) || c.actor) },
|
|
301
|
+
channelId: "",
|
|
302
|
+
channelLabel: `decision/${title}`,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** An escalation — already fully read back by the facts pass. */
|
|
307
|
+
function hydrateEscalation(c, facts) {
|
|
308
|
+
const id = s(c.ids.escalationId);
|
|
309
|
+
const e = facts.escalations instanceof Map ? facts.escalations.get(id) : null;
|
|
310
|
+
const title = s((e && e.title) || id);
|
|
311
|
+
const lines = [`${c.kind === "escalation.resolved" ? "Escalation resolved" : "Escalation raised"} — "${title}"`];
|
|
312
|
+
if (e && e.severity) lines.push(`Severity: ${s(e.severity)}`);
|
|
313
|
+
if (e && e.detail) lines.push(clip(e.detail, 1200));
|
|
314
|
+
if (e && e.waitingOn) lines.push(`Waiting on: ${s(e.waitingOn)}`);
|
|
315
|
+
if (e && e.resolution) lines.push(`Resolution: ${clip(e.resolution, 600)}`);
|
|
316
|
+
return {
|
|
317
|
+
ok: true,
|
|
318
|
+
text: clip(lines.join("\n")),
|
|
319
|
+
subjectDetail: title,
|
|
320
|
+
threadId: id,
|
|
321
|
+
from: { id: s(c.actor), name: s(c.actor) },
|
|
322
|
+
channelId: s(c.ids.channelId || (e && e.channelId) || ""),
|
|
323
|
+
channelLabel: `escalation/${title}`,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** An approval — already read back by the facts pass (that IS the probe). */
|
|
328
|
+
function hydrateApproval(c, facts) {
|
|
329
|
+
const id = s(c.ids.approvalId);
|
|
330
|
+
const a = facts.approvals instanceof Map ? facts.approvals.get(id) : null;
|
|
331
|
+
const actionClass = s((a && a.actionClass) || c.ids.actionClass);
|
|
332
|
+
const lines = [`${describeApprovalKind(c.kind)} — ${actionClass || id}`];
|
|
333
|
+
if (a) {
|
|
334
|
+
lines.push(`Status: ${s(a.status)} · requester ${s(a.requester)}${a.approver ? ` · approver ${s(a.approver)}` : ""}`);
|
|
335
|
+
if (a.reason) lines.push(`Reason: ${clip(a.reason, 600)}`);
|
|
336
|
+
if (a.itemId) lines.push(`Board item: ${s(a.itemId)}`);
|
|
337
|
+
if (a.expiresAt) lines.push(`Expires: ${s(a.expiresAt)}`);
|
|
338
|
+
if (a.subject) {
|
|
339
|
+
lines.push(`Subject: ${clip(typeof a.subject === "string" ? a.subject : JSON.stringify(a.subject), 600)}`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
ok: true,
|
|
344
|
+
text: clip(lines.join("\n")),
|
|
345
|
+
subjectDetail: actionClass || id,
|
|
346
|
+
threadId: id,
|
|
347
|
+
from: { id: s((a && a.requester) || c.actor), name: s((a && a.requester) || c.actor) },
|
|
348
|
+
channelId: "",
|
|
349
|
+
channelLabel: `approval/${actionClass || id}`,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** A handoff — the payload already carries intent + both ends. */
|
|
354
|
+
function hydrateHandoff(c, facts) {
|
|
355
|
+
const taskId = s(c.ids.taskId);
|
|
356
|
+
const task = facts.tasks instanceof Map ? facts.tasks.get(taskId) : null;
|
|
357
|
+
const title = s((task && task.title) || taskId);
|
|
358
|
+
const lines = [`${describeHandoffKind(c.kind)}${title ? ` — "${title}"` : ""}`];
|
|
359
|
+
if (c.ids.intent) lines.push(`Intent: ${clip(c.ids.intent, 800)}`);
|
|
360
|
+
lines.push(`From ${s(c.ids.from)} → to ${s(c.ids.to)}`);
|
|
361
|
+
if (task && task.detail) lines.push(`Detail: ${clip(task.detail, 600)}`);
|
|
362
|
+
return {
|
|
363
|
+
ok: true,
|
|
364
|
+
text: clip(lines.join("\n")),
|
|
365
|
+
subjectDetail: title || s(c.ids.handoffId),
|
|
366
|
+
threadId: s(c.ids.runId || c.ids.handoffId),
|
|
367
|
+
from: { id: s(c.ids.from || c.actor), name: s(c.ids.from || c.actor) },
|
|
368
|
+
channelId: "",
|
|
369
|
+
channelLabel: `handoff/${title || s(c.ids.handoffId)}`,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Inbound email. `email.thread` is OWN-MAILBOX ONLY (hq
|
|
375
|
+
* `methods/email/thread.ts` pins `mailboxId: actingMailbox(ctx).id`), so this
|
|
376
|
+
* read is the ACL — another seat's mail is simply not returned.
|
|
377
|
+
*/
|
|
378
|
+
async function hydrateEmail(c, facts, io, cache) {
|
|
379
|
+
const threadId = s(c.ids.threadId);
|
|
380
|
+
const messageId = s(c.ids.emailMessageId);
|
|
381
|
+
|
|
382
|
+
const thread = threadId
|
|
383
|
+
? await cached(cache, `emailThread:${threadId}`, async () => {
|
|
384
|
+
const frame = await io.call("email.thread", { threadId });
|
|
385
|
+
return frame && frame.ok === true ? resultOf(frame) : null;
|
|
386
|
+
})
|
|
387
|
+
: null;
|
|
388
|
+
|
|
389
|
+
let message = null;
|
|
390
|
+
const messages = arrayOf(thread, "messages");
|
|
391
|
+
if (messageId) message = messages.find((m) => s(m.id) === messageId) || null;
|
|
392
|
+
if (!message && messages.length) message = messages[messages.length - 1];
|
|
393
|
+
if (!message && messageId) {
|
|
394
|
+
const frame = await io.call("email.message", { id: messageId });
|
|
395
|
+
message = frame && frame.ok === true ? resultOf(frame) : null;
|
|
396
|
+
}
|
|
397
|
+
if (!message) return { ok: false, reason: "email_unreadable", text: "" };
|
|
398
|
+
|
|
399
|
+
const subject = s(message.subject || (thread && thread.thread && thread.thread.subject));
|
|
400
|
+
const body = clip(s(message.text || message.bodyText || message.snippet || message.html));
|
|
401
|
+
if (!body) return { ok: false, reason: "empty_body", text: "" };
|
|
402
|
+
const fromAddr = s(
|
|
403
|
+
(message.from && (message.from.address || message.from.email || message.from.name)) || message.fromAddress,
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
return {
|
|
407
|
+
ok: true,
|
|
408
|
+
text: body,
|
|
409
|
+
subjectDetail: subject,
|
|
410
|
+
threadId,
|
|
411
|
+
threadContext: renderThread(
|
|
412
|
+
messages
|
|
413
|
+
.filter((m) => s(m.id) !== s(message.id))
|
|
414
|
+
.map((m) => ({
|
|
415
|
+
authorName: s((m.from && (m.from.name || m.from.address)) || m.fromAddress),
|
|
416
|
+
body: m.text || m.snippet,
|
|
417
|
+
})),
|
|
418
|
+
),
|
|
419
|
+
isReply: messages.length > 1,
|
|
420
|
+
from: { id: fromAddr, name: s((message.from && message.from.name) || fromAddr) },
|
|
421
|
+
channelId: s(c.ids.mailboxId),
|
|
422
|
+
channelLabel: "email",
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ---------------------------------------------------------------------------
|
|
427
|
+
// Small helpers
|
|
428
|
+
// ---------------------------------------------------------------------------
|
|
429
|
+
|
|
430
|
+
/** Memoise an async read for the lifetime of one pull. */
|
|
431
|
+
async function cached(cache, key, fn) {
|
|
432
|
+
if (cache.has(key)) return cache.get(key);
|
|
433
|
+
const v = await fn();
|
|
434
|
+
cache.set(key, v);
|
|
435
|
+
return v;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Human phrasing for a board event kind. */
|
|
439
|
+
export function describeBoardKind(kind) {
|
|
440
|
+
switch (kind) {
|
|
441
|
+
case "item.assigned":
|
|
442
|
+
return "A board item was assigned to you";
|
|
443
|
+
case "item.claimed":
|
|
444
|
+
return "A board item was claimed";
|
|
445
|
+
case "item.commented":
|
|
446
|
+
case "board.commented":
|
|
447
|
+
return "New comment on your board item";
|
|
448
|
+
case "board.blocked":
|
|
449
|
+
return "Your board item was BLOCKED";
|
|
450
|
+
case "item.moved":
|
|
451
|
+
return "Your board item moved column";
|
|
452
|
+
case "item.completed":
|
|
453
|
+
return "Your board item was completed";
|
|
454
|
+
case "item.attached":
|
|
455
|
+
return "A file was attached to your board item";
|
|
456
|
+
default:
|
|
457
|
+
return "Update on your board item";
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Human phrasing for a decision event kind. */
|
|
462
|
+
export function describeDecisionKind(kind) {
|
|
463
|
+
switch (kind) {
|
|
464
|
+
case "decision.proposed":
|
|
465
|
+
return "A decision was proposed";
|
|
466
|
+
case "decision.comment":
|
|
467
|
+
return "New comment on a decision";
|
|
468
|
+
case "decision.adjustment_requested":
|
|
469
|
+
return "An adjustment was requested on a decision";
|
|
470
|
+
case "decision.signed":
|
|
471
|
+
return "A decision was signed";
|
|
472
|
+
case "decision.adopted":
|
|
473
|
+
return "A decision was adopted";
|
|
474
|
+
case "decision.superseded":
|
|
475
|
+
return "A decision was superseded";
|
|
476
|
+
case "decision.reversed":
|
|
477
|
+
return "A decision was reversed";
|
|
478
|
+
default:
|
|
479
|
+
return "Decision update";
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Human phrasing for an approval event kind. */
|
|
484
|
+
export function describeApprovalKind(kind) {
|
|
485
|
+
if (kind === "approval.requested") return "An approval is waiting on you";
|
|
486
|
+
if (kind === "approval.approved") return "Your approval request was APPROVED";
|
|
487
|
+
if (kind === "approval.rejected") return "Your approval request was REJECTED";
|
|
488
|
+
if (kind === "approval.expired") return "An approval request EXPIRED";
|
|
489
|
+
return "Approval update";
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** Human phrasing for a handoff event kind. */
|
|
493
|
+
export function describeHandoffKind(kind) {
|
|
494
|
+
switch (kind) {
|
|
495
|
+
case "handoff.offered":
|
|
496
|
+
return "A handoff was offered to you";
|
|
497
|
+
case "handoff.accepted":
|
|
498
|
+
return "Your handoff was accepted";
|
|
499
|
+
case "handoff.declined":
|
|
500
|
+
return "Your handoff was declined";
|
|
501
|
+
case "handoff.cancelled":
|
|
502
|
+
return "A handoff was cancelled";
|
|
503
|
+
default:
|
|
504
|
+
return "Handoff update";
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** Human phrasing for a workspace-doc event kind. */
|
|
509
|
+
export function describeFilesKind(kind, name) {
|
|
510
|
+
switch (kind) {
|
|
511
|
+
case "doc.comment":
|
|
512
|
+
return `New comment on "${name}"`;
|
|
513
|
+
case "doc.suggested":
|
|
514
|
+
return `A suggestion was made on "${name}"`;
|
|
515
|
+
case "doc.suggestion_accepted":
|
|
516
|
+
return `A suggestion on "${name}" was accepted`;
|
|
517
|
+
case "doc.suggestion_rejected":
|
|
518
|
+
return `A suggestion on "${name}" was rejected`;
|
|
519
|
+
case "file.shared":
|
|
520
|
+
return `"${name}" was shared with you`;
|
|
521
|
+
default:
|
|
522
|
+
return `Update on "${name}"`;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
export default {
|
|
527
|
+
hydrate,
|
|
528
|
+
clip,
|
|
529
|
+
renderThread,
|
|
530
|
+
describeBoardKind,
|
|
531
|
+
describeDecisionKind,
|
|
532
|
+
describeApprovalKind,
|
|
533
|
+
describeHandoffKind,
|
|
534
|
+
describeFilesKind,
|
|
535
|
+
};
|