@cohortapp/agent-sdk 2.3.1 → 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/bin/maestro.mjs +37 -50
- 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/channels/inbox-item.mjs +20 -0
- 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 +79 -25
- package/lib/org/client.test.mjs +54 -1
- package/lib/org/doctor.mjs +64 -0
- package/lib/org/doctor.test.mjs +31 -2
- 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 +45 -2
- package/lib/org/mesh.test.mjs +55 -0
- package/lib/org/messaging.mjs +180 -15
- package/lib/org/messaging.test.mjs +117 -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 +9 -4
- 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 +70 -11
- package/scripts/daemon/cadence-handlers.mjs +187 -5
- 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/maestro-daemon.mjs +23 -0
- package/scripts/daemon/prompt-builder.mjs +41 -1
- package/scripts/daemon/responder.mjs +56 -0
- 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/poller/inbox-scan-poller.mjs +26 -1
- package/scripts/poller/inbox-scan-poller.test.mjs +64 -0
- package/scripts/poller/slack-cloud-relay-client.mjs +5 -0
- package/scripts/poller/slack-poller.mjs +32 -0
- package/scripts/poller/slack-socket-mode.mjs +27 -1
- package/scripts/poller/slack-socket-mode.test.mjs +52 -0
- package/scripts/poller/utils.mjs +47 -0
- 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,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* index.test.mjs — end-to-end `pullWideInbound` over an injected fake org.
|
|
3
|
+
*
|
|
4
|
+
* One realistic ledger window carrying ten families is pulled and the result is
|
|
5
|
+
* asserted twice: as MessageEvents, and as the flat inbox items the daemon
|
|
6
|
+
* actually consumes. The negative half is asserted by CALL LOG — a private room
|
|
7
|
+
* this agent is not in must produce no history fetch, no body, and no item.
|
|
8
|
+
*
|
|
9
|
+
* Run: node --test lib/org/inbound/index.test.mjs
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
"use strict";
|
|
13
|
+
|
|
14
|
+
import { test } from "node:test";
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
|
|
17
|
+
import { pullWideInbound } from "./index.mjs";
|
|
18
|
+
import { eventToInboxItem } from "../../channels/inbox-item.mjs";
|
|
19
|
+
|
|
20
|
+
const ME = "M-me";
|
|
21
|
+
const THEM = "M-them";
|
|
22
|
+
const BOSS = "M-boss";
|
|
23
|
+
|
|
24
|
+
const okFrame = (result) => ({ ok: true, result });
|
|
25
|
+
const okRead = (payload) => ({ ok: true, payload, status: 200 });
|
|
26
|
+
|
|
27
|
+
/** An org fake: routes for `call` + `read`, with a full call log. */
|
|
28
|
+
function fakeIo(routes = {}) {
|
|
29
|
+
const calls = [];
|
|
30
|
+
const logs = [];
|
|
31
|
+
return {
|
|
32
|
+
calls,
|
|
33
|
+
logs,
|
|
34
|
+
stats: { calls: 0, reads: 0, failures: 0, methods: [] },
|
|
35
|
+
log: (level, message, meta) => logs.push({ level, message, meta }),
|
|
36
|
+
async call(method, params) {
|
|
37
|
+
calls.push({ method, params });
|
|
38
|
+
this.stats.calls += 1;
|
|
39
|
+
const h = routes[method];
|
|
40
|
+
if (!h) return { ok: false, error: { code: "NOT_FOUND", message: method } };
|
|
41
|
+
return typeof h === "function" ? await h(params) : h;
|
|
42
|
+
},
|
|
43
|
+
async read(path) {
|
|
44
|
+
const base = String(path).split("?")[0];
|
|
45
|
+
calls.push({ method: `read:${base}`, path });
|
|
46
|
+
this.stats.reads += 1;
|
|
47
|
+
const h = routes[`read:${base}`];
|
|
48
|
+
if (!h) return { ok: false, error: { code: "NOT_FOUND", message: base }, status: 404 };
|
|
49
|
+
return typeof h === "function" ? await h(path) : h;
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A cfg that passes `isEnabled` + `configFromAgent`. */
|
|
55
|
+
const CFG = { org: { cohort: { enabled: true, base: "https://os.cohortapp.com/adaptic", token: "tok" } } };
|
|
56
|
+
|
|
57
|
+
// A ledger window with one row per surface, plus three rows that must NOT reach me.
|
|
58
|
+
const LEDGER = [
|
|
59
|
+
// (1) DM to me
|
|
60
|
+
{ seq: 1, family: "messaging", kind: "send", entity_id: "m-dm", actor: THEM, at: "2026-08-11T09:00:00.000Z",
|
|
61
|
+
payload: { actor: THEM, channelId: "C-dm", channelKind: "DM", mentionCount: 0, threaded: false } },
|
|
62
|
+
// (2) @mention in a public space — THE headline gap
|
|
63
|
+
{ seq: 2, family: "messaging", kind: "send", entity_id: "m-mention", actor: THEM, at: "2026-08-11T09:01:00.000Z",
|
|
64
|
+
payload: { actor: THEM, channelId: "C-pub", channelKind: "PUBLIC", mentionCount: 1, threaded: false } },
|
|
65
|
+
// (3) thread reply in a thread I started
|
|
66
|
+
{ seq: 3, family: "messaging", kind: "send", entity_id: "m-thread", actor: THEM, at: "2026-08-11T09:02:00.000Z",
|
|
67
|
+
payload: { actor: THEM, channelId: "C-pub", channelKind: "PUBLIC", mentionCount: 0, threaded: true } },
|
|
68
|
+
// (4) NEGATIVE: someone else's DM
|
|
69
|
+
{ seq: 4, family: "messaging", kind: "send", entity_id: "m-theirs", actor: THEM, at: "2026-08-11T09:03:00.000Z",
|
|
70
|
+
payload: { actor: THEM, channelId: "C-theirs", channelKind: "DM", mentionCount: 0, threaded: false } },
|
|
71
|
+
// (5) NEGATIVE: ambient public chatter
|
|
72
|
+
{ seq: 5, family: "messaging", kind: "send", entity_id: "m-noise", actor: THEM, at: "2026-08-11T09:04:00.000Z",
|
|
73
|
+
payload: { actor: THEM, channelId: "C-pub", channelKind: "PUBLIC", mentionCount: 0, threaded: false } },
|
|
74
|
+
// (6) board assignment to me
|
|
75
|
+
{ seq: 6, family: "board", kind: "item.assigned", entity_id: "T-1", actor: BOSS, at: "2026-08-11T09:05:00.000Z",
|
|
76
|
+
payload: { actor: BOSS, assignee: ME, assignedBy: BOSS } },
|
|
77
|
+
// (7) comment on my task
|
|
78
|
+
{ seq: 7, family: "board", kind: "item.commented", entity_id: "T-1", actor: THEM, at: "2026-08-11T09:06:00.000Z",
|
|
79
|
+
payload: { actor: THEM, kind: "human" } },
|
|
80
|
+
// (8) NEGATIVE: comment on someone else's task
|
|
81
|
+
{ seq: 8, family: "board", kind: "item.commented", entity_id: "T-99", actor: THEM, at: "2026-08-11T09:07:00.000Z",
|
|
82
|
+
payload: { actor: THEM, kind: "human" } },
|
|
83
|
+
// (9) doc comment on a doc I own
|
|
84
|
+
{ seq: 9, family: "files", kind: "doc.comment", entity_id: "F-1", actor: THEM, at: "2026-08-11T09:08:00.000Z",
|
|
85
|
+
payload: { name: "Spec", commentId: "dc1", anchored: false } },
|
|
86
|
+
// (10) approval on my desk
|
|
87
|
+
{ seq: 10, family: "approval", kind: "approval.requested", entity_id: "A-1", actor: THEM, at: "2026-08-11T09:09:00.000Z",
|
|
88
|
+
payload: { requester: THEM, actionClass: "spend.over_1k", payloadHash: "h" } },
|
|
89
|
+
// (11) decision I proposed gets an adjustment request
|
|
90
|
+
{ seq: 11, family: "decision", kind: "decision.adjustment_requested", entity_id: "D-1", actor: THEM, at: "2026-08-11T09:10:00.000Z",
|
|
91
|
+
payload: { commentId: "c1", authorId: THEM, isAdjustment: true, length: 12 } },
|
|
92
|
+
// (12) handoff offered to me
|
|
93
|
+
{ seq: 12, family: "handoff", kind: "handoff.offered", entity_id: "R-1", actor: THEM, at: "2026-08-11T09:11:00.000Z",
|
|
94
|
+
payload: { itemId: "T-1", from: THEM, to: ME, intent: "take this over", handoffId: "H-1" } },
|
|
95
|
+
// (13) inbound email to my mailbox (surface OFF by default)
|
|
96
|
+
{ seq: 13, family: "email", kind: "received", entity_id: "EM-1", actor: "system", at: "2026-08-11T09:12:00.000Z",
|
|
97
|
+
payload: { emailMessageId: "EM-1", threadId: "ET-1", mailboxId: "MB-1", memberId: ME, autoSubmitted: false } },
|
|
98
|
+
// (14) noise family the daemon must never be woken for
|
|
99
|
+
{ seq: 14, family: "presence", kind: "beat", entity_id: null, actor: THEM, at: "2026-08-11T09:13:00.000Z",
|
|
100
|
+
payload: { focus: "x" } },
|
|
101
|
+
// (15) my OWN message — the echo that must never come back
|
|
102
|
+
{ seq: 15, family: "messaging", kind: "send", entity_id: "m-mine", actor: ME, at: "2026-08-11T09:14:00.000Z",
|
|
103
|
+
payload: { actor: ME, channelId: "C-pub", channelKind: "PUBLIC", mentionCount: 1, threaded: false } },
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
function routes(over = {}) {
|
|
107
|
+
return {
|
|
108
|
+
"read:events": okRead({ events: LEDGER }),
|
|
109
|
+
"messaging.channels": okFrame({
|
|
110
|
+
channels: [
|
|
111
|
+
{ id: "C-dm", kind: "DM", slug: "dm-them", name: "them" },
|
|
112
|
+
{ id: "C-pub", kind: "PUBLIC", slug: "general", name: "General" },
|
|
113
|
+
],
|
|
114
|
+
}),
|
|
115
|
+
"messaging.history": ({ channelId }) => {
|
|
116
|
+
if (channelId === "C-dm") {
|
|
117
|
+
return okFrame({ channelId, messages: [{ id: "m-dm", authorId: THEM, authorName: "Casey", body: "are you free at 3?", mentions: [] }] });
|
|
118
|
+
}
|
|
119
|
+
if (channelId === "C-pub") {
|
|
120
|
+
return okFrame({
|
|
121
|
+
channelId,
|
|
122
|
+
messages: [
|
|
123
|
+
{ id: "root-1", authorId: ME, authorName: "Isla", body: "kicking off the migration", mentions: [] },
|
|
124
|
+
{ id: "m-mention", authorId: THEM, authorName: "Casey", body: "@Isla can you review this?", mentions: [ME] },
|
|
125
|
+
{ id: "m-thread", authorId: THEM, authorName: "Casey", body: "one more thing on that", threadRootId: "root-1", mentions: [] },
|
|
126
|
+
{ id: "m-noise", authorId: THEM, authorName: "Casey", body: "standup at 10", mentions: [] },
|
|
127
|
+
{ id: "m-mine", authorId: ME, authorName: "Isla", body: "on it", mentions: [ME] },
|
|
128
|
+
],
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
throw new Error(`history fetched for a channel outside my roster: ${channelId}`);
|
|
132
|
+
},
|
|
133
|
+
"read:board.context": okRead({
|
|
134
|
+
lanes: [{ columns: { doing: [{ id: "T-1", title: "Migrate the DB", col: "doing", assigneeId: ME, reviewerId: null, detail: "zero downtime" }] } }],
|
|
135
|
+
}),
|
|
136
|
+
"board.taskComments": ({ taskId }) => okFrame({ taskId, comments: [{ id: "c1", authorId: THEM, authorName: "Casey", body: "how is this going?" }] }),
|
|
137
|
+
"files.get": ({ fileId }) =>
|
|
138
|
+
fileId === "F-1"
|
|
139
|
+
? okFrame({ file: { id: "F-1", name: "Spec", ownerId: ME }, permission: "owner" })
|
|
140
|
+
: { ok: false, error: { code: "NOT_FOUND", message: "no" } },
|
|
141
|
+
"files.comments": okFrame({ comments: [{ id: "dc1", authorId: THEM, authorName: "Casey", body: "can we cut section 4?" }] }),
|
|
142
|
+
"approval.get": ({ id }) => okFrame({ id, requester: THEM, approver: ME, status: "pending", actionClass: "spend.over_1k", reason: "vendor renewal" }),
|
|
143
|
+
"read:decision.list": okRead({ decisions: [{ id: "D-1", title: "Adopt X", status: "proposed", proposedBy: ME, body: "we adopt X" }] }),
|
|
144
|
+
"decision.listComments": okFrame({ comments: [{ id: "c1", authorId: THEM, authorName: "Casey", body: "what about Y?" }] }),
|
|
145
|
+
"email.thread": ({ threadId }) =>
|
|
146
|
+
okFrame({ thread: { id: threadId, subject: "Contract" }, messages: [{ id: "EM-1", subject: "Contract", text: "please review", from: { name: "Dana", address: "dana@acme.com" } }] }),
|
|
147
|
+
...over,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
test("one pull delivers every surface the agent is entitled to", async () => {
|
|
154
|
+
const io = fakeIo(routes());
|
|
155
|
+
const { events, nextCursor, stats } = await pullWideInbound({ cfg: CFG, agentId: ME, cursor: 0, io });
|
|
156
|
+
|
|
157
|
+
assert.equal(nextCursor, 15, "the cursor advances past every scanned row, delivered or not");
|
|
158
|
+
const bySurface = {};
|
|
159
|
+
for (const e of events) bySurface[e.cohort.surface] = (bySurface[e.cohort.surface] || 0) + 1;
|
|
160
|
+
|
|
161
|
+
assert.deepEqual(bySurface, {
|
|
162
|
+
dm: 1,
|
|
163
|
+
mention: 1,
|
|
164
|
+
thread_reply: 1,
|
|
165
|
+
task_assigned: 1,
|
|
166
|
+
task_comment: 1,
|
|
167
|
+
doc_comment: 1,
|
|
168
|
+
approval: 1,
|
|
169
|
+
decision: 1,
|
|
170
|
+
handoff: 1,
|
|
171
|
+
});
|
|
172
|
+
assert.equal(stats.scanned, 15);
|
|
173
|
+
assert.equal(stats.delivered, 9);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("the typed reason rides along on every delivered item", async () => {
|
|
177
|
+
const io = fakeIo(routes());
|
|
178
|
+
const { events } = await pullWideInbound({ cfg: CFG, agentId: ME, io });
|
|
179
|
+
const reasons = Object.fromEntries(events.map((e) => [e.cohort.surface, e.cohort.reason]));
|
|
180
|
+
assert.deepEqual(reasons, {
|
|
181
|
+
dm: "dm",
|
|
182
|
+
mention: "mention",
|
|
183
|
+
thread_reply: "thread",
|
|
184
|
+
task_assigned: "assignee",
|
|
185
|
+
task_comment: "assignee",
|
|
186
|
+
doc_comment: "owner",
|
|
187
|
+
approval: "approver",
|
|
188
|
+
decision: "proposer",
|
|
189
|
+
handoff: "direct",
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("NEGATIVE: nothing from someone else's private room, my own echo, or ambient noise", async () => {
|
|
194
|
+
const io = fakeIo(routes());
|
|
195
|
+
const { events, stats } = await pullWideInbound({ cfg: CFG, agentId: ME, io });
|
|
196
|
+
|
|
197
|
+
const ids = events.map((e) => e.message_id);
|
|
198
|
+
assert.equal(ids.includes("m-theirs"), false, "someone else's DM must never be delivered");
|
|
199
|
+
assert.equal(ids.includes("m-mine"), false, "my own message must never come back");
|
|
200
|
+
assert.equal(ids.includes("m-noise"), false, "ambient public chatter is not inbound");
|
|
201
|
+
assert.equal(events.some((e) => e.cohort.entity_id === "T-99"), false, "another seat's task is not mine");
|
|
202
|
+
|
|
203
|
+
// Asserted by CALL LOG, not just by absence of output: the private room was
|
|
204
|
+
// never even probed.
|
|
205
|
+
const probed = io.calls.filter((c) => c.method === "messaging.history").map((c) => c.params.channelId);
|
|
206
|
+
assert.deepEqual(probed.sort(), ["C-dm", "C-pub"]);
|
|
207
|
+
assert.ok(stats.dropped.own_echo >= 1);
|
|
208
|
+
assert.ok(stats.dropped.channel_not_visible >= 1);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("email is OFF by default and is not even hydrated", async () => {
|
|
212
|
+
const io = fakeIo(routes());
|
|
213
|
+
const { events } = await pullWideInbound({ cfg: CFG, agentId: ME, io });
|
|
214
|
+
assert.equal(events.some((e) => e.cohort.surface === "email"), false);
|
|
215
|
+
assert.equal(io.calls.some((c) => c.method === "email.thread"), false, "a disabled surface costs nothing");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("email switches on explicitly (for a fleet with the orgmail adapter off)", async () => {
|
|
219
|
+
const io = fakeIo(routes());
|
|
220
|
+
const { events } = await pullWideInbound({ cfg: CFG, agentId: ME, io, surfaces: { email: true } });
|
|
221
|
+
const mail = events.find((e) => e.cohort.surface === "email");
|
|
222
|
+
assert.ok(mail, "email must be deliverable when enabled");
|
|
223
|
+
assert.equal(mail.kind, "email");
|
|
224
|
+
assert.equal(mail.text, "please review");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("a surface subset turns everything else off", async () => {
|
|
228
|
+
const io = fakeIo(routes());
|
|
229
|
+
const { events } = await pullWideInbound({ cfg: CFG, agentId: ME, io, surfaces: ["mention"] });
|
|
230
|
+
assert.deepEqual(events.map((e) => e.cohort.surface), ["mention"]);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("the delivered events survive eventToInboxItem with their routing hints intact", async () => {
|
|
234
|
+
const io = fakeIo(routes());
|
|
235
|
+
const { events } = await pullWideInbound({ cfg: CFG, agentId: ME, io });
|
|
236
|
+
const items = events.map(eventToInboxItem);
|
|
237
|
+
|
|
238
|
+
for (const item of items) {
|
|
239
|
+
assert.equal(item.service, "cohort");
|
|
240
|
+
assert.ok(item.id, "every item needs a dedup id");
|
|
241
|
+
assert.ok(item.content, "every item needs a body");
|
|
242
|
+
assert.ok(item.timestamp);
|
|
243
|
+
}
|
|
244
|
+
const kinds = items.map((i) => i.kind || "message").sort();
|
|
245
|
+
assert.deepEqual(kinds, [
|
|
246
|
+
"approval", "decision", "file_comment", "handoff", "mention",
|
|
247
|
+
"message", "task_assigned", "task_comment", "thread_reply",
|
|
248
|
+
]);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("the DM item is unchanged in shape from what ships today", async () => {
|
|
252
|
+
const io = fakeIo(routes());
|
|
253
|
+
const { events } = await pullWideInbound({ cfg: CFG, agentId: ME, io });
|
|
254
|
+
const dm = eventToInboxItem(events.find((e) => e.cohort.surface === "dm"));
|
|
255
|
+
assert.equal(dm.id, "cohort-m-dm");
|
|
256
|
+
assert.equal("kind" in dm, false);
|
|
257
|
+
assert.equal(dm.subject, "Cohort message");
|
|
258
|
+
assert.equal(dm.content, "are you free at 3?");
|
|
259
|
+
assert.equal(dm.sender, "Casey");
|
|
260
|
+
assert.equal(dm.source, "cohort");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
// failure modes
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
test("no agentId → refuses to pull, LOUDLY (own echoes would come back)", async () => {
|
|
268
|
+
const logs = [];
|
|
269
|
+
const io = fakeIo(routes());
|
|
270
|
+
const out = await pullWideInbound({ cfg: CFG, agentId: "", io, log: (l, m) => logs.push(`${l}:${m}`) });
|
|
271
|
+
assert.deepEqual(out.events, []);
|
|
272
|
+
assert.equal(io.calls.length, 0);
|
|
273
|
+
assert.ok(logs.some((l) => l.startsWith("error:") && /agentId/.test(l)));
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("org not configured → inert, no throw", async () => {
|
|
277
|
+
const out = await pullWideInbound({ cfg: { org: { cohort: { enabled: false } } }, agentId: ME });
|
|
278
|
+
assert.deepEqual(out.events, []);
|
|
279
|
+
assert.equal(out.nextCursor, 0);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("events read fails → cursor HELD so nothing is skipped", async () => {
|
|
283
|
+
const io = fakeIo({ ...routes(), "read:events": { ok: false, error: { code: "INTERNAL", message: "boom" }, status: 500 } });
|
|
284
|
+
const out = await pullWideInbound({ cfg: CFG, agentId: ME, cursor: 7, io });
|
|
285
|
+
assert.deepEqual(out.events, []);
|
|
286
|
+
assert.equal(out.nextCursor, 7);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("a degraded fact set closes surfaces rather than opening them", async () => {
|
|
290
|
+
// Roster unreadable: every messaging surface must go quiet, and say why.
|
|
291
|
+
const io = fakeIo({ ...routes(), "messaging.channels": { ok: false, error: { code: "INTERNAL", message: "down" } } });
|
|
292
|
+
const { events, stats } = await pullWideInbound({ cfg: CFG, agentId: ME, io });
|
|
293
|
+
assert.equal(events.some((e) => e.cohort.family === "messaging"), false);
|
|
294
|
+
assert.ok(stats.degraded.includes("channels"));
|
|
295
|
+
// The families that do not need the roster still work.
|
|
296
|
+
assert.ok(events.some((e) => e.cohort.surface === "task_assigned"));
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("an unhydratable directed item is dropped with a WARNING, not delivered blank", async () => {
|
|
300
|
+
const io = fakeIo({ ...routes(), "board.taskComments": { ok: false, error: { code: "INTERNAL", message: "down" } }, "files.get": { ok: false, error: { code: "INTERNAL", message: "down" } } });
|
|
301
|
+
const logs = [];
|
|
302
|
+
const { events } = await pullWideInbound({ cfg: CFG, agentId: ME, io, log: (l, m) => logs.push(`${l}:${m}`) });
|
|
303
|
+
// The board item still renders from the task card; the doc has no ACL entry so
|
|
304
|
+
// it never becomes directed in the first place.
|
|
305
|
+
assert.equal(events.some((e) => e.cohort.surface === "doc_comment"), false);
|
|
306
|
+
for (const e of events) assert.ok(e.text.trim(), "no blank body may ever be delivered");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("stats report what was spent and why things were dropped", async () => {
|
|
310
|
+
const io = fakeIo(routes());
|
|
311
|
+
const { stats } = await pullWideInbound({ cfg: CFG, agentId: ME, io });
|
|
312
|
+
assert.equal(stats.scanned, 15);
|
|
313
|
+
// 15 rows in, 1 (`presence`) has no rule at all → never becomes a candidate.
|
|
314
|
+
assert.equal(stats.classified, 14, "noise families are dropped before any RPC is spent");
|
|
315
|
+
assert.ok(stats.calls > 0 && stats.reads > 0);
|
|
316
|
+
assert.ok(Object.keys(stats.dropped).length > 0);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("an empty ledger window is a no-op that still advances nothing", async () => {
|
|
320
|
+
const io = fakeIo({ ...routes(), "read:events": okRead({ events: [] }) });
|
|
321
|
+
const out = await pullWideInbound({ cfg: CFG, agentId: ME, cursor: 5, io });
|
|
322
|
+
assert.deepEqual(out.events, []);
|
|
323
|
+
assert.equal(out.nextCursor, 5);
|
|
324
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/org/inbound/io.mjs — the ONE injectable network seam for wide inbound.
|
|
3
|
+
*
|
|
4
|
+
* Everything the directedness resolver and the hydrators need from hq goes
|
|
5
|
+
* through this object, so a test injects a plain fake and never touches the
|
|
6
|
+
* wire. It is a thin adapter over `lib/org/client.mjs` `call` / `read` that
|
|
7
|
+
* adds exactly three things the raw client does not:
|
|
8
|
+
*
|
|
9
|
+
* 1. Config resolution — `{cfg}` → `{base, token, orgId}` via the SAME
|
|
10
|
+
* `isEnabled` + `configFromAgent` chain every other org module uses, so a
|
|
11
|
+
* disabled agent is inert here for the same reason it is inert everywhere.
|
|
12
|
+
* 2. Uniform, NEVER-SILENT fail-open — every call returns
|
|
13
|
+
* `{ok:false, error}` instead of throwing, AND logs the failure with the
|
|
14
|
+
* method that failed. Silent fail-open has caused every bug in this area:
|
|
15
|
+
* a swallowed `registry.register` error is why no agent was ever in the
|
|
16
|
+
* directory, and a swallowed roster read is why DMs went missing.
|
|
17
|
+
* 3. A call counter, so the pull loop can report how much it spent and a test
|
|
18
|
+
* can assert that a private room was NEVER probed.
|
|
19
|
+
*
|
|
20
|
+
* @module lib/org/inbound/io
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
"use strict";
|
|
24
|
+
|
|
25
|
+
import { isEnabled, configFromAgent, call as clientCall, read as clientRead } from "../client.mjs";
|
|
26
|
+
|
|
27
|
+
/** A no-op logger with the daemon's `(level, message, meta?)` shape. */
|
|
28
|
+
export function noopLog() {}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve `{base, token, orgId}` from an agent cfg, or null when the org
|
|
32
|
+
* integration is disabled/unconfigured. Mirrors `lib/org/messaging.resolveOrg`.
|
|
33
|
+
* @param {object} cfg
|
|
34
|
+
* @returns {{base:string, token:string, orgId:string}|null}
|
|
35
|
+
*/
|
|
36
|
+
export function resolveOrg(cfg) {
|
|
37
|
+
if (!isEnabled(cfg)) return null;
|
|
38
|
+
const c = configFromAgent(cfg);
|
|
39
|
+
if (!c.base) return null;
|
|
40
|
+
return { base: c.base, token: c.token, orgId: c.orgId };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @typedef {Object} InboundIo
|
|
45
|
+
* @property {(method:string, params?:object)=>Promise<object>} call RPC → res frame
|
|
46
|
+
* @property {(path:string)=>Promise<object>} read GET → {ok,payload}
|
|
47
|
+
* @property {(level:string, msg:string, meta?:object)=>void} log
|
|
48
|
+
* @property {{calls:number, reads:number, failures:number, methods:string[]}} stats
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Build the real IO seam from an agent cfg. Returns null when the org
|
|
53
|
+
* integration is off — the caller then does nothing at all (fail-open).
|
|
54
|
+
*
|
|
55
|
+
* @param {{cfg:object, fetchImpl?:Function, log?:Function, timeoutMs?:number}} o
|
|
56
|
+
* @returns {InboundIo|null}
|
|
57
|
+
*/
|
|
58
|
+
export function makeIo(o = {}) {
|
|
59
|
+
const org = resolveOrg(o.cfg);
|
|
60
|
+
if (!org) return null;
|
|
61
|
+
const log = typeof o.log === "function" ? o.log : noopLog;
|
|
62
|
+
const conn = {
|
|
63
|
+
base: org.base,
|
|
64
|
+
token: org.token,
|
|
65
|
+
orgId: org.orgId,
|
|
66
|
+
fetchImpl: o.fetchImpl,
|
|
67
|
+
...(Number.isFinite(o.timeoutMs) ? { timeoutMs: o.timeoutMs } : {}),
|
|
68
|
+
};
|
|
69
|
+
const stats = { calls: 0, reads: 0, failures: 0, methods: [] };
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
stats,
|
|
73
|
+
log,
|
|
74
|
+
async call(method, params = {}) {
|
|
75
|
+
stats.calls += 1;
|
|
76
|
+
stats.methods.push(method);
|
|
77
|
+
try {
|
|
78
|
+
const frame = await clientCall(method, params, conn);
|
|
79
|
+
if (!frame || frame.ok !== true) {
|
|
80
|
+
stats.failures += 1;
|
|
81
|
+
// NEVER SILENT. A directedness probe that fails changes the verdict,
|
|
82
|
+
// so the operator has to be able to see it in the daemon log.
|
|
83
|
+
log("warn", `[inbound] ${method} failed`, {
|
|
84
|
+
code: frame && frame.error ? frame.error.code : "NO_FRAME",
|
|
85
|
+
message: frame && frame.error ? frame.error.message : "no frame",
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return frame;
|
|
89
|
+
} catch (err) {
|
|
90
|
+
stats.failures += 1;
|
|
91
|
+
log("warn", `[inbound] ${method} threw`, { error: err && err.message ? err.message : String(err) });
|
|
92
|
+
return { ok: false, error: { code: "INTERNAL", message: String((err && err.message) || err) } };
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
async read(path) {
|
|
96
|
+
stats.reads += 1;
|
|
97
|
+
stats.methods.push(`read:${String(path).split("?")[0]}`);
|
|
98
|
+
try {
|
|
99
|
+
const r = await clientRead(path, conn);
|
|
100
|
+
if (!r || r.ok !== true) {
|
|
101
|
+
stats.failures += 1;
|
|
102
|
+
log("warn", `[inbound] read ${path} failed`, {
|
|
103
|
+
code: r && r.error ? r.error.code : "NO_RESULT",
|
|
104
|
+
status: r ? r.status : 0,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return r;
|
|
108
|
+
} catch (err) {
|
|
109
|
+
stats.failures += 1;
|
|
110
|
+
log("warn", `[inbound] read ${path} threw`, { error: err && err.message ? err.message : String(err) });
|
|
111
|
+
return { ok: false, error: { code: "INTERNAL", message: String((err && err.message) || err) }, status: 0 };
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Unwrap an RPC res frame into its result, or null. Accepts the two shapes hq
|
|
119
|
+
* returns (`{ok:true, result}` and a bare object) without guessing.
|
|
120
|
+
* @param {object} frame
|
|
121
|
+
* @returns {object|null}
|
|
122
|
+
*/
|
|
123
|
+
export function resultOf(frame) {
|
|
124
|
+
if (!frame || frame.ok !== true) return null;
|
|
125
|
+
return frame.result !== undefined ? frame.result : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Pull a named array out of an RPC result, tolerating the bare-array form.
|
|
130
|
+
* @param {object|null} result
|
|
131
|
+
* @param {string} key e.g. "messages" | "channels" | "comments"
|
|
132
|
+
* @returns {object[]}
|
|
133
|
+
*/
|
|
134
|
+
export function arrayOf(result, key) {
|
|
135
|
+
if (Array.isArray(result)) return result;
|
|
136
|
+
if (result && Array.isArray(result[key])) return result[key];
|
|
137
|
+
if (result && Array.isArray(result.items)) return result.items;
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export default { makeIo, resolveOrg, resultOf, arrayOf, noopLog };
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/org/inbound/project.mjs — surface → the existing MessageEvent contract.
|
|
3
|
+
*
|
|
4
|
+
* The whole point of this module is that the DAEMON NEEDS NO CHANGE. The legacy
|
|
5
|
+
* ingestion path (`scripts/daemon/agent-daemon.mjs poll()` → classifier →
|
|
6
|
+
* directed gate → thread lease → responder → dispatcher) consumes a flat inbox
|
|
7
|
+
* item produced by `lib/channels/inbox-item.eventToInboxItem`. Everything a new
|
|
8
|
+
* surface carries therefore has to ride on fields that converter already
|
|
9
|
+
* forwards:
|
|
10
|
+
*
|
|
11
|
+
* kind → `item.kind` — the ROUTING HINT. One value per surface
|
|
12
|
+
* (see surfaces.mjs). `classifyByKind` returns null for all
|
|
13
|
+
* of them, so they fall through to the normal LLM classifier
|
|
14
|
+
* — correct, because a task assignment or an approval does
|
|
15
|
+
* need triage, unlike a reaction.
|
|
16
|
+
* subject → `item.subject` — "Cohort @mention: <channel>" etc.
|
|
17
|
+
* content → `item.content` — the hydrated body.
|
|
18
|
+
* channel/_id → the surface's own scope key (channel id, task id, …).
|
|
19
|
+
* thread_id → the reply anchor the responder replies INTO.
|
|
20
|
+
* thread_context → prior thread, already rendered.
|
|
21
|
+
* raw_ref → `cohort:<surface>:<entityId>:<seq>` — stable, greppable.
|
|
22
|
+
* priority_signals→ mentions_agent / tagged_urgent are real, not guessed.
|
|
23
|
+
* is_private → a CONFIDENTIALITY assertion the daemon's outcome gate
|
|
24
|
+
* reads. Set only when we actually know the room class.
|
|
25
|
+
*
|
|
26
|
+
* The DM and call projections are deliberately byte-compatible with what
|
|
27
|
+
* `lib/org/messaging.toMessageEvent` produces today (kind "message" / "call",
|
|
28
|
+
* `id: cohort-<messageId>`, `raw_ref: cohort:<channel>:<message>`), so turning
|
|
29
|
+
* the wide tail on cannot re-deliver or re-shape the inbound that already works.
|
|
30
|
+
*
|
|
31
|
+
* PURE — no IO, no clock beyond an injectable `now`.
|
|
32
|
+
*
|
|
33
|
+
* @module lib/org/inbound/project
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
"use strict";
|
|
37
|
+
|
|
38
|
+
import { SURFACES } from "./surfaces.mjs";
|
|
39
|
+
|
|
40
|
+
/** Blank-safe string. */
|
|
41
|
+
function s(v) {
|
|
42
|
+
return v == null ? "" : String(v);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Urgency words that justify raising the item's priority signal. */
|
|
46
|
+
const URGENT = /\b(urgent|asap|immediately|critical|p0|blocker|blocked|sev-?[01]|escalat\w*)\b/i;
|
|
47
|
+
const DEADLINE = /\b(by (mon|tue|wed|thu|fri|sat|sun|tomorrow|today|eod|eow)|due\b|deadline|before \d)/i;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Project a directed, hydrated candidate onto a MessageEvent.
|
|
51
|
+
*
|
|
52
|
+
* @param {Object} o
|
|
53
|
+
* @param {import("./directedness.mjs").Candidate} o.candidate
|
|
54
|
+
* @param {{surface:string, reason:string}} o.verdict
|
|
55
|
+
* @param {import("./hydrate.mjs").Hydrated} o.hydrated
|
|
56
|
+
* @param {string} o.me
|
|
57
|
+
* @param {import("./facts.mjs").Facts} [o.facts]
|
|
58
|
+
* @param {string} [o.now] ISO timestamp fallback
|
|
59
|
+
* @returns {object|null} a MessageEvent, or null when it must not be delivered
|
|
60
|
+
*/
|
|
61
|
+
export function toMessageEvent(o = {}) {
|
|
62
|
+
const { candidate: c, verdict, hydrated, me } = o;
|
|
63
|
+
if (!c || !verdict || !verdict.surface || !hydrated || hydrated.ok !== true) return null;
|
|
64
|
+
const def = SURFACES[verdict.surface];
|
|
65
|
+
if (!def) return null;
|
|
66
|
+
|
|
67
|
+
const facts = o.facts || {};
|
|
68
|
+
const seq = c.seq != null ? String(c.seq) : "";
|
|
69
|
+
const entityId = s(c.entityId);
|
|
70
|
+
const text = s(hydrated.text);
|
|
71
|
+
if (!text.trim() && verdict.surface !== "call") return null;
|
|
72
|
+
|
|
73
|
+
// ── ids. The inbox item's `id` is the DEDUP KEY: `writeInboxItem` refuses a
|
|
74
|
+
// second write of the same id, and the cursor is only advanced AFTER the
|
|
75
|
+
// write, so a crash re-pulls the same rows and the id must be STABLE across
|
|
76
|
+
// that replay. `seq` is stable (it is the ledger position), so it is the
|
|
77
|
+
// right discriminator for families whose entityId repeats across events
|
|
78
|
+
// (a task gets many comments).
|
|
79
|
+
const messageId = messageIdFor(c, verdict);
|
|
80
|
+
const inboxId = inboxIdFor(c, verdict, messageId, seq);
|
|
81
|
+
|
|
82
|
+
const channelId = s(hydrated.channelId || c.ids.channelId || "");
|
|
83
|
+
const channelLabel = s(hydrated.channelLabel || channelId || "cohort");
|
|
84
|
+
const chan = facts.channels instanceof Map ? facts.channels.get(channelId) : null;
|
|
85
|
+
const channelKind = s((c.ids.channelKind || (chan && chan.kind) || "")).toUpperCase();
|
|
86
|
+
|
|
87
|
+
const subjectDetail = s(hydrated.subjectDetail);
|
|
88
|
+
const subject = subjectDetail ? `${def.subject}: ${subjectDetail}` : def.subject;
|
|
89
|
+
|
|
90
|
+
const from = hydrated.from || { id: s(c.actor), name: s(c.actor) };
|
|
91
|
+
const scope = scopeFor(verdict.surface, channelKind, def.scope);
|
|
92
|
+
|
|
93
|
+
const ev = {
|
|
94
|
+
channel: "cohort",
|
|
95
|
+
service: "cohort",
|
|
96
|
+
message_id: messageId,
|
|
97
|
+
id: inboxId,
|
|
98
|
+
thread_id: s(hydrated.threadId),
|
|
99
|
+
from: { id: s(from.id), name: s(from.name || from.id) },
|
|
100
|
+
text,
|
|
101
|
+
timestamp: c.at || o.now || new Date().toISOString(),
|
|
102
|
+
kind: def.kind,
|
|
103
|
+
subject,
|
|
104
|
+
// ONLY ever a real Cohort channel id, never an entity id standing in for
|
|
105
|
+
// one. `scripts/daemon/responder.mjs sendCohortReply` routes its reply with
|
|
106
|
+
// `messaging.send({channel: item.channel_id})`, so putting a task/decision
|
|
107
|
+
// id here would post-to-a-non-channel and 400. A surface with no room leaves
|
|
108
|
+
// this blank and is routed by `kind` + `raw_ref` instead (see the wiring
|
|
109
|
+
// note in this directory's module docs).
|
|
110
|
+
channel_id: channelId,
|
|
111
|
+
channel_label: channelLabel,
|
|
112
|
+
sender: s(from.name || from.id),
|
|
113
|
+
is_reply: Boolean(hydrated.isReply),
|
|
114
|
+
thread_context: hydrated.threadContext != null ? hydrated.threadContext : null,
|
|
115
|
+
priority_signals: {
|
|
116
|
+
from_ceo: false,
|
|
117
|
+
tagged_urgent: URGENT.test(text) || c.kind === "board.blocked" || c.family === "escalation",
|
|
118
|
+
contains_deadline: DEADLINE.test(text),
|
|
119
|
+
// TRUE only when this really is a direct address, so downstream triage can
|
|
120
|
+
// trust it. `channel`/`assignee` delivery is directed at the SEAT, not a
|
|
121
|
+
// literal @mention, and says so.
|
|
122
|
+
mentions_agent:
|
|
123
|
+
verdict.reason === "mention" ||
|
|
124
|
+
verdict.reason === "named" ||
|
|
125
|
+
verdict.reason === "dm" ||
|
|
126
|
+
verdict.reason === "direct",
|
|
127
|
+
},
|
|
128
|
+
raw_ref: `cohort:${verdict.surface}:${entityId || messageId}:${seq}`,
|
|
129
|
+
ingest_source: "cohort",
|
|
130
|
+
source: {
|
|
131
|
+
channel: "cohort",
|
|
132
|
+
chatId: channelId,
|
|
133
|
+
...(hydrated.threadId ? { threadRef: s(hydrated.threadId) } : {}),
|
|
134
|
+
userScope: scope,
|
|
135
|
+
},
|
|
136
|
+
// ── Cohort-specific provenance. `eventToInboxItem` drops unknown keys, so
|
|
137
|
+
// these never reach the YAML — they are for in-process consumers (the
|
|
138
|
+
// cadence handler's log line, tests, and any future router) that see the
|
|
139
|
+
// MessageEvent before it is flattened.
|
|
140
|
+
cohort: {
|
|
141
|
+
surface: verdict.surface,
|
|
142
|
+
topic: def.topic,
|
|
143
|
+
reason: verdict.reason,
|
|
144
|
+
family: c.family,
|
|
145
|
+
event_kind: c.kind,
|
|
146
|
+
entity_id: entityId || null,
|
|
147
|
+
seq: c.seq ?? null,
|
|
148
|
+
me: s(me),
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// CONFIDENTIALITY. The daemon's outcome gate reads `is_private === false` as a
|
|
153
|
+
// positive "this source is public" assertion, so we stamp it ONLY when the
|
|
154
|
+
// room class is actually known — a default here would forge one.
|
|
155
|
+
if (channelKind) {
|
|
156
|
+
ev.channel_type = channelKind.toLowerCase();
|
|
157
|
+
ev.is_private = channelKind !== "PUBLIC";
|
|
158
|
+
ev.is_dm = channelKind === "DM";
|
|
159
|
+
} else if (!channelId) {
|
|
160
|
+
// A non-channel surface (task / decision / approval / doc / email) is org-
|
|
161
|
+
// internal and never public.
|
|
162
|
+
ev.is_private = true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return ev;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The per-surface `message_id`. For messaging this stays the hq message id so
|
|
170
|
+
* the projection is byte-identical to today's DM path; for everything else the
|
|
171
|
+
* ledger seq is the only value that is both unique per event and stable across
|
|
172
|
+
* an at-least-once replay.
|
|
173
|
+
*/
|
|
174
|
+
export function messageIdFor(c, verdict) {
|
|
175
|
+
if (c.family === "messaging") return s(c.ids.messageId || c.entityId || c.seq);
|
|
176
|
+
if (c.family === "calling") return s(c.ids.callId || c.entityId || c.seq);
|
|
177
|
+
return s(c.seq != null ? c.seq : c.entityId);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The inbox item id — the DEDUP key. `cohort-<messageId>` for the two legacy
|
|
182
|
+
* surfaces (byte-compat with `lib/org/messaging.toMessageEvent`), and
|
|
183
|
+
* `cohort-<surface>-<seq>` for the new ones so two comments on the same task
|
|
184
|
+
* never collide.
|
|
185
|
+
*/
|
|
186
|
+
export function inboxIdFor(c, verdict, messageId, seq) {
|
|
187
|
+
if (c.family === "messaging" || c.family === "calling") {
|
|
188
|
+
return messageId ? `cohort-${messageId}` : undefined;
|
|
189
|
+
}
|
|
190
|
+
return `cohort-${verdict.surface}-${seq || messageId}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The coarse audience scope the daemon uses for pairing vs membership rules. */
|
|
194
|
+
export function scopeFor(surface, channelKind, fallback) {
|
|
195
|
+
if (channelKind === "DM") return "dm";
|
|
196
|
+
if (channelKind === "GROUP_DM" || channelKind === "HUDDLE") return "group";
|
|
197
|
+
if (channelKind) return "channel";
|
|
198
|
+
return fallback || "channel";
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export default { toMessageEvent, messageIdFor, inboxIdFor, scopeFor };
|