@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.
Files changed (148) hide show
  1. package/framework-features.json +30 -0
  2. package/lib/backlog.mjs +136 -0
  3. package/lib/cadences.mjs +63 -2
  4. package/lib/cadences.test.mjs +105 -0
  5. package/lib/capability/inventory.mjs +542 -0
  6. package/lib/capability/inventory.test.mjs +232 -0
  7. package/lib/capability/probe.mjs +255 -0
  8. package/lib/channels/contract.mjs +37 -1
  9. package/lib/channels/contract.test.mjs +25 -1
  10. package/lib/claude-bin.mjs +37 -3
  11. package/lib/claude-bin.test.mjs +42 -8
  12. package/lib/execution/disposition.mjs +501 -0
  13. package/lib/execution/disposition.test.mjs +482 -0
  14. package/lib/execution/drive.mjs +352 -0
  15. package/lib/execution/drive.test.mjs +270 -0
  16. package/lib/execution/effects.mjs +340 -0
  17. package/lib/execution/effects.test.mjs +193 -0
  18. package/lib/execution/index.mjs +152 -0
  19. package/lib/execution/intake.mjs +581 -0
  20. package/lib/execution/intake.test.mjs +343 -0
  21. package/lib/execution/journal.mjs +374 -0
  22. package/lib/execution/journal.test.mjs +261 -0
  23. package/lib/execution/match.mjs +331 -0
  24. package/lib/execution/match.test.mjs +235 -0
  25. package/lib/execution/pipeline.mjs +341 -0
  26. package/lib/execution/pipeline.test.mjs +389 -0
  27. package/lib/execution/route.mjs +332 -0
  28. package/lib/execution/route.test.mjs +186 -0
  29. package/lib/execution/surface-policy.mjs +446 -0
  30. package/lib/execution/surface-policy.test.mjs +162 -0
  31. package/lib/goals/admission.mjs +209 -0
  32. package/lib/goals/admission.test.mjs +139 -0
  33. package/lib/goals/classify.mjs +206 -0
  34. package/lib/goals/classify.test.mjs +109 -0
  35. package/lib/goals/collaborate.mjs +415 -0
  36. package/lib/goals/collaborate.test.mjs +324 -0
  37. package/lib/goals/gaps.mjs +111 -0
  38. package/lib/goals/gaps.test.mjs +284 -0
  39. package/lib/goals/loop.mjs +537 -0
  40. package/lib/goals/loop.test.mjs +719 -0
  41. package/lib/identity/persona.mjs +247 -0
  42. package/lib/identity/persona.test.mjs +117 -0
  43. package/lib/kpi.mjs +469 -0
  44. package/lib/kpi.test.mjs +244 -0
  45. package/lib/mandate/audit.mjs +168 -0
  46. package/lib/mandate/audit.test.mjs +195 -0
  47. package/lib/mandate/cache.mjs +162 -0
  48. package/lib/mandate/derive.mjs +317 -0
  49. package/lib/mandate/derive.test.mjs +224 -0
  50. package/lib/mandate/model.mjs +352 -0
  51. package/lib/mandate/model.test.mjs +145 -0
  52. package/lib/mandate/refresh.mjs +187 -0
  53. package/lib/mandate/refresh.test.mjs +293 -0
  54. package/lib/mcp/server.test.mjs +4 -4
  55. package/lib/org/approvals.mjs +14 -2
  56. package/lib/org/client.mjs +58 -22
  57. package/lib/org/client.test.mjs +3 -1
  58. package/lib/org/inbound/directedness.mjs +720 -0
  59. package/lib/org/inbound/directedness.test.mjs +543 -0
  60. package/lib/org/inbound/facts.mjs +501 -0
  61. package/lib/org/inbound/facts.test.mjs +375 -0
  62. package/lib/org/inbound/hydrate.mjs +535 -0
  63. package/lib/org/inbound/hydrate.test.mjs +326 -0
  64. package/lib/org/inbound/index.mjs +233 -0
  65. package/lib/org/inbound/index.test.mjs +324 -0
  66. package/lib/org/inbound/io.mjs +141 -0
  67. package/lib/org/inbound/project.mjs +201 -0
  68. package/lib/org/inbound/project.test.mjs +287 -0
  69. package/lib/org/inbound/surfaces.mjs +257 -0
  70. package/lib/org/knowledge.mjs +10 -1
  71. package/lib/org/knowledge.test.mjs +8 -1
  72. package/lib/org/leases.mjs +5 -0
  73. package/lib/org/mesh.mjs +17 -2
  74. package/lib/org/messaging.mjs +40 -4
  75. package/lib/org/messaging.test.mjs +40 -0
  76. package/lib/org/param-contract.mjs +694 -0
  77. package/lib/org/param-contract.test.mjs +451 -0
  78. package/lib/org/protocol.checksum +1 -1
  79. package/lib/org/protocol.mjs +8 -0
  80. package/lib/org/protocol.test.mjs +5 -1
  81. package/lib/org/push.mjs +1025 -0
  82. package/lib/org/push.test.mjs +690 -0
  83. package/lib/org/tool-surface.mjs +138 -38
  84. package/lib/org/tool-surface.test.mjs +13 -8
  85. package/lib/org/typing.mjs +341 -0
  86. package/lib/org/typing.test.mjs +291 -0
  87. package/lib/plan/compile.mjs +510 -0
  88. package/lib/plan/compile.test.mjs +286 -0
  89. package/lib/plan/emit.mjs +256 -0
  90. package/lib/plan/emit.test.mjs +246 -0
  91. package/lib/plan/explain.mjs +226 -0
  92. package/lib/plan/explain.test.mjs +188 -0
  93. package/lib/plan/schema.mjs +140 -0
  94. package/lib/resource-governor.mjs +47 -1
  95. package/lib/resource-governor.test.mjs +21 -1
  96. package/lib/setup/enroll-from-cohort.mjs +84 -16
  97. package/lib/setup/enroll-from-cohort.test.mjs +43 -1
  98. package/lib/setup/sections/identity.mjs +15 -4
  99. package/lib/setup/sections/identity.test.mjs +94 -0
  100. package/lib/setup/sections/inventory.mjs +178 -0
  101. package/lib/setup/sections/inventory.test.mjs +198 -0
  102. package/lib/setup/sections/mandate.mjs +392 -0
  103. package/lib/setup/sections/mandate.test.mjs +373 -0
  104. package/lib/setup/sections/subagents.mjs +427 -0
  105. package/lib/setup/sections/subagents.test.mjs +429 -0
  106. package/lib/setup/sections/verify.mjs +121 -0
  107. package/lib/setup/sections/verify.test.mjs +175 -0
  108. package/lib/setup/sot.mjs +2 -0
  109. package/lib/subagents/cli.mjs +463 -0
  110. package/lib/subagents/cli.test.mjs +389 -0
  111. package/lib/subagents/client.mjs +373 -0
  112. package/lib/subagents/client.test.mjs +309 -0
  113. package/lib/subagents/gap.mjs +268 -0
  114. package/lib/subagents/gap.test.mjs +234 -0
  115. package/lib/subagents/lock.mjs +296 -0
  116. package/lib/subagents/lock.test.mjs +248 -0
  117. package/lib/subagents/manifest.mjs +224 -0
  118. package/lib/subagents/manifest.test.mjs +175 -0
  119. package/lib/subagents/refs.mjs +274 -0
  120. package/lib/subagents/refs.test.mjs +204 -0
  121. package/lib/subagents/resolve.mjs +455 -0
  122. package/lib/subagents/resolve.test.mjs +422 -0
  123. package/lib/subagents/schema.mjs +467 -0
  124. package/lib/subagents/schema.test.mjs +306 -0
  125. package/package.json +8 -3
  126. package/plugins/maestro-skills/.claude-plugin/marketplace.json +16 -0
  127. package/policies/ai-disclosure.yaml +42 -2
  128. package/scaffold/CLAUDE.md +16 -2
  129. package/schedules/triggers/goal-steward.md +79 -0
  130. package/scripts/ci/conformance-org-api.mjs +792 -0
  131. package/scripts/ci/conformance-org-api.test.mjs +417 -0
  132. package/scripts/daemon/agent-daemon.mjs +36 -4
  133. package/scripts/daemon/cadence-handlers.mjs +145 -1
  134. package/scripts/daemon/goal-steward-cadence.test.mjs +243 -0
  135. package/scripts/daemon/inbox-deferral.mjs +45 -2
  136. package/scripts/daemon/inbox-deferral.test.mjs +56 -0
  137. package/scripts/daemon/inbox-wake.mjs +282 -0
  138. package/scripts/daemon/inbox-wake.test.mjs +199 -0
  139. package/scripts/daemon/prompt-builder.mjs +41 -1
  140. package/scripts/daemon/typing-registry.mjs +55 -2
  141. package/scripts/daemon/typing-registry.test.mjs +25 -0
  142. package/scripts/local-triggers/generate-plists.test.mjs +5 -5
  143. package/scripts/setup/gen-subagent-manifest.mjs +95 -0
  144. package/scripts/setup/gen-subagent-manifest.test.mjs +124 -0
  145. package/scripts/setup/generate-plan.mjs +108 -0
  146. package/scripts/setup/init-capability-manifest.mjs +70 -0
  147. package/scripts/setup/init-skill-marketplace.mjs +155 -0
  148. package/scripts/setup/init-skill-marketplace.test.mjs +193 -0
