@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,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
|
+
};
|
package/lib/org/knowledge.mjs
CHANGED
|
@@ -377,7 +377,16 @@ export async function listMeetings(opts = {}) {
|
|
|
377
377
|
// internals (pure shaping)
|
|
378
378
|
// ---------------------------------------------------------------------------
|
|
379
379
|
|
|
380
|
-
/**
|
|
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
|
-
|
|
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
|
});
|
package/lib/org/leases.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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`);
|