@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,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hydrate.test.mjs — per-family body/context hydration.
|
|
3
|
+
*
|
|
4
|
+
* Two things are being asserted throughout:
|
|
5
|
+
* 1. every surface produces a body a classifier can actually act on, and
|
|
6
|
+
* 2. hydration only ever uses the agent's OWN entitled reads — the call log is
|
|
7
|
+
* checked so a hydrator cannot quietly acquire a wider aperture than the
|
|
8
|
+
* directedness probe that let the item through.
|
|
9
|
+
*
|
|
10
|
+
* Run: node --test lib/org/inbound/hydrate.test.mjs
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
"use strict";
|
|
14
|
+
|
|
15
|
+
import { test } from "node:test";
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
|
|
18
|
+
import { hydrate, clip, renderThread, describeBoardKind, describeApprovalKind } from "./hydrate.mjs";
|
|
19
|
+
import { classifyEvent } from "./directedness.mjs";
|
|
20
|
+
|
|
21
|
+
const ME = "M-me";
|
|
22
|
+
const THEM = "M-them";
|
|
23
|
+
const okFrame = (result) => ({ ok: true, result });
|
|
24
|
+
|
|
25
|
+
function fakeIo(routes = {}) {
|
|
26
|
+
const calls = [];
|
|
27
|
+
const logs = [];
|
|
28
|
+
return {
|
|
29
|
+
calls,
|
|
30
|
+
logs,
|
|
31
|
+
log: (level, message, meta) => logs.push({ level, message, meta }),
|
|
32
|
+
async call(method, params) {
|
|
33
|
+
calls.push({ method, params });
|
|
34
|
+
const h = routes[method];
|
|
35
|
+
if (!h) return { ok: false, error: { code: "NOT_FOUND", message: method } };
|
|
36
|
+
return typeof h === "function" ? await h(params) : h;
|
|
37
|
+
},
|
|
38
|
+
async read() {
|
|
39
|
+
return { ok: false, error: { code: "NOT_FOUND", message: "read" }, status: 404 };
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function facts(over = {}) {
|
|
45
|
+
return {
|
|
46
|
+
me: ME,
|
|
47
|
+
myNames: [],
|
|
48
|
+
channels: new Map(),
|
|
49
|
+
channelPages: new Map(),
|
|
50
|
+
myThreadRootIds: new Set(),
|
|
51
|
+
tasks: null,
|
|
52
|
+
decisions: null,
|
|
53
|
+
escalations: null,
|
|
54
|
+
approvals: null,
|
|
55
|
+
fileAcl: null,
|
|
56
|
+
degraded: [],
|
|
57
|
+
...over,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function ev(over) {
|
|
62
|
+
return { seq: 7, at: "2026-08-11T00:00:00.000Z", actor: THEM, ...over };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// messaging
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
test("messaging hydrates from the ACL'd history page — no extra RPC", async () => {
|
|
70
|
+
const f = facts();
|
|
71
|
+
f.channels.set("C-pub", { id: "C-pub", kind: "PUBLIC", name: "General", slug: "general" });
|
|
72
|
+
f.channelPages.set("C-pub", {
|
|
73
|
+
byId: new Map([
|
|
74
|
+
["m1", { id: "m1", authorId: THEM, authorName: "Casey", body: "@Isla take a look", mentions: [ME] }],
|
|
75
|
+
]),
|
|
76
|
+
messages: [{ id: "m1", authorId: THEM, authorName: "Casey", body: "@Isla take a look", mentions: [ME] }],
|
|
77
|
+
});
|
|
78
|
+
const c = classifyEvent(ev({ family: "messaging", kind: "send", entity_id: "m1", payload: { actor: THEM, channelId: "C-pub", channelKind: "PUBLIC", mentionCount: 1 } }));
|
|
79
|
+
const io = fakeIo({});
|
|
80
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "mention", reason: "mention" }, facts: f, io });
|
|
81
|
+
|
|
82
|
+
assert.equal(h.ok, true);
|
|
83
|
+
assert.equal(h.text, "@Isla take a look");
|
|
84
|
+
assert.deepEqual(h.from, { id: THEM, name: "Casey" });
|
|
85
|
+
assert.equal(h.channelLabel, "General");
|
|
86
|
+
assert.equal(io.calls.length, 0, "the body was already on the page");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("a message that never hydrated is DROPPED, not delivered blank", async () => {
|
|
90
|
+
const c = classifyEvent(ev({ family: "messaging", kind: "send", entity_id: "m404", payload: { actor: THEM, channelId: "C-pub", channelKind: "PUBLIC" } }));
|
|
91
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "mention" }, facts: facts(), io: fakeIo({}) });
|
|
92
|
+
assert.equal(h.ok, false);
|
|
93
|
+
assert.equal(h.reason, "message_not_in_page");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("an empty body is dropped (a blank inbound produces a nonsense reply)", async () => {
|
|
97
|
+
const f = facts();
|
|
98
|
+
f.channelPages.set("C", { byId: new Map([["m1", { id: "m1", authorId: THEM, body: " " }]]), messages: [] });
|
|
99
|
+
const c = classifyEvent(ev({ family: "messaging", kind: "send", entity_id: "m1", payload: { actor: THEM, channelId: "C", channelKind: "DM" } }));
|
|
100
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "dm" }, facts: f, io: fakeIo({}) });
|
|
101
|
+
assert.equal(h.ok, false);
|
|
102
|
+
assert.equal(h.reason, "empty_body");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("a threaded message carries prior-thread context", async () => {
|
|
106
|
+
const msgs = [
|
|
107
|
+
{ id: "r1", authorId: ME, authorName: "Isla", body: "kick off", threadRootId: null },
|
|
108
|
+
{ id: "m2", authorId: THEM, authorName: "Casey", body: "one more thing", threadRootId: "r1" },
|
|
109
|
+
];
|
|
110
|
+
const f = facts();
|
|
111
|
+
f.channelPages.set("C-pub", { byId: new Map(msgs.map((m) => [m.id, m])), messages: msgs });
|
|
112
|
+
const c = classifyEvent(ev({ family: "messaging", kind: "send", entity_id: "m2", payload: { actor: THEM, channelId: "C-pub", channelKind: "PUBLIC", threaded: true } }));
|
|
113
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "thread_reply", reason: "thread" }, facts: f, io: fakeIo({}) });
|
|
114
|
+
|
|
115
|
+
assert.equal(h.isReply, true);
|
|
116
|
+
assert.equal(h.threadId, "r1");
|
|
117
|
+
assert.match(h.threadContext, /Isla: kick off/);
|
|
118
|
+
assert.doesNotMatch(h.threadContext, /one more thing/, "the trigger itself is not its own context");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// board
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
test("board hydrates the task card + its comment thread", async () => {
|
|
126
|
+
const f = facts({ tasks: new Map([["T-1", { id: "T-1", title: "Ship the thing", col: "doing", priority: 1, detail: "make it fast", channelId: "C-eng" }]]) });
|
|
127
|
+
const io = fakeIo({
|
|
128
|
+
"board.taskComments": ({ taskId }) =>
|
|
129
|
+
okFrame({
|
|
130
|
+
taskId,
|
|
131
|
+
comments: [
|
|
132
|
+
{ id: "c1", authorId: ME, authorName: "Isla", body: "on it" },
|
|
133
|
+
{ id: "c2", authorId: THEM, authorName: "Casey", body: "any update?" },
|
|
134
|
+
],
|
|
135
|
+
}),
|
|
136
|
+
});
|
|
137
|
+
const c = classifyEvent(ev({ family: "board", kind: "item.commented", entity_id: "T-1", payload: { actor: THEM, kind: "human" } }));
|
|
138
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "task_comment", reason: "assignee" }, facts: f, io });
|
|
139
|
+
|
|
140
|
+
assert.equal(h.ok, true);
|
|
141
|
+
assert.match(h.text, /New comment on your board item — "Ship the thing"/);
|
|
142
|
+
assert.match(h.text, /Column: doing/);
|
|
143
|
+
assert.match(h.text, /any update\?/);
|
|
144
|
+
assert.match(h.threadContext, /Isla: on it/);
|
|
145
|
+
assert.equal(h.threadId, "T-1");
|
|
146
|
+
assert.deepEqual(io.calls.map((x) => x.method), ["board.taskComments"]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("a reviewer is TOLD they are the reviewer", async () => {
|
|
150
|
+
const f = facts({ tasks: new Map([["T-2", { id: "T-2", title: "Review me", col: "review" }]]) });
|
|
151
|
+
const io = fakeIo({ "board.taskComments": okFrame({ comments: [] }) });
|
|
152
|
+
const c = classifyEvent(ev({ family: "board", kind: "item.moved", entity_id: "T-2", payload: { actor: THEM, to: "review" } }));
|
|
153
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "task_comment", reason: "reviewer" }, facts: f, io });
|
|
154
|
+
assert.match(h.text, /You are the REVIEWER/);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("two candidates on one task share a single taskComments read", async () => {
|
|
158
|
+
let n = 0;
|
|
159
|
+
const io = fakeIo({ "board.taskComments": () => { n += 1; return okFrame({ comments: [] }); } });
|
|
160
|
+
const f = facts({ tasks: new Map([["T-3", { id: "T-3", title: "x" }]]) });
|
|
161
|
+
const cache = new Map();
|
|
162
|
+
const mk = (seq, kind) => classifyEvent(ev({ seq, family: "board", kind, entity_id: "T-3", payload: { actor: THEM } }));
|
|
163
|
+
await hydrate({ candidate: mk(1, "item.commented"), verdict: { surface: "task_comment" }, facts: f, io, cache });
|
|
164
|
+
await hydrate({ candidate: mk(2, "item.moved"), verdict: { surface: "task_comment" }, facts: f, io, cache });
|
|
165
|
+
assert.equal(n, 1);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// file / doc
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
test("a chat-file comment hydrates through file.listComments, scoped by channel", async () => {
|
|
173
|
+
const f = facts();
|
|
174
|
+
f.channels.set("C-team", { id: "C-team", kind: "PRIVATE", name: "Leads" });
|
|
175
|
+
const io = fakeIo({
|
|
176
|
+
"file.listComments": okFrame({ comments: [{ id: "fc1", authorId: THEM, authorName: "Casey", body: "typo on slide 3" }] }),
|
|
177
|
+
});
|
|
178
|
+
const c = classifyEvent(ev({ family: "file", kind: "file.commented", entity_id: "fc1", payload: { fileKey: "message-attachment:abc", channelId: "C-team", authorId: THEM } }));
|
|
179
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "file_comment", reason: "channel" }, facts: f, io });
|
|
180
|
+
|
|
181
|
+
assert.equal(h.ok, true);
|
|
182
|
+
assert.match(h.text, /typo on slide 3/);
|
|
183
|
+
// The channel is forwarded so hq can run its own visibility gate.
|
|
184
|
+
assert.equal(io.calls[0].params.channelId, "C-team");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("an unreadable file thread is dropped rather than delivered empty", async () => {
|
|
188
|
+
const c = classifyEvent(ev({ family: "file", kind: "file.commented", entity_id: "fc2", payload: { fileKey: "message-attachment:z", channelId: "C-team" } }));
|
|
189
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "file_comment" }, facts: facts(), io: fakeIo({}) });
|
|
190
|
+
assert.equal(h.ok, false);
|
|
191
|
+
assert.equal(h.reason, "file_comment_unreadable");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("a workspace doc comment hydrates through files.comments", async () => {
|
|
195
|
+
const f = facts({ fileAcl: new Map([["F-1", { ownerId: ME, shared: false, name: "Spec" }]]) });
|
|
196
|
+
const io = fakeIo({
|
|
197
|
+
"files.comments": okFrame({ comments: [{ id: "dc1", authorId: THEM, authorName: "Casey", body: "can we cut section 4?" }] }),
|
|
198
|
+
});
|
|
199
|
+
const c = classifyEvent(ev({ family: "files", kind: "doc.comment", entity_id: "F-1", payload: { name: "Spec", commentId: "dc1" } }));
|
|
200
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "doc_comment", reason: "owner" }, facts: f, io });
|
|
201
|
+
|
|
202
|
+
assert.equal(h.ok, true);
|
|
203
|
+
assert.match(h.text, /New comment on "Spec"/);
|
|
204
|
+
assert.match(h.text, /cut section 4/);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// decision / escalation / approval / handoff
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
test("a decision carries status, body, rationale and the latest comment", async () => {
|
|
212
|
+
const f = facts({
|
|
213
|
+
decisions: new Map([["D-1", { id: "D-1", title: "Adopt X", status: "proposed", scope: "eng", body: "we adopt X", rationale: "cheaper" }]]),
|
|
214
|
+
});
|
|
215
|
+
const io = fakeIo({
|
|
216
|
+
"decision.listComments": okFrame({ comments: [{ id: "c1", authorId: THEM, authorName: "Casey", body: "what about Y?" }] }),
|
|
217
|
+
});
|
|
218
|
+
const c = classifyEvent(ev({ family: "decision", kind: "decision.adjustment_requested", entity_id: "D-1", payload: { commentId: "c1", authorId: THEM } }));
|
|
219
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "decision", reason: "proposer" }, facts: f, io });
|
|
220
|
+
|
|
221
|
+
assert.match(h.text, /An adjustment was requested on a decision — "Adopt X"/);
|
|
222
|
+
assert.match(h.text, /Status: proposed/);
|
|
223
|
+
assert.match(h.text, /Rationale: cheaper/);
|
|
224
|
+
assert.match(h.text, /what about Y\?/);
|
|
225
|
+
assert.match(h.text, /needs a response/);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("an escalation needs no extra read — the facts pass already had it", async () => {
|
|
229
|
+
const f = facts({ escalations: new Map([["E-1", { id: "E-1", title: "prod down", severity: "high", detail: "500s on /v1", waitingOn: ME }]]) });
|
|
230
|
+
const io = fakeIo({});
|
|
231
|
+
const c = classifyEvent(ev({ family: "escalation", kind: "escalation.raised", entity_id: "E-1", payload: { actor: THEM, severity: "high" } }));
|
|
232
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "escalation", reason: "waiting_on" }, facts: f, io });
|
|
233
|
+
|
|
234
|
+
assert.match(h.text, /Escalation raised — "prod down"/);
|
|
235
|
+
assert.match(h.text, /Severity: high/);
|
|
236
|
+
assert.match(h.text, /Waiting on: M-me/);
|
|
237
|
+
assert.equal(io.calls.length, 0);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("an approval renders the whole decision packet", async () => {
|
|
241
|
+
const f = facts({
|
|
242
|
+
approvals: new Map([["A-1", { id: "A-1", requester: THEM, approver: ME, status: "pending", actionClass: "spend.over_1k", reason: "vendor renewal", itemId: "T-9", expiresAt: "2026-08-12T00:00:00.000Z" }]]),
|
|
243
|
+
});
|
|
244
|
+
const c = classifyEvent(ev({ family: "approval", kind: "approval.requested", entity_id: "A-1", payload: { requester: THEM, actionClass: "spend.over_1k" } }));
|
|
245
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "approval", reason: "approver" }, facts: f, io: fakeIo({}) });
|
|
246
|
+
|
|
247
|
+
assert.match(h.text, /An approval is waiting on you — spend\.over_1k/);
|
|
248
|
+
assert.match(h.text, /Status: pending/);
|
|
249
|
+
assert.match(h.text, /vendor renewal/);
|
|
250
|
+
assert.match(h.text, /Board item: T-9/);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("a handoff renders intent and both ends from the payload alone", async () => {
|
|
254
|
+
const f = facts({ tasks: new Map([["T-1", { id: "T-1", title: "Migrate DB", detail: "zero downtime" }]]) });
|
|
255
|
+
const c = classifyEvent(ev({ family: "handoff", kind: "handoff.offered", entity_id: "R-1", payload: { itemId: "T-1", from: THEM, to: ME, intent: "please take this over" } }));
|
|
256
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "handoff", reason: "direct" }, facts: f, io: fakeIo({}) });
|
|
257
|
+
|
|
258
|
+
assert.match(h.text, /A handoff was offered to you — "Migrate DB"/);
|
|
259
|
+
assert.match(h.text, /Intent: please take this over/);
|
|
260
|
+
assert.match(h.text, /From M-them → to M-me/);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
// email
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
test("email hydrates through email.thread, which hq scopes to MY mailbox", async () => {
|
|
268
|
+
const io = fakeIo({
|
|
269
|
+
"email.thread": ({ threadId }) =>
|
|
270
|
+
okFrame({
|
|
271
|
+
thread: { id: threadId, subject: "Contract renewal" },
|
|
272
|
+
messages: [
|
|
273
|
+
{ id: "EM-0", subject: "Contract renewal", text: "here is the draft", from: { name: "Dana", address: "dana@acme.com" } },
|
|
274
|
+
{ id: "EM-1", subject: "Re: Contract renewal", text: "any thoughts?", from: { name: "Dana", address: "dana@acme.com" } },
|
|
275
|
+
],
|
|
276
|
+
}),
|
|
277
|
+
});
|
|
278
|
+
const c = classifyEvent(ev({ family: "email", kind: "received", actor: "system", entity_id: "EM-1", payload: { emailMessageId: "EM-1", threadId: "ET-1", mailboxId: "MB-1", memberId: ME } }));
|
|
279
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "email", reason: "direct" }, facts: facts(), io });
|
|
280
|
+
|
|
281
|
+
assert.equal(h.ok, true);
|
|
282
|
+
assert.equal(h.text, "any thoughts?");
|
|
283
|
+
assert.equal(h.subjectDetail, "Re: Contract renewal");
|
|
284
|
+
assert.equal(h.from.name, "Dana");
|
|
285
|
+
assert.match(h.threadContext, /here is the draft/);
|
|
286
|
+
assert.deepEqual(io.calls.map((x) => x.method), ["email.thread"]);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("unreadable mail is dropped (not another seat's mail leaking through)", async () => {
|
|
290
|
+
const c = classifyEvent(ev({ family: "email", kind: "received", entity_id: "EM-9", payload: { emailMessageId: "EM-9", threadId: "ET-9", memberId: ME } }));
|
|
291
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "email" }, facts: facts(), io: fakeIo({}) });
|
|
292
|
+
assert.equal(h.ok, false);
|
|
293
|
+
assert.equal(h.reason, "email_unreadable");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
// robustness
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
test("a hydrator that throws fails open, loudly, and never takes the pull down", async () => {
|
|
301
|
+
const io = fakeIo({
|
|
302
|
+
"board.taskComments": () => {
|
|
303
|
+
throw new Error("boom");
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
const c = classifyEvent(ev({ family: "board", kind: "item.commented", entity_id: "T-1", payload: { actor: THEM } }));
|
|
307
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "task_comment" }, facts: facts(), io });
|
|
308
|
+
assert.equal(h.ok, false);
|
|
309
|
+
assert.equal(h.reason, "hydrate_threw");
|
|
310
|
+
assert.ok(io.logs.some((l) => l.level === "warn" && /hydrate board threw/.test(l.message)));
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("clip and renderThread are bounded", () => {
|
|
314
|
+
assert.equal(clip("abc", 10), "abc");
|
|
315
|
+
assert.ok(clip("x".repeat(100), 10).length <= 11);
|
|
316
|
+
assert.equal(renderThread([]), null);
|
|
317
|
+
assert.equal(renderThread(null), null);
|
|
318
|
+
assert.match(renderThread([{ authorName: "A", body: "hi" }]), /A: hi/);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("event-kind phrasing is stable and total", () => {
|
|
322
|
+
assert.match(describeBoardKind("item.assigned"), /assigned to you/);
|
|
323
|
+
assert.match(describeBoardKind("who.knows"), /Update on your board item/);
|
|
324
|
+
assert.match(describeApprovalKind("approval.requested"), /waiting on you/);
|
|
325
|
+
assert.match(describeApprovalKind("approval.rejected"), /REJECTED/);
|
|
326
|
+
});
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/org/inbound/index.mjs — WIDE inbound: give an SDK agent the same inbound
|
|
3
|
+
* coverage hq's own responder has.
|
|
4
|
+
*
|
|
5
|
+
* ── THE PROBLEM ──
|
|
6
|
+
* `lib/org/messaging.pullInbound` tails two families (`messaging`, `calling`)
|
|
7
|
+
* and recognises two things: a message in a private room I belong to, and a call
|
|
8
|
+
* in one of my rooms. hq's in-process responder (`src/server/llm-responder/`)
|
|
9
|
+
* reacts to eleven triggers — channel @mentions, thread replies, reactions,
|
|
10
|
+
* polls, decision comments, board comments, file comments, genUI actions,
|
|
11
|
+
* inbound email, call utterances, ambient. And since hq commit `4d762d30` it
|
|
12
|
+
* STANDS DOWN for any member whose daemon beat inside 100 s
|
|
13
|
+
* (`llm-responder/sdk-driven.ts`). So for a live SDK agent the net effect of an
|
|
14
|
+
* @mention in a space today is: **nobody answers**. hq is quiet by design and the
|
|
15
|
+
* SDK is deaf by omission.
|
|
16
|
+
*
|
|
17
|
+
* ── THE SHAPE OF THE FIX ──
|
|
18
|
+
* Four pure-ish stages over one injectable IO seam:
|
|
19
|
+
*
|
|
20
|
+
* events ──classifyEvent──▶ candidates (pure, directedness.mjs)
|
|
21
|
+
* │
|
|
22
|
+
* resolveFacts ◀───────┘ (agent's OWN entitled reads,
|
|
23
|
+
* │ facts.mjs — batched + capped)
|
|
24
|
+
* ▼
|
|
25
|
+
* resolveDirected (pure verdict + typed reason)
|
|
26
|
+
* │ directed only
|
|
27
|
+
* ▼
|
|
28
|
+
* hydrate (bodies, ACL'd, hydrate.mjs)
|
|
29
|
+
* │
|
|
30
|
+
* ▼
|
|
31
|
+
* toMessageEvent (existing contract, project.mjs)
|
|
32
|
+
*
|
|
33
|
+
* Nothing downstream changes: the output is the same `MessageEvent` array
|
|
34
|
+
* `pullInbound` already returns, so `guardMessagingInbound` keeps calling
|
|
35
|
+
* `eventToInboxItem` + `writeInboxItem` verbatim. The only new information the
|
|
36
|
+
* daemon sees is `kind`, which carries the surface so the classifier can route.
|
|
37
|
+
*
|
|
38
|
+
* ── SAFETY ──
|
|
39
|
+
* The org feed is org-wide and redacted. The hard rule, enforced in
|
|
40
|
+
* `directedness.mjs` and again in `facts.mjs`, is: **a channel outside my
|
|
41
|
+
* `messaging.channels` roster is never probed, never hydrated, and never
|
|
42
|
+
* directed.** hq returns a non-PUBLIC channel only to its members, so a private
|
|
43
|
+
* room I am not in cannot appear — and a negative test asserts that no
|
|
44
|
+
* `messaging.history` call is ever made for one.
|
|
45
|
+
*
|
|
46
|
+
* ── FAIL-OPEN, NEVER SILENT ──
|
|
47
|
+
* Every read that fails degrades the verdict to "unknown" (which closes private
|
|
48
|
+
* surfaces rather than opening them) and logs through `io.log`. `pullWideInbound`
|
|
49
|
+
* never throws into its caller, and returns `stats` so the cadence handler can
|
|
50
|
+
* log what it spent and what it dropped.
|
|
51
|
+
*
|
|
52
|
+
* @module lib/org/inbound
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
"use strict";
|
|
56
|
+
|
|
57
|
+
import { read as clientRead } from "../client.mjs";
|
|
58
|
+
import { classifyEvent, resolveDirected } from "./directedness.mjs";
|
|
59
|
+
import { resolveFacts, DEFAULT_LIMITS } from "./facts.mjs";
|
|
60
|
+
import { hydrate } from "./hydrate.mjs";
|
|
61
|
+
import { toMessageEvent } from "./project.mjs";
|
|
62
|
+
import { makeIo, resolveOrg } from "./io.mjs";
|
|
63
|
+
import { resolveEnabledSurfaces, parseSurfaceEnv, SURFACES } from "./surfaces.mjs";
|
|
64
|
+
|
|
65
|
+
export { SURFACES, parseSurfaceEnv, resolveEnabledSurfaces } from "./surfaces.mjs";
|
|
66
|
+
export { classifyEvent, resolveDirected } from "./directedness.mjs";
|
|
67
|
+
export { resolveFacts } from "./facts.mjs";
|
|
68
|
+
export { hydrate } from "./hydrate.mjs";
|
|
69
|
+
export { toMessageEvent } from "./project.mjs";
|
|
70
|
+
export { makeIo } from "./io.mjs";
|
|
71
|
+
|
|
72
|
+
/** Default page size for the `/v1/events` read. */
|
|
73
|
+
const DEFAULT_EVENT_LIMIT = 200;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Pull every DIRECTED org event since `cursor` and project it onto MessageEvent.
|
|
77
|
+
*
|
|
78
|
+
* Drop-in for `lib/org/messaging.pullInbound`: same `{cfg, agentId, cursor,
|
|
79
|
+
* limit, fetchImpl}` in, same `{events, nextCursor}` out — plus `stats`.
|
|
80
|
+
*
|
|
81
|
+
* @param {Object} o
|
|
82
|
+
* @param {object} o.cfg the agent config (org.cohort.*)
|
|
83
|
+
* @param {string} [o.agentId] the seat's member id (REQUIRED in
|
|
84
|
+
* practice — without it own-echo suppression is off and every direct surface
|
|
85
|
+
* misses, so we refuse to run and say so)
|
|
86
|
+
* @param {number|string} [o.cursor]
|
|
87
|
+
* @param {number} [o.limit]
|
|
88
|
+
* @param {Function} [o.fetchImpl]
|
|
89
|
+
* @param {string[]} [o.myNames] display names for prose-mention matching
|
|
90
|
+
* @param {string[]|Record<string,boolean>} [o.surfaces] surface enable override
|
|
91
|
+
* @param {object} [o.limits] overrides for {@link DEFAULT_LIMITS}
|
|
92
|
+
* @param {Function} [o.log] (level, message, meta?) — daemon logger
|
|
93
|
+
* @param {import("./io.mjs").InboundIo} [o.io] injected IO (tests)
|
|
94
|
+
* @param {Function} [o.readImpl] injected `/v1` GET (tests)
|
|
95
|
+
* @returns {Promise<{events:object[], nextCursor:(number|string|null), stats:object}>}
|
|
96
|
+
*/
|
|
97
|
+
export async function pullWideInbound(o = {}) {
|
|
98
|
+
const log = typeof o.log === "function" ? o.log : () => {};
|
|
99
|
+
const cursor = o.cursor ?? 0;
|
|
100
|
+
const stats = {
|
|
101
|
+
scanned: 0,
|
|
102
|
+
classified: 0,
|
|
103
|
+
directed: 0,
|
|
104
|
+
delivered: 0,
|
|
105
|
+
bySurface: {},
|
|
106
|
+
dropped: {},
|
|
107
|
+
degraded: [],
|
|
108
|
+
calls: 0,
|
|
109
|
+
reads: 0,
|
|
110
|
+
};
|
|
111
|
+
const empty = { events: [], nextCursor: cursor, stats };
|
|
112
|
+
|
|
113
|
+
const org = resolveOrg(o.cfg);
|
|
114
|
+
if (!org) {
|
|
115
|
+
log("info", "[inbound] org messaging not configured — wide inbound inert");
|
|
116
|
+
return empty;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const me = o.agentId ? String(o.agentId) : "";
|
|
120
|
+
if (!me) {
|
|
121
|
+
// NEVER SILENT. Without the seat id we cannot suppress the agent's own
|
|
122
|
+
// echoes and every payload-named surface (email/approval/handoff) misses.
|
|
123
|
+
// Running anyway would produce an agent talking to itself.
|
|
124
|
+
log("error", "[inbound] no agentId — refusing to pull (set COHORT_AGENT_ID)");
|
|
125
|
+
return empty;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const { enabled, unknown } = resolveEnabledSurfaces(
|
|
129
|
+
o.surfaces !== undefined ? o.surfaces : parseSurfaceEnv(process.env.COHORT_INBOUND_SURFACES),
|
|
130
|
+
);
|
|
131
|
+
if (unknown.length) {
|
|
132
|
+
log("warn", `[inbound] ignoring unknown surface name(s): ${unknown.join(", ")}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const io =
|
|
136
|
+
o.io ||
|
|
137
|
+
makeIo({ cfg: o.cfg, fetchImpl: o.fetchImpl, log, timeoutMs: o.timeoutMs });
|
|
138
|
+
if (!io) return empty;
|
|
139
|
+
|
|
140
|
+
// ── 1. Read the ledger window. hq IGNORES `family=` on this read
|
|
141
|
+
// (`src/server/rpc/reads.ts` looks at `cursor` + `limit` only), so the
|
|
142
|
+
// filter is ours to apply — which is exactly why widening the aperture
|
|
143
|
+
// costs no extra bandwidth: the rows were always arriving.
|
|
144
|
+
const limit = Number.isFinite(o.limit) ? o.limit : DEFAULT_EVENT_LIMIT;
|
|
145
|
+
const readImpl = o.readImpl || (io.read ? (p) => io.read(p) : clientRead);
|
|
146
|
+
const r = await readImpl(
|
|
147
|
+
`events?cursor=${encodeURIComponent(cursor)}&limit=${encodeURIComponent(limit)}`,
|
|
148
|
+
);
|
|
149
|
+
if (!r || r.ok !== true || !r.payload) {
|
|
150
|
+
log("warn", "[inbound] events read failed — cursor held, will retry next tick");
|
|
151
|
+
return empty;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const raw = Array.isArray(r.payload)
|
|
155
|
+
? r.payload
|
|
156
|
+
: Array.isArray(r.payload.events)
|
|
157
|
+
? r.payload.events
|
|
158
|
+
: [];
|
|
159
|
+
stats.scanned = raw.length;
|
|
160
|
+
|
|
161
|
+
let maxSeq = cursor;
|
|
162
|
+
const candidates = [];
|
|
163
|
+
for (const ev of raw) {
|
|
164
|
+
const seq = Number(ev && ev.seq);
|
|
165
|
+
if (Number.isFinite(seq)) maxSeq = Math.max(Number(maxSeq) || 0, seq);
|
|
166
|
+
const cand = classifyEvent(ev);
|
|
167
|
+
if (cand) candidates.push(cand);
|
|
168
|
+
}
|
|
169
|
+
stats.classified = candidates.length;
|
|
170
|
+
const nextCursor = raw.length ? maxSeq : cursor;
|
|
171
|
+
|
|
172
|
+
if (candidates.length === 0) return { events: [], nextCursor, stats };
|
|
173
|
+
|
|
174
|
+
// ── 2. The agent's own half of the join.
|
|
175
|
+
const facts = await resolveFacts({
|
|
176
|
+
candidates,
|
|
177
|
+
me,
|
|
178
|
+
io,
|
|
179
|
+
myNames: o.myNames,
|
|
180
|
+
enabled,
|
|
181
|
+
limits: { ...DEFAULT_LIMITS, ...(o.limits || {}) },
|
|
182
|
+
});
|
|
183
|
+
stats.degraded = facts.degraded.slice();
|
|
184
|
+
|
|
185
|
+
// ── 3. Verdicts.
|
|
186
|
+
const directed = [];
|
|
187
|
+
for (const cand of candidates) {
|
|
188
|
+
const verdict = resolveDirected(cand, me, facts, { enabled });
|
|
189
|
+
if (!verdict.directed) {
|
|
190
|
+
stats.dropped[verdict.reason] = (stats.dropped[verdict.reason] || 0) + 1;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
directed.push({ cand, verdict });
|
|
194
|
+
}
|
|
195
|
+
stats.directed = directed.length;
|
|
196
|
+
|
|
197
|
+
// ── 4. Hydrate + project. Body reads happen ONLY for what is already mine.
|
|
198
|
+
const cache = new Map();
|
|
199
|
+
const events = [];
|
|
200
|
+
for (const { cand, verdict } of directed) {
|
|
201
|
+
const hydrated = await hydrate({ candidate: cand, verdict, facts, io, cache });
|
|
202
|
+
if (!hydrated || hydrated.ok !== true) {
|
|
203
|
+
const why = (hydrated && hydrated.reason) || "hydration_failed";
|
|
204
|
+
stats.dropped[why] = (stats.dropped[why] || 0) + 1;
|
|
205
|
+
// A blank inbound produces a nonsense reply in a real room. Drop it, loudly.
|
|
206
|
+
log("warn", `[inbound] dropping ${verdict.surface} ${cand.entityId || cand.seq}: ${why}`);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const projected = toMessageEvent({ candidate: cand, verdict, hydrated, me, facts });
|
|
210
|
+
if (!projected) {
|
|
211
|
+
stats.dropped.projection_refused = (stats.dropped.projection_refused || 0) + 1;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
events.push(projected);
|
|
215
|
+
stats.bySurface[verdict.surface] = (stats.bySurface[verdict.surface] || 0) + 1;
|
|
216
|
+
}
|
|
217
|
+
stats.delivered = events.length;
|
|
218
|
+
if (io.stats) {
|
|
219
|
+
stats.calls = io.stats.calls;
|
|
220
|
+
stats.reads = io.stats.reads;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (events.length) {
|
|
224
|
+
const summary = Object.entries(stats.bySurface)
|
|
225
|
+
.map(([k, v]) => `${k}×${v}`)
|
|
226
|
+
.join(", ");
|
|
227
|
+
log("info", `[inbound] ${events.length} directed item(s): ${summary}`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return { events, nextCursor, stats };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export default { pullWideInbound };
|