@@ -0,0 +1,287 @@
1
+ /**
2
+ * project.test.mjs — the projection onto the EXISTING MessageEvent contract.
3
+ *
4
+ * The design promise is "the inbox pipeline needs no change", so these tests do
5
+ * not stop at the MessageEvent: they push it through the real
6
+ * `lib/channels/inbox-item.eventToInboxItem` and assert the flat item the daemon
7
+ * actually reads. In particular:
8
+ *
9
+ * - the per-surface `kind` SURVIVES `normalizeKind` (it would be silently
10
+ * rewritten to "message" if `contract.EVENT_KINDS` had not been extended),
11
+ * - the inbox `id` is stable across an at-least-once replay (it is the dedup
12
+ * key, and the cursor is advanced only after the write), and
13
+ * - the DM/call projections stay compatible with what `lib/org/messaging`
14
+ * produces today, so switching the tail on cannot re-deliver or re-shape the
15
+ * inbound that already works.
16
+ *
17
+ * Run: node --test lib/org/inbound/project.test.mjs
18
+ */
19
+
20
+ "use strict";
21
+
22
+ import { test } from "node:test";
23
+ import assert from "node:assert/strict";
24
+
25
+ import { toMessageEvent, inboxIdFor, messageIdFor, scopeFor } from "./project.mjs";
26
+ import { classifyEvent } from "./directedness.mjs";
27
+ import { SURFACES, SURFACE_EVENT_KINDS, resolveEnabledSurfaces, parseSurfaceEnv, defaultEnabledSurfaces } from "./surfaces.mjs";
28
+ import { eventToInboxItem } from "../../channels/inbox-item.mjs";
29
+ import { EVENT_KINDS, normalizeKind } from "../../channels/contract.mjs";
30
+
31
+ const ME = "M-me";
32
+ const THEM = "M-them";
33
+
34
+ function ev(over) {
35
+ return { seq: 42, at: "2026-08-11T09:00:00.000Z", actor: THEM, ...over };
36
+ }
37
+
38
+ function project(candidate, surface, reason, hydrated, facts = {}) {
39
+ return toMessageEvent({
40
+ candidate,
41
+ verdict: { surface, reason, directed: true },
42
+ hydrated: { ok: true, ...hydrated },
43
+ me: ME,
44
+ facts,
45
+ });
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // contract drift — the whole routing hint depends on this
50
+ // ---------------------------------------------------------------------------
51
+
52
+ test("every surface kind is a REGISTERED EventKind (else normalizeKind eats it)", () => {
53
+ for (const kind of SURFACE_EVENT_KINDS) {
54
+ assert.ok(EVENT_KINDS.includes(kind), `contract.EVENT_KINDS is missing "${kind}"`);
55
+ assert.equal(normalizeKind(kind), kind, `"${kind}" would be rewritten to "message"`);
56
+ }
57
+ });
58
+
59
+ test("the five original chat kinds are untouched", () => {
60
+ for (const kind of ["message", "reaction", "channel_cc", "voice_note", "call"]) {
61
+ assert.ok(EVENT_KINDS.includes(kind));
62
+ }
63
+ });
64
+
65
+ test("email is the one surface OFF by default (the orgmail adapter already polls it)", () => {
66
+ const d = defaultEnabledSurfaces();
67
+ assert.equal(d.email, false);
68
+ for (const [name, on] of Object.entries(d)) {
69
+ if (name !== "email") assert.equal(on, true, `${name} should default on`);
70
+ }
71
+ });
72
+
73
+ test("surface overrides: array = exact set, object = delta, env grammar", () => {
74
+ assert.deepEqual(resolveEnabledSurfaces(["mention"]).enabled.mention, true);
75
+ assert.deepEqual(resolveEnabledSurfaces(["mention"]).enabled.dm, false);
76
+ assert.deepEqual(resolveEnabledSurfaces({ email: true }).enabled.email, true);
77
+ assert.deepEqual(resolveEnabledSurfaces({ email: true }).enabled.dm, true, "a delta keeps the rest");
78
+ assert.deepEqual(resolveEnabledSurfaces({ nope: true }).unknown, ["nope"]);
79
+
80
+ assert.equal(parseSurfaceEnv(""), undefined);
81
+ assert.equal(parseSurfaceEnv("default"), undefined);
82
+ assert.deepEqual(parseSurfaceEnv("mention,thread_reply"), ["mention", "thread_reply"]);
83
+ assert.deepEqual(parseSurfaceEnv("+email,-thread_reply"), { email: true, thread_reply: false });
84
+ assert.equal(parseSurfaceEnv("all").email, true);
85
+ assert.equal(parseSurfaceEnv("none").dm, false);
86
+ });
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // per-surface projection → real inbox item
90
+ // ---------------------------------------------------------------------------
91
+
92
+ test("an @mention projects to kind:'mention' and survives eventToInboxItem", () => {
93
+ const c = classifyEvent(
94
+ ev({ family: "messaging", kind: "send", entity_id: "m1", payload: { actor: THEM, channelId: "C-pub", channelKind: "PUBLIC", mentionCount: 1 } }),
95
+ );
96
+ const facts = { channels: new Map([["C-pub", { id: "C-pub", kind: "PUBLIC", name: "General" }]]) };
97
+ const mev = project(c, "mention", "mention", {
98
+ text: "@Isla can you look?",
99
+ from: { id: THEM, name: "Casey" },
100
+ channelId: "C-pub",
101
+ channelLabel: "General",
102
+ }, facts);
103
+
104
+ assert.equal(mev.kind, "mention");
105
+ assert.equal(mev.subject, "Cohort @mention");
106
+ assert.equal(mev.priority_signals.mentions_agent, true);
107
+ assert.equal(mev.is_private, false, "a PUBLIC room is a positive public assertion");
108
+ assert.equal(mev.raw_ref, "cohort:mention:m1:42");
109
+
110
+ const item = eventToInboxItem(mev);
111
+ assert.equal(item.kind, "mention", "the routing hint must reach the daemon");
112
+ assert.equal(item.service, "cohort");
113
+ assert.equal(item.channel, "General");
114
+ assert.equal(item.content, "@Isla can you look?");
115
+ assert.equal(item.sender, "Casey");
116
+ assert.equal(item.is_private, false);
117
+ });
118
+
119
+ test("a board assignment projects to kind:'task_assigned' with the task as the thread", () => {
120
+ const c = classifyEvent(ev({ family: "board", kind: "item.assigned", entity_id: "T-1", payload: { actor: THEM, assignee: ME } }));
121
+ const mev = project(c, "task_assigned", "assignee", {
122
+ text: 'A board item was assigned to you — "Ship the thing"',
123
+ subjectDetail: "Ship the thing",
124
+ threadId: "T-1",
125
+ from: { id: THEM, name: "Casey" },
126
+ channelLabel: "task/Ship the thing",
127
+ });
128
+
129
+ assert.equal(mev.kind, "task_assigned");
130
+ assert.equal(mev.subject, "Cohort task assigned: Ship the thing");
131
+ assert.equal(mev.thread_id, "T-1");
132
+ assert.equal(mev.is_private, true, "a non-channel org surface is never public");
133
+ const item = eventToInboxItem(mev);
134
+ assert.equal(item.kind, "task_assigned");
135
+ assert.equal(item.thread_id, "T-1");
136
+ assert.equal(item.id, "cohort-task_assigned-42");
137
+ });
138
+
139
+ test("each remaining surface projects to its own kind", () => {
140
+ const cases = [
141
+ ["thread_reply", classifyEvent(ev({ family: "messaging", kind: "send", entity_id: "m2", payload: { actor: THEM, channelId: "C", channelKind: "PUBLIC", threaded: true } }))],
142
+ ["task_comment", classifyEvent(ev({ family: "board", kind: "item.commented", entity_id: "T-2", payload: { actor: THEM } }))],
143
+ ["file_comment", classifyEvent(ev({ family: "file", kind: "file.commented", entity_id: "fc1", payload: { fileKey: "k", channelId: "C" } }))],
144
+ ["doc_comment", classifyEvent(ev({ family: "files", kind: "doc.comment", entity_id: "F-1", payload: { name: "Spec" } }))],
145
+ ["approval", classifyEvent(ev({ family: "approval", kind: "approval.requested", entity_id: "A-1", payload: { requester: THEM } }))],
146
+ ["decision", classifyEvent(ev({ family: "decision", kind: "decision.comment", entity_id: "D-1", payload: { authorId: THEM } }))],
147
+ ["escalation", classifyEvent(ev({ family: "escalation", kind: "escalation.raised", entity_id: "E-1", payload: { actor: THEM } }))],
148
+ ["handoff", classifyEvent(ev({ family: "handoff", kind: "handoff.offered", entity_id: "R-1", payload: { from: THEM, to: ME } }))],
149
+ ["email", classifyEvent(ev({ family: "email", kind: "received", entity_id: "EM-1", payload: { memberId: ME, threadId: "ET-1" } }))],
150
+ ];
151
+ for (const [surface, cand] of cases) {
152
+ const mev = project(cand, surface, "direct", { text: "something happened", from: { id: THEM, name: "Casey" } });
153
+ assert.ok(mev, `${surface} must project`);
154
+ assert.equal(mev.kind, SURFACES[surface].kind, surface);
155
+ const item = eventToInboxItem(mev);
156
+ assert.equal(item.kind, SURFACES[surface].kind, `${surface} kind must survive serialisation`);
157
+ assert.equal(item.service, "cohort");
158
+ assert.ok(item.content, `${surface} must carry a body`);
159
+ }
160
+ // doc_comment and file_comment deliberately share one kind: they are the same
161
+ // thing to a classifier ("someone commented on a file of mine").
162
+ assert.equal(SURFACES.doc_comment.kind, SURFACES.file_comment.kind);
163
+ });
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // the legacy DM / call shape must not move
167
+ // ---------------------------------------------------------------------------
168
+
169
+ test("a DM projects to the SAME kind/id/subject the current pullInbound produces", () => {
170
+ const c = classifyEvent(ev({ family: "messaging", kind: "send", entity_id: "m9", payload: { actor: THEM, channelId: "C-dm", channelKind: "DM" } }));
171
+ const facts = { channels: new Map([["C-dm", { id: "C-dm", kind: "DM", name: "them" }]]) };
172
+ const mev = project(c, "dm", "dm", { text: "ping", from: { id: THEM, name: "Casey" }, channelId: "C-dm", channelLabel: "them" }, facts);
173
+
174
+ assert.equal(mev.kind, "message", "an existing DM must NOT change kind");
175
+ assert.equal(mev.id, "cohort-m9", "the dedup id must not move");
176
+ assert.equal(mev.subject, "Cohort message");
177
+ assert.equal(mev.channel, "cohort");
178
+ assert.equal(mev.source.userScope, "dm");
179
+ assert.equal(mev.is_dm, true);
180
+
181
+ const item = eventToInboxItem(mev);
182
+ assert.equal("kind" in item, false, "kind 'message' stays omitted, as it is today");
183
+ assert.equal(item.id, "cohort-m9");
184
+ });
185
+
186
+ test("a call keeps kind:'call' and the call id", () => {
187
+ const c = classifyEvent(ev({ family: "calling", kind: "call.started", entity_id: "call-1", payload: { actor: THEM, callId: "call-1", channelId: "C-dm", topic: "sync" } }));
188
+ const mev = project(c, "call", "channel", { text: "Call invite: sync", subjectDetail: "sync", threadId: "call-1", from: { id: THEM, name: "Casey" }, channelId: "C-dm" });
189
+ assert.equal(mev.kind, "call");
190
+ assert.equal(mev.id, "cohort-call-1");
191
+ assert.equal(eventToInboxItem(mev).kind, "call");
192
+ });
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // dedup ids + replay stability
196
+ // ---------------------------------------------------------------------------
197
+
198
+ test("the inbox id is STABLE across a replay of the same ledger row", () => {
199
+ const mk = () => classifyEvent(ev({ family: "board", kind: "item.commented", entity_id: "T-1", payload: { actor: THEM } }));
200
+ const a = project(mk(), "task_comment", "assignee", { text: "a" });
201
+ const b = project(mk(), "task_comment", "assignee", { text: "a" });
202
+ assert.equal(a.id, b.id, "an at-least-once re-pull must dedup, not double-deliver");
203
+ });
204
+
205
+ test("two comments on the SAME task get different ids", () => {
206
+ const one = classifyEvent({ seq: 10, family: "board", kind: "item.commented", entity_id: "T-1", actor: THEM, payload: { actor: THEM } });
207
+ const two = classifyEvent({ seq: 11, family: "board", kind: "item.commented", entity_id: "T-1", actor: THEM, payload: { actor: THEM } });
208
+ assert.notEqual(
209
+ project(one, "task_comment", "assignee", { text: "a" }).id,
210
+ project(two, "task_comment", "assignee", { text: "b" }).id,
211
+ );
212
+ });
213
+
214
+ test("id/messageId helpers are explicit about their two regimes", () => {
215
+ const msg = { family: "messaging", ids: { messageId: "m1" }, entityId: "m1", seq: 5 };
216
+ assert.equal(messageIdFor(msg, {}), "m1");
217
+ assert.equal(inboxIdFor(msg, { surface: "dm" }, "m1", "5"), "cohort-m1");
218
+ const task = { family: "board", ids: { taskId: "T-1" }, entityId: "T-1", seq: 5 };
219
+ assert.equal(messageIdFor(task, {}), "5");
220
+ assert.equal(inboxIdFor(task, { surface: "task_comment" }, "5", "5"), "cohort-task_comment-5");
221
+ });
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // signals + refusals
225
+ // ---------------------------------------------------------------------------
226
+
227
+ test("mentions_agent is only true for a real direct address", () => {
228
+ const c = classifyEvent(ev({ family: "board", kind: "item.commented", entity_id: "T-1", payload: { actor: THEM } }));
229
+ assert.equal(project(c, "task_comment", "assignee", { text: "x" }).priority_signals.mentions_agent, false);
230
+ assert.equal(project(c, "task_comment", "direct", { text: "x" }).priority_signals.mentions_agent, true);
231
+ });
232
+
233
+ test("urgency and deadline signals come off the hydrated body, and blocks are urgent", () => {
234
+ const c = classifyEvent(ev({ family: "board", kind: "board.blocked", entity_id: "T-1", payload: { actor: THEM, reason: "waiting" } }));
235
+ const mev = project(c, "task_comment", "assignee", { text: "this is blocked, need it by Friday" });
236
+ assert.equal(mev.priority_signals.tagged_urgent, true);
237
+ assert.equal(mev.priority_signals.contains_deadline, true);
238
+ });
239
+
240
+ test("projection REFUSES an unhydrated, bodyless or surfaceless item", () => {
241
+ const c = classifyEvent(ev({ family: "board", kind: "item.commented", entity_id: "T-1", payload: { actor: THEM } }));
242
+ assert.equal(toMessageEvent({ candidate: c, verdict: { surface: "task_comment" }, hydrated: { ok: false }, me: ME }), null);
243
+ assert.equal(project(c, "task_comment", "assignee", { text: " " }), null);
244
+ assert.equal(toMessageEvent({ candidate: c, verdict: { surface: "nope" }, hydrated: { ok: true, text: "x" }, me: ME }), null);
245
+ assert.equal(toMessageEvent({}), null);
246
+ });
247
+
248
+ test("is_private is stamped only when the room class is actually known", () => {
249
+ const c = classifyEvent(ev({ family: "messaging", kind: "send", entity_id: "m1", payload: { actor: THEM, channelId: "C", channelKind: "PRIVATE" } }));
250
+ assert.equal(project(c, "mention", "mention", { text: "x", channelId: "C" }).is_private, true);
251
+
252
+ const noKind = classifyEvent(ev({ family: "file", kind: "file.commented", entity_id: "fc", payload: { fileKey: "k", channelId: "C-unknown" } }));
253
+ const mev = project(noKind, "file_comment", "channel", { text: "x", channelId: "C-unknown" });
254
+ assert.equal("is_private" in mev, false, "an unknown room class must not forge a confidentiality assertion");
255
+ });
256
+
257
+ test("channel_id is ONLY ever a real channel id — never an entity id in disguise", () => {
258
+ // `responder.sendCohortReply` does `messaging.send({channel: item.channel_id})`.
259
+ // A task id here would post to a non-channel and 400, so a room-less surface
260
+ // must leave it blank and be routed by `kind` + `raw_ref` instead.
261
+ const roomless = [
262
+ ["task_comment", classifyEvent(ev({ family: "board", kind: "item.commented", entity_id: "T-1", payload: { actor: THEM } }))],
263
+ ["decision", classifyEvent(ev({ family: "decision", kind: "decision.comment", entity_id: "D-1", payload: { authorId: THEM } }))],
264
+ ["approval", classifyEvent(ev({ family: "approval", kind: "approval.requested", entity_id: "A-1", payload: { requester: THEM } }))],
265
+ ["doc_comment", classifyEvent(ev({ family: "files", kind: "doc.comment", entity_id: "F-1", payload: { name: "Spec" } }))],
266
+ ["handoff", classifyEvent(ev({ family: "handoff", kind: "handoff.offered", entity_id: "R-1", payload: { from: THEM, to: ME } }))],
267
+ ];
268
+ for (const [surface, cand] of roomless) {
269
+ const mev = project(cand, surface, "direct", { text: "x", threadId: "X-1" });
270
+ assert.equal(mev.channel_id, "", `${surface} must not fake a channel id`);
271
+ assert.equal(eventToInboxItem(mev).channel_id, "", surface);
272
+ // The routing information is still there, on fields the converter keeps.
273
+ assert.match(eventToInboxItem(mev).raw_ref, new RegExp(`^cohort:${surface}:`));
274
+ assert.ok(eventToInboxItem(mev).thread_id, `${surface} must carry a reply anchor`);
275
+ }
276
+
277
+ // A board item that DOES live in a channel keeps it, so a reply can go there.
278
+ const inChannel = classifyEvent(ev({ family: "board", kind: "item.commented", entity_id: "T-2", payload: { actor: THEM, channelId: "C-eng" } }));
279
+ assert.equal(project(inChannel, "task_comment", "assignee", { text: "x", channelId: "C-eng" }).channel_id, "C-eng");
280
+ });
281
+
282
+ test("scopeFor maps room class to the daemon's audience vocabulary", () => {
283
+ assert.equal(scopeFor("dm", "DM", "channel"), "dm");
284
+ assert.equal(scopeFor("dm", "GROUP_DM", "channel"), "group");
285
+ assert.equal(scopeFor("mention", "PUBLIC", "channel"), "channel");
286
+ assert.equal(scopeFor("approval", "", "dm"), "dm");
287
+ });
@@ -0,0 +1,257 @@
1
+ /**
2
+ * lib/org/inbound/surfaces.mjs — the inbound SURFACE vocabulary.
3
+ *
4
+ * Today `pullInbound` tails two families (`messaging`, `calling`) and recognises
5
+ * exactly two things: a message in a private room I belong to, and a call in one
6
+ * of my rooms. hq's own in-process responder (`src/server/llm-responder/`) reacts
7
+ * to eleven inbound triggers, and since hq commit 4d762d30 it STOPS answering for
8
+ * any member whose daemon has beaten inside 100 s. The net effect for a live SDK
9
+ * agent is that an @mention in a space is answered by nobody: hq stands down and
10
+ * the SDK never sees it.
11
+ *
12
+ * This table is the parity target expressed as data. Each SURFACE is:
13
+ *
14
+ * - `topic` the coarse bucket (mirrors hq `agent-stream/relevance.ts` Topic),
15
+ * - `kind` the MessageEvent `kind` written onto the inbox item, so the
16
+ * daemon classifier can route WITHOUT parsing prose,
17
+ * - `subject` the human-readable inbox subject prefix,
18
+ * - `scope` the `source.userScope` the projection stamps,
19
+ * - `default` whether the surface is tailed unless the operator says otherwise.
20
+ *
21
+ * Adding a surface is a data edit here plus a `classifyEvent` branch in
22
+ * `directedness.mjs` — the pull loop, hydration dispatch and projection are all
23
+ * table-driven off this module.
24
+ *
25
+ * Pure data. No IO.
26
+ *
27
+ * @module lib/org/inbound/surfaces
28
+ */
29
+
30
+ "use strict";
31
+
32
+ /**
33
+ * WHY `email` DEFAULTS OFF. The org-mail channel adapter
34
+ * (lib/channels/orgmail/adapter.mjs) ALREADY polls `email.inbox` every 45 s and
35
+ * writes its own inbox items under `state/inbox/orgmail/`. Tailing the `email`
36
+ * family here as well would deliver every inbound mail twice, and the two items
37
+ * would race for the same thread lease. Enable this surface only when the
38
+ * orgmail adapter is off (`config/orgmail.yaml` absent) — see README of this
39
+ * directory / the wiring notes.
40
+ */
41
+ export const SURFACES = Object.freeze({
42
+ /** A message in a DM / group-DM / huddle I belong to. Byte-compatible with today. */
43
+ dm: Object.freeze({
44
+ topic: "message",
45
+ kind: "message",
46
+ subject: "Cohort message",
47
+ scope: "dm",
48
+ default: true,
49
+ }),
50
+ /** An @mention of me in a public/private space. THE headline gap. */
51
+ mention: Object.freeze({
52
+ topic: "message",
53
+ kind: "mention",
54
+ subject: "Cohort @mention",
55
+ scope: "channel",
56
+ default: true,
57
+ }),
58
+ /** A reply in a thread I have spoken in / been named in. */
59
+ thread_reply: Object.freeze({
60
+ topic: "message",
61
+ kind: "thread_reply",
62
+ subject: "Cohort thread reply",
63
+ scope: "channel",
64
+ default: true,
65
+ }),
66
+ /** A call started/invited in one of my rooms. Byte-compatible with today. */
67
+ call: Object.freeze({
68
+ topic: "call",
69
+ kind: "call",
70
+ subject: "Cohort call invite",
71
+ scope: "channel",
72
+ default: true,
73
+ }),
74
+ /** A board item assigned to me (or claimed/completed on my behalf). */
75
+ task_assigned: Object.freeze({
76
+ topic: "task",
77
+ kind: "task_assigned",
78
+ subject: "Cohort task assigned",
79
+ scope: "channel",
80
+ default: true,
81
+ }),
82
+ /** A comment / block / move on a board item I own or review. */
83
+ task_comment: Object.freeze({
84
+ topic: "task",
85
+ kind: "task_comment",
86
+ subject: "Cohort task update",
87
+ scope: "channel",
88
+ default: true,
89
+ }),
90
+ /** A comment on a chat-attached file in one of my rooms (family `file`). */
91
+ file_comment: Object.freeze({
92
+ topic: "file",
93
+ kind: "file_comment",
94
+ subject: "Cohort file comment",
95
+ scope: "channel",
96
+ default: true,
97
+ }),
98
+ /** A comment / suggestion on a workspace doc I own or am shared on (family `files`). */
99
+ doc_comment: Object.freeze({
100
+ topic: "file",
101
+ kind: "file_comment",
102
+ subject: "Cohort document comment",
103
+ scope: "channel",
104
+ default: true,
105
+ }),
106
+ /** An approval on my desk, or the outcome of one I requested. */
107
+ approval: Object.freeze({
108
+ topic: "approval",
109
+ kind: "approval",
110
+ subject: "Cohort approval",
111
+ scope: "channel",
112
+ default: true,
113
+ }),
114
+ /** A decision I proposed / commented on / must sign. */
115
+ decision: Object.freeze({
116
+ topic: "decision",
117
+ kind: "decision",
118
+ subject: "Cohort decision",
119
+ scope: "channel",
120
+ default: true,
121
+ }),
122
+ /** An escalation raised on my task / in my room / waiting on me. */
123
+ escalation: Object.freeze({
124
+ topic: "escalation",
125
+ kind: "escalation",
126
+ subject: "Cohort escalation",
127
+ scope: "channel",
128
+ default: true,
129
+ }),
130
+ /** A delegation offered to me, or one I offered being accepted/declined. */
131
+ handoff: Object.freeze({
132
+ topic: "handoff",
133
+ kind: "handoff",
134
+ subject: "Cohort handoff",
135
+ scope: "channel",
136
+ default: true,
137
+ }),
138
+ /** An inbound email routed to my mailbox. OFF by default — see note above. */
139
+ email: Object.freeze({
140
+ topic: "email",
141
+ kind: "email",
142
+ subject: "Cohort email",
143
+ scope: "dm",
144
+ default: false,
145
+ }),
146
+ });
147
+
148
+ /** Every surface name, sorted for determinism. */
149
+ export const SURFACE_NAMES = Object.freeze(Object.keys(SURFACES).sort());
150
+
151
+ /**
152
+ * The MessageEvent `kind` values these surfaces introduce, beyond the five the
153
+ * channel contract already ships. `lib/channels/contract.mjs` must list them in
154
+ * EVENT_KINDS or `eventToInboxItem` will normalise them all back to "message"
155
+ * and the classifier loses the routing hint. Exported so a test can assert the
156
+ * two lists have not drifted.
157
+ */
158
+ export const SURFACE_EVENT_KINDS = Object.freeze(
159
+ Array.from(new Set(Object.values(SURFACES).map((s) => s.kind))).sort(),
160
+ );
161
+
162
+ /** The default enable map: `{ surfaceName: boolean }`. */
163
+ export function defaultEnabledSurfaces() {
164
+ const out = {};
165
+ for (const [name, def] of Object.entries(SURFACES)) out[name] = def.default;
166
+ return out;
167
+ }
168
+
169
+ /**
170
+ * Resolve the effective enable map from an override.
171
+ *
172
+ * Accepts `undefined` (all defaults), an array of surface names (exactly those
173
+ * on, everything else off), or a partial `{name: boolean}` map merged over the
174
+ * defaults. Unknown names are IGNORED rather than throwing — a config typo must
175
+ * not take the inbound loop down (fail-open house rule); the caller gets the
176
+ * dropped names back so it can log them.
177
+ *
178
+ * @param {string[]|Record<string,boolean>|undefined|null} override
179
+ * @returns {{enabled: Record<string,boolean>, unknown: string[]}}
180
+ */
181
+ export function resolveEnabledSurfaces(override) {
182
+ const enabled = defaultEnabledSurfaces();
183
+ const unknown = [];
184
+ if (override == null) return { enabled, unknown };
185
+
186
+ if (Array.isArray(override)) {
187
+ for (const key of Object.keys(enabled)) enabled[key] = false;
188
+ for (const name of override) {
189
+ const k = String(name);
190
+ if (Object.prototype.hasOwnProperty.call(enabled, k)) enabled[k] = true;
191
+ else unknown.push(k);
192
+ }
193
+ return { enabled, unknown };
194
+ }
195
+
196
+ if (typeof override === "object") {
197
+ for (const [name, on] of Object.entries(override)) {
198
+ if (Object.prototype.hasOwnProperty.call(enabled, name)) enabled[name] = !!on;
199
+ else unknown.push(String(name));
200
+ }
201
+ return { enabled, unknown };
202
+ }
203
+
204
+ return { enabled, unknown };
205
+ }
206
+
207
+ /**
208
+ * Parse the `COHORT_INBOUND_SURFACES` env override.
209
+ *
210
+ * Grammar (comma separated, whitespace tolerant):
211
+ * "all" every surface on
212
+ * "default" the defaults (same as unset)
213
+ * "mention,thread_reply" exactly these on, everything else off
214
+ * "+email" the defaults PLUS email
215
+ * "-thread_reply" the defaults MINUS thread_reply
216
+ * A list may mix `+`/`-` entries, in which case it is treated as a delta over
217
+ * the defaults; a list with no sign prefixes is treated as an exact set.
218
+ *
219
+ * @param {string|undefined|null} raw
220
+ * @returns {string[]|Record<string,boolean>|undefined} an override for
221
+ * {@link resolveEnabledSurfaces}, or undefined for "use the defaults".
222
+ */
223
+ export function parseSurfaceEnv(raw) {
224
+ const s = typeof raw === "string" ? raw.trim() : "";
225
+ if (!s) return undefined;
226
+ const lower = s.toLowerCase();
227
+ if (lower === "default" || lower === "defaults") return undefined;
228
+ if (lower === "all") {
229
+ const all = {};
230
+ for (const name of SURFACE_NAMES) all[name] = true;
231
+ return all;
232
+ }
233
+ if (lower === "none" || lower === "off") {
234
+ const none = {};
235
+ for (const name of SURFACE_NAMES) none[name] = false;
236
+ return none;
237
+ }
238
+ const parts = s.split(",").map((p) => p.trim()).filter(Boolean);
239
+ const signed = parts.some((p) => p.startsWith("+") || p.startsWith("-"));
240
+ if (!signed) return parts;
241
+ const delta = {};
242
+ for (const p of parts) {
243
+ if (p.startsWith("+")) delta[p.slice(1)] = true;
244
+ else if (p.startsWith("-")) delta[p.slice(1)] = false;
245
+ else delta[p] = true;
246
+ }
247
+ return delta;
248
+ }
249
+
250
+ export default {
251
+ SURFACES,
252
+ SURFACE_NAMES,
253
+ SURFACE_EVENT_KINDS,
254
+ defaultEnabledSurfaces,
255
+ resolveEnabledSurfaces,
256
+ parseSurfaceEnv,
257
+ };
@@ -377,7 +377,16 @@ export async function listMeetings(opts = {}) {
377
377
  // internals (pure shaping)
378
378
  // ---------------------------------------------------------------------------
379
379
 
380
- /** Coerce a fact arg (string text or fact object) + opts into the wire params. */
380
+ /**
381
+ * Coerce a fact arg (string text or fact object) + opts into the wire params.
382
+ *
383
+ * `kind` / `participants` / `links` / `refs` are written FLAT here and folded into
384
+ * hq's `metadata` container by the wire contract in `client.call()`
385
+ * (lib/org/param-contract#PARAM_CONTRACT["knowledge.append"].fold) — hq's handler
386
+ * persists enrichment only inside `metadata`, so flat keys were silently dropped
387
+ * on every append. Keep them flat here: the contract is the one place that knows
388
+ * hq's shape.
389
+ */
381
390
  function factToParams(fact, opts = {}) {
382
391
  const base = typeof fact === "string" ? { text: fact } : (fact && typeof fact === "object" ? { ...fact } : {});
383
392
  // Pull through the structured options without letting opts plumbing leak.
@@ -81,7 +81,9 @@ test("remember(string): appends an episode (the sole shared-write primitive)", a
81
81
  assert.equal(f.calls[0].url, `${BASE}/api/v1/knowledge.append`);
82
82
  assert.equal(f.calls[0].method, "POST");
83
83
  assert.equal(f.calls[0].headers.authorization, "Bearer tok");
84
- assert.deepEqual(f.calls[0].body, { text: "we closed the Q3 deal", group: "org" });
84
+ // hq's knowledge.append reads `body` (with `text` accepted as an alias); the
85
+ // wire contract adds the canonical name and keeps the legacy one alongside.
86
+ assert.deepEqual(f.calls[0].body, { text: "we closed the Q3 deal", body: "we closed the Q3 deal", group: "org" });
85
87
  });
86
88
 
87
89
  test("remember(fact object): pulls through opts (kind/participants/provenance), normalises group", async () => {
@@ -90,11 +92,16 @@ test("remember(fact object): pulls through opts (kind/participants/provenance),
90
92
  { text: "intro call notes" },
91
93
  { cfg: CFG, group: { kind: "unit", id: "sales" }, kind: "note", participants: ["a", "b"], provenance: { src: "call" }, fetchImpl: f },
92
94
  );
95
+ // Enrichment hq only persists inside `metadata` is FOLDED there by the wire
96
+ // contract — flat `kind`/`participants`/`links`/`refs` were silently dropped by
97
+ // the server on every append before this.
93
98
  assert.deepEqual(f.calls[0].body, {
94
99
  text: "intro call notes",
100
+ body: "intro call notes",
95
101
  kind: "note",
96
102
  participants: ["a", "b"],
97
103
  provenance: { src: "call" },
104
+ metadata: { kind: "note", participants: ["a", "b"] },
98
105
  group: "unit:sales",
99
106
  });
100
107
  });
@@ -201,11 +201,16 @@ export async function claimLease(scope, resourceId, opts = {}) {
201
201
  }
202
202
  const c = resolveClient(opts);
203
203
  const ms = Number.isFinite(opts.failOpenTimeoutMs) ? opts.failOpenTimeoutMs : DEFAULT_FAIL_OPEN_MS;
204
+ // WIRE CONTRACT (hq `methods/lease/claim.ts`): the holder is the AUTHENTICATED
205
+ // actor — hq never reads a client-supplied `holder`, and the only token field
206
+ // it models is `tokenHash`. `holder` is kept on the wire (harmless; hq's schema
207
+ // is not strict) but is documented as not-read in lib/org/param-contract.
204
208
  const params = {
205
209
  scope,
206
210
  resourceId,
207
211
  holder: opts.holder || undefined,
208
212
  ttlMs: Number.isFinite(opts.ttlMs) ? opts.ttlMs : undefined,
213
+ ...(opts.tokenHash ? { tokenHash: String(opts.tokenHash) } : {}),
209
214
  };
210
215
  const frame = await withTimeout(
211
216
  Promise.resolve(c.leaseClaim(params, rpcOpts(opts))),
package/lib/org/mesh.mjs CHANGED
@@ -449,13 +449,28 @@ export async function connectOrgMesh(o = {}) {
449
449
  if (entry && entry.id) {
450
450
  // Fetch THIS seat's granted tools (SP5); falls back to workspace-wide when absent.
451
451
  ctx.agentMemberId = entry.id;
452
- await client.register(entry, {
452
+ // The rich self-entry is projected onto hq's `.strict()` registerSchema by
453
+ // the wire contract inside client.call (param-contract#toRegisterParams);
454
+ // passing it raw used to 400 on every boot.
455
+ const frame = await client.register(entry, {
453
456
  base: conn.base,
454
457
  token: conn.token,
455
458
  idempotencyKey: `registry.register:${entry.id}`,
456
459
  fetchImpl: o.fetchImpl,
457
460
  });
458
- logInfo(`registered ${entry.id} with the org mesh`);
461
+ // NEVER claim success we did not get. `register` fails OPEN (it returns an
462
+ // error frame rather than throwing), and this line used to log "registered
463
+ // …" unconditionally — so a rejected registration looked healthy on every
464
+ // boot while the agent was absent from the directory.
465
+ if (frame && frame.ok) {
466
+ logInfo(`registered ${entry.id} with the org mesh`);
467
+ } else {
468
+ const err = (frame && frame.error) || {};
469
+ logWarn(
470
+ `registration REJECTED for ${entry.id} (${err.code || "unknown"}: ${err.message || "no detail"}) ` +
471
+ `— this agent is NOT in the org directory; presence-beat will retry`,
472
+ );
473
+ }
459
474
  }
460
475
  } catch (err) {
461
476
  logWarn(`registration failed (${err && err.message}) — continuing; presence-beat will retry`);