@cohortapp/agent-sdk 2.5.0 → 2.6.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 (108) hide show
  1. package/bin/maestro.mjs +185 -88
  2. package/bin/maestro.test.mjs +175 -48
  3. package/docs/runbooks/backup-restore.md +65 -33
  4. package/framework-features.json +4 -4
  5. package/lib/backup/policy.mjs +710 -0
  6. package/lib/backup/policy.test.mjs +305 -0
  7. package/lib/budget-escalate.mjs +133 -0
  8. package/lib/budget-escalate.test.mjs +232 -0
  9. package/lib/budget-guard.envelope.test.mjs +476 -0
  10. package/lib/budget-guard.mjs +853 -75
  11. package/lib/budget-guard.test.mjs +91 -42
  12. package/lib/cadences.mjs +33 -0
  13. package/lib/channels/orgmail/adapter.mjs +88 -3
  14. package/lib/channels/orgmail/adapter.test.mjs +137 -0
  15. package/lib/channels/repeat-suppressor.mjs +198 -0
  16. package/lib/channels/repeat-suppressor.test.mjs +134 -0
  17. package/lib/comms/receipts.mjs +297 -0
  18. package/lib/cost/ledger-row.mjs +333 -0
  19. package/lib/cost/ledger-row.test.mjs +183 -0
  20. package/lib/execution/drive.mjs +28 -1
  21. package/lib/execution/effects.mjs +191 -12
  22. package/lib/execution/effects.test.mjs +50 -11
  23. package/lib/goals/admission.mjs +13 -1
  24. package/lib/goals/admission.test.mjs +26 -1
  25. package/lib/goals/loop.mjs +13 -0
  26. package/lib/kpi-sensors.test.mjs +3 -0
  27. package/lib/mandate/cache.mjs +13 -5
  28. package/lib/mandate/derive.mjs +146 -21
  29. package/lib/mandate/derive.test.mjs +50 -6
  30. package/lib/mandate/model.mjs +32 -4
  31. package/lib/mandate/refresh.test.mjs +16 -2
  32. package/lib/mcp/server.test.mjs +12 -3
  33. package/lib/model-router/economics.mjs +107 -76
  34. package/lib/model-router/economics.test.mjs +64 -46
  35. package/lib/model-router/integration-coverage.test.mjs +39 -37
  36. package/lib/model-router/ledger.mjs +75 -22
  37. package/lib/model-router/ledger.test.mjs +35 -2
  38. package/lib/org/client.mjs +14 -0
  39. package/lib/org/cost-sync.mjs +16 -2
  40. package/lib/org/doctor.mjs +62 -1
  41. package/lib/org/doctor.test.mjs +36 -3
  42. package/lib/org/email-remedy.mjs +49 -0
  43. package/lib/org/engagement-ledger.mjs +376 -0
  44. package/lib/org/engagement-ledger.test.mjs +112 -0
  45. package/lib/org/engagement.mjs +1056 -0
  46. package/lib/org/engagement.test.mjs +739 -0
  47. package/lib/org/inbound/hydrate.mjs +107 -15
  48. package/lib/org/inbound/hydrate.test.mjs +127 -0
  49. package/lib/org/messaging.mjs +230 -3
  50. package/lib/org/messaging.test.mjs +110 -1
  51. package/lib/org/param-contract.mjs +56 -2
  52. package/lib/org/param-contract.test.mjs +26 -0
  53. package/lib/org/protocol.checksum +1 -1
  54. package/lib/org/protocol.mjs +5 -0
  55. package/lib/org/protocol.test.mjs +7 -1
  56. package/lib/org/tool-surface.mjs +506 -10
  57. package/lib/org/tool-surface.test.mjs +191 -7
  58. package/lib/org/ui-parity.mjs +333 -6
  59. package/lib/org/ui-parity.test.mjs +96 -3
  60. package/lib/org/work-ledger.mjs +241 -0
  61. package/lib/org/work-ledger.test.mjs +237 -0
  62. package/lib/plan/adoption-e2e.test.mjs +366 -0
  63. package/lib/plan/budget-enforcement.test.mjs +400 -0
  64. package/lib/plan/budget-runtime.mjs +215 -0
  65. package/lib/plan/compile.mjs +201 -5
  66. package/lib/plan/compile.test.mjs +19 -5
  67. package/lib/plan/emit.mjs +8 -0
  68. package/lib/plan/emit.test.mjs +18 -0
  69. package/lib/resource-governor.mjs +58 -12
  70. package/lib/resource-governor.test.mjs +41 -1
  71. package/lib/security/audit-engine.mjs +45 -8
  72. package/lib/security/audit-engine.test.mjs +35 -0
  73. package/lib/setup/enroll-from-cohort.mjs +14 -1
  74. package/lib/setup/sections/mandate.mjs +48 -7
  75. package/lib/setup/sections/mandate.test.mjs +17 -2
  76. package/lib/setup/sections/orgmail.mjs +10 -2
  77. package/lib/setup/state.mjs +83 -2
  78. package/lib/telemetry/collect.mjs +360 -20
  79. package/lib/telemetry/collect.test.mjs +266 -0
  80. package/package.json +1 -1
  81. package/scripts/cost/track-claude-usage.mjs +207 -48
  82. package/scripts/cost/track-claude-usage.test.mjs +148 -0
  83. package/scripts/daemon/agent-daemon.mjs +315 -17
  84. package/scripts/daemon/assurance-e2e.test.mjs +421 -0
  85. package/scripts/daemon/assurance.mjs +944 -0
  86. package/scripts/daemon/assurance.test.mjs +668 -0
  87. package/scripts/daemon/cadence-consumer-governance.test.mjs +56 -0
  88. package/scripts/daemon/cadence-consumer.mjs +147 -9
  89. package/scripts/daemon/cadence-consumer.test.mjs +6 -0
  90. package/scripts/daemon/cadence-handlers.mjs +158 -0
  91. package/scripts/daemon/cadence-handlers.test.mjs +64 -0
  92. package/scripts/daemon/deliver.mjs +314 -0
  93. package/scripts/daemon/dispatcher-governance.test.mjs +10 -0
  94. package/scripts/daemon/dispatcher.mjs +64 -6
  95. package/scripts/daemon/responder-cost.test.mjs +68 -0
  96. package/scripts/daemon/responder.mjs +351 -298
  97. package/scripts/local-triggers/generate-plists.test.mjs +7 -4
  98. package/scripts/maintenance/backup-run.mjs +415 -0
  99. package/scripts/maintenance/backup-to-cloud.sh +16 -116
  100. package/scripts/org/send-orgmail.mjs +16 -0
  101. package/scripts/record-receipt.sh +63 -0
  102. package/scripts/restore-from-backup.sh +14 -3
  103. package/scripts/restore-from-backup.test.mjs +8 -5
  104. package/scripts/send-email-threaded.py +47 -0
  105. package/scripts/send-sms.sh +4 -0
  106. package/scripts/send-whatsapp.sh +4 -0
  107. package/scripts/setup/init-backup.mjs +93 -38
  108. package/scripts/slack-send.sh +12 -0
@@ -15,6 +15,10 @@ import {
15
15
  reactMessage,
16
16
  fetchHistory,
17
17
  listChannels,
18
+ openDm,
19
+ openConversation,
20
+ createChannel,
21
+ addChannelMember,
18
22
  initiateCall,
19
23
  joinCall,
20
24
  pullInbound,
@@ -93,10 +97,41 @@ test("sendMessage: a clean message posts to /v1/messaging.send with Bearer + pro
93
97
  // wire shape
94
98
  assert.equal(f.calls[0].body.channel, "C-eng");
95
99
  assert.equal(f.calls[0].body.body, "Deploy is green on staging.");
96
- assert.deepEqual(f.calls[0].body.mentions, ["agent-2"]);
100
+ // `[{memberId}]`, NOT `["agent-2"]`. hq's mentionSchema is an object schema,
101
+ // so the bare-string form this used to assert did not merely lose the tag — it
102
+ // failed zod and 400'd the entire send. The one call an agent makes to bring a
103
+ // colleague in was the one call guaranteed to fail.
104
+ assert.deepEqual(f.calls[0].body.mentions, [{ memberId: "agent-2" }]);
97
105
  assert.equal(f.calls[0].body.id, "msg-xyz");
98
106
  });
99
107
 
108
+ test("sendMessage: mentions accept strings or objects, de-dupe, and keep offsets", async () => {
109
+ const f = fakeFetch(() => ({ body: { ok: true, result: { id: "m2b" } } }));
110
+ await sendMessage(
111
+ {
112
+ channel: "C-eng",
113
+ body: "hi",
114
+ mentions: ["agent-2", { memberId: "agent-2" }, { id: "agent-3" }, { memberId: "agent-4", offset: 7 }, "", null],
115
+ idempotencyId: "msg-m",
116
+ },
117
+ { cfg: CFG, fetchImpl: f, gateImpl: allowGate, hookBus: captureBus(), ledgerImpl: captureLedger() },
118
+ );
119
+ assert.deepEqual(f.calls[0].body.mentions, [
120
+ { memberId: "agent-2" },
121
+ { memberId: "agent-3" },
122
+ { memberId: "agent-4", offset: 7 },
123
+ ]);
124
+ });
125
+
126
+ test("sendMessage: no mentions means the key is absent, not an empty array", async () => {
127
+ const f = fakeFetch(() => ({ body: { ok: true, result: { id: "m2c" } } }));
128
+ await sendMessage(
129
+ { channel: "C-eng", body: "hi", mentions: [] },
130
+ { cfg: CFG, fetchImpl: f, gateImpl: allowGate, hookBus: captureBus(), ledgerImpl: captureLedger() },
131
+ );
132
+ assert.equal("mentions" in f.calls[0].body, false);
133
+ });
134
+
100
135
  test("sendMessage: emits afterSend + attributes a source='messaging' ledger row on success", async () => {
101
136
  const f = fakeFetch(() => ({ body: { ok: true, result: { id: "m3" } } }));
102
137
  const ledger = captureLedger();
@@ -353,3 +388,77 @@ test("isDirectedAtAgent: an explicit mentions[] still wins when a producer suppl
353
388
  // …and someone else's mention in the same space is still not ours.
354
389
  assert.equal(isDirectedAtAgent(ev, "agent-other"), false);
355
390
  });
391
+
392
+ // ---------------------------------------------------------------------------
393
+ // Rooms — the four verbs the SDK had in its protocol table and could not call
394
+ // ---------------------------------------------------------------------------
395
+
396
+ test("openDm resolves the 1:1 channel and hands back the id the send needs", async () => {
397
+ const f = fakeFetch(() => ({ body: { ok: true, result: { channelId: "C-dm-1", created: true } } }));
398
+ const r = await openDm({ memberId: "M-2" }, { cfg: CFG, fetchImpl: f });
399
+ assert.equal(r.ok, true);
400
+ assert.equal(r.channelId, "C-dm-1");
401
+ assert.equal(r.created, true);
402
+ assert.match(f.calls[0].url, /\/v1\/channel\.resolveOrCreateDm$/);
403
+ assert.deepEqual(f.calls[0].body, { memberId: "M-2" });
404
+ });
405
+
406
+ test("openDm reads the channel id out of whichever shape the handler returns", async () => {
407
+ for (const result of [{ id: "C-x" }, { channelId: "C-x" }, { channel: { id: "C-x" } }]) {
408
+ const r = await openDm({ memberId: "M-2" }, { cfg: CFG, fetchImpl: fakeFetch(() => ({ body: { ok: true, result } })) });
409
+ assert.equal(r.channelId, "C-x", JSON.stringify(result));
410
+ }
411
+ });
412
+
413
+ test("openConversation de-dupes the member list and sends hq's `memberIds`", async () => {
414
+ const f = fakeFetch(() => ({ body: { ok: true, result: { id: "C-gdm" } } }));
415
+ const r = await openConversation({ memberIds: ["M-2", "M-3", "M-2", ""], name: "Vendor contract" }, { cfg: CFG, fetchImpl: f });
416
+ assert.equal(r.ok, true);
417
+ assert.equal(r.channelId, "C-gdm");
418
+ assert.match(f.calls[0].url, /\/v1\/channel\.createConversation$/);
419
+ assert.deepEqual(f.calls[0].body.memberIds, ["M-2", "M-3"]);
420
+ assert.equal(f.calls[0].body.name, "Vendor contract");
421
+ });
422
+
423
+ test("openConversation with nobody in it never reaches the wire", async () => {
424
+ const f = fakeFetch(() => ({ body: { ok: true, result: {} } }));
425
+ const r = await openConversation({ memberIds: [] }, { cfg: CFG, fetchImpl: f });
426
+ assert.equal(r.ok, false);
427
+ assert.equal(f.calls.length, 0);
428
+ });
429
+
430
+ test("createChannel refuses a slug hq would reject, before spending a round trip", async () => {
431
+ const f = fakeFetch(() => ({ body: { ok: true, result: { id: "C-1" } } }));
432
+ const bad = await createChannel({ slug: "Vendor Contract!", name: "Vendor" }, { cfg: CFG, fetchImpl: f });
433
+ assert.equal(bad.ok, false);
434
+ assert.match(bad.frame.error.message, /lowercase alphanumerics and hyphens/);
435
+ assert.equal(f.calls.length, 0);
436
+
437
+ const good = await createChannel({ slug: "vendor-contract", name: "Vendor", members: ["M-2"] }, { cfg: CFG, fetchImpl: f });
438
+ assert.equal(good.ok, true);
439
+ assert.equal(f.calls[0].body.kind, "PRIVATE", "a channel an agent makes is private by default");
440
+ assert.deepEqual(f.calls[0].body.members, ["M-2"]);
441
+ });
442
+
443
+ test("addChannelMember reports hq's idempotent re-add as added:false, not as a failure", async () => {
444
+ const f = fakeFetch(() => ({ body: { ok: true, result: { channelId: "C-1", memberId: "M-2", added: false } } }));
445
+ const r = await addChannelMember({ channelId: "C-1", memberId: "M-2" }, { cfg: CFG, fetchImpl: f });
446
+ assert.equal(r.ok, true);
447
+ assert.equal(r.added, false);
448
+ assert.match(f.calls[0].url, /\/v1\/channel\.addMember$/);
449
+ });
450
+
451
+ test("every room verb fails open to an error frame when the org is unconfigured", async () => {
452
+ const off = { org: { cohort: { enabled: false } } };
453
+ for (const [name, fn, args] of [
454
+ ["openDm", openDm, { memberId: "M-2" }],
455
+ ["openConversation", openConversation, { memberIds: ["M-2"] }],
456
+ ["createChannel", createChannel, { slug: "x", name: "X" }],
457
+ ["addChannelMember", addChannelMember, { channelId: "C", memberId: "M-2" }],
458
+ ]) {
459
+ const r = await fn(args, { cfg: off });
460
+ assert.equal(r.ok, false, name);
461
+ assert.equal(r.frame.ok, false, name);
462
+ assert.ok(r.frame.error.message, `${name} must say why`);
463
+ }
464
+ });
@@ -131,6 +131,54 @@ function firstString(...vals) {
131
131
  return "";
132
132
  }
133
133
 
134
+ /**
135
+ * Coerce `mentions` onto the OBJECT shape hq validates.
136
+ *
137
+ * hq's `mentionSchema` is `z.object({memberId, offset?})` and `mentions` is an
138
+ * array of it (server/validation/message.ts). Every SDK call site wrote the
139
+ * obvious thing — an array of member id STRINGS — and zod rejects the array, so
140
+ * the entire `messaging.send` came back BAD_REQUEST. Not "the tags were lost":
141
+ * the whole message was refused. Tagging somebody is the one action an agent
142
+ * takes to make a colleague accountable for answering, and it was the one action
143
+ * guaranteed to fail.
144
+ *
145
+ * It belongs HERE, in the one contract every plane goes through, rather than in
146
+ * any single helper: the curated `messaging_send` tool, the `org_rpc` escape
147
+ * hatch, the ui-parity wrappers and `messaging.sendMessage` all reach the wire
148
+ * through `client.call`, and fixing one of them would have left the rest armed.
149
+ *
150
+ * Idempotent: already-shaped input passes through untouched.
151
+ *
152
+ * @param {object} params
153
+ * @returns {object}
154
+ */
155
+ export function normaliseMentionParams(params) {
156
+ const p = isObj(params) ? params : {};
157
+ if (!Array.isArray(p.mentions)) return p;
158
+ const seen = new Set();
159
+ const mentions = [];
160
+ for (const m of p.mentions) {
161
+ let memberId = "";
162
+ let offset;
163
+ if (typeof m === "string" || typeof m === "number") {
164
+ memberId = String(m).trim();
165
+ } else if (isObj(m)) {
166
+ memberId = String(m.memberId ?? m.id ?? m.member ?? "").trim();
167
+ if (Number.isInteger(m.offset) && m.offset >= 0) offset = m.offset;
168
+ }
169
+ if (!memberId || seen.has(memberId)) continue;
170
+ seen.add(memberId);
171
+ mentions.push(offset === undefined ? { memberId } : { memberId, offset });
172
+ }
173
+ // An EMPTY list is dropped rather than sent: hq is happy with `[]`, but an
174
+ // absent key states "no tags" without asserting an empty relation.
175
+ if (!mentions.length) {
176
+ const { mentions: _drop, ...rest } = p;
177
+ return rest;
178
+ }
179
+ return { ...p, mentions: mentions.slice(0, 50) };
180
+ }
181
+
134
182
  /**
135
183
  * Project a rich maestro self-entry onto hq's `.strict()` registerSchema
136
184
  * ({displayName, archetype, humanSponsor, card}). Everything the server does not
@@ -255,7 +303,11 @@ export const PARAM_CONTRACT = {
255
303
  },
256
304
  mint: ["idempotencyId"],
257
305
  required: ["channelId", "body", "idempotencyId"],
258
- note: "hq reads idempotencyId (NOT clientMsgId) — sendMessageSchemaV1.",
306
+ transform: normaliseMentionParams,
307
+ note:
308
+ "hq reads idempotencyId (NOT clientMsgId) — sendMessageSchemaV1. `mentions` is an array of " +
309
+ "OBJECTS ({memberId, offset?}), not of id strings: the string form fails zod and 400s the " +
310
+ "whole send, so the transform coerces it.",
259
311
  },
260
312
  "messaging.history": {
261
313
  alias: { channel: "channelId", cursor: "before", since: "after" },
@@ -274,7 +326,9 @@ export const PARAM_CONTRACT = {
274
326
  note: "pagination anchors are MESSAGE IDS (before=older, after=newer), not timestamps.",
275
327
  },
276
328
  "messaging.react": { alias: { channel: "channelId" }, required: ["messageId", "emoji"] },
277
- "messaging.edit": { required: ["messageId", "body"] },
329
+ // Same object-shaped `mentions` as `messaging.send` (editMessageSchema reuses
330
+ // mentionSchema), so it carries the same landmine and the same fix.
331
+ "messaging.edit": { required: ["messageId", "body"], transform: normaliseMentionParams },
278
332
 
279
333
  // ── calling ──────────────────────────────────────────────────────────────
280
334
  "calling.start": {
@@ -449,3 +449,29 @@ test("GUARD: enum-valued tool params list exactly hq's vocabulary", () => {
449
449
  }
450
450
  }
451
451
  });
452
+
453
+ test("messaging.send/edit: mentions are coerced to hq's object shape at the ONE chokepoint", () => {
454
+ const sent = normalizeParams("messaging.send", {
455
+ channelId: "C-1",
456
+ body: "b",
457
+ idempotencyId: "i",
458
+ mentions: ["M-2", { memberId: "M-2" }, { id: "M-3" }, { memberId: "M-4", offset: 3 }, "", null],
459
+ });
460
+ assert.deepEqual(sent.params.mentions, [
461
+ { memberId: "M-2" },
462
+ { memberId: "M-3" },
463
+ { memberId: "M-4", offset: 3 },
464
+ ]);
465
+
466
+ // Idempotent: applying the contract twice must not re-wrap.
467
+ const twice = normalizeParams("messaging.send", sent.params);
468
+ assert.deepEqual(twice.params.mentions, sent.params.mentions);
469
+
470
+ // An empty list states nothing rather than asserting an empty relation.
471
+ const none = normalizeParams("messaging.send", { channelId: "C", body: "b", idempotencyId: "i", mentions: [] });
472
+ assert.equal("mentions" in none.params, false);
473
+
474
+ // messaging.edit reuses hq's mentionSchema, so it gets the same coercion.
475
+ const edited = normalizeParams("messaging.edit", { messageId: "m1", body: "b", mentions: ["M-9"] });
476
+ assert.deepEqual(edited.params.mentions, [{ memberId: "M-9" }]);
477
+ });
@@ -1 +1 @@
1
- 1946d98145f4c1e56d80e58c37d9b54d5ea8d88c7d30251a694484418327dddf
1
+ 358b95ed6a48faeffa78b7ca717a909905e8ff4e7b75278edf274a0acdaf5dd6
@@ -485,6 +485,11 @@ export const METHODS = Object.freeze({
485
485
  "board.removeTaskAttachment": { family: "board", scope: "board.write", sideEffecting: true },
486
486
  "board.taskComments": { family: "board", scope: "board.read", sideEffecting: false },
487
487
  "board.taskAttachments": { family: "board", scope: "board.read", sideEffecting: false },
488
+ // board.track — ONE step of an agent's work made visible: find-or-open the row
489
+ // for an ask, move its column, comment progress, attach what it produced, and
490
+ // TAG the requester. Idempotent on (askKey, stage): the same step replayed
491
+ // creates no second row, no duplicate comment and no second notification.
492
+ "board.track": { family: "board", scope: "board.write", sideEffecting: true, idempotent: true },
488
493
  // --- invitation ---
489
494
  "invitation.create": { family: "invitation", scope: "admin", sideEffecting: true, idempotent: true },
490
495
  "invitation.revoke": { family: "invitation", scope: "admin", sideEffecting: true },
@@ -169,8 +169,14 @@ test("protocol: frozen family + method counts (additive evolution guard)", () =>
169
169
  // escalation.ask/answer, memory.recall, preference.upsert/list (6 reverse-
170
170
  // parity methods), the 9 subagent.* registry methods, agent.wait, the 11
171
171
  // mandate.* spine methods, and board.requestReview/resolveReview → 460.
172
+ // The 2026-08 work-visibility pass adds 1: board.track — ONE step of an
173
+ // agent's work made visible (find-or-open the row for an ask, move its
174
+ // column, comment progress, attach output, tag the requester). It is the
175
+ // single door BOTH planes use: hq's in-process responder calls the same
176
+ // `server/work/ledger.ts` directly, the daemon reaches it over this wire, so
177
+ // the threshold and the idempotency cannot fork → 461.
172
178
  // the descriptors; the checksum (read at runtime) is the primary drift guard.
173
- assert.equal(Object.keys(METHODS).length, 460, "method count");
179
+ assert.equal(Object.keys(METHODS).length, 461, "method count");
174
180
  });
175
181
 
176
182
  test("protocol SP3: messaging + calling families/methods/scopes", async () => {