@cohortapp/agent-sdk 2.3.1 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/bin/maestro.mjs +37 -50
  2. package/framework-features.json +30 -0
  3. package/lib/backlog.mjs +136 -0
  4. package/lib/cadences.mjs +63 -2
  5. package/lib/cadences.test.mjs +105 -0
  6. package/lib/capability/inventory.mjs +542 -0
  7. package/lib/capability/inventory.test.mjs +232 -0
  8. package/lib/capability/probe.mjs +255 -0
  9. package/lib/channels/contract.mjs +37 -1
  10. package/lib/channels/contract.test.mjs +25 -1
  11. package/lib/channels/inbox-item.mjs +20 -0
  12. package/lib/claude-bin.mjs +37 -3
  13. package/lib/claude-bin.test.mjs +42 -8
  14. package/lib/execution/disposition.mjs +501 -0
  15. package/lib/execution/disposition.test.mjs +482 -0
  16. package/lib/execution/drive.mjs +352 -0
  17. package/lib/execution/drive.test.mjs +270 -0
  18. package/lib/execution/effects.mjs +340 -0
  19. package/lib/execution/effects.test.mjs +193 -0
  20. package/lib/execution/index.mjs +152 -0
  21. package/lib/execution/intake.mjs +581 -0
  22. package/lib/execution/intake.test.mjs +343 -0
  23. package/lib/execution/journal.mjs +374 -0
  24. package/lib/execution/journal.test.mjs +261 -0
  25. package/lib/execution/match.mjs +331 -0
  26. package/lib/execution/match.test.mjs +235 -0
  27. package/lib/execution/pipeline.mjs +341 -0
  28. package/lib/execution/pipeline.test.mjs +389 -0
  29. package/lib/execution/route.mjs +332 -0
  30. package/lib/execution/route.test.mjs +186 -0
  31. package/lib/execution/surface-policy.mjs +446 -0
  32. package/lib/execution/surface-policy.test.mjs +162 -0
  33. package/lib/goals/admission.mjs +209 -0
  34. package/lib/goals/admission.test.mjs +139 -0
  35. package/lib/goals/classify.mjs +206 -0
  36. package/lib/goals/classify.test.mjs +109 -0
  37. package/lib/goals/collaborate.mjs +415 -0
  38. package/lib/goals/collaborate.test.mjs +324 -0
  39. package/lib/goals/gaps.mjs +111 -0
  40. package/lib/goals/gaps.test.mjs +284 -0
  41. package/lib/goals/loop.mjs +537 -0
  42. package/lib/goals/loop.test.mjs +719 -0
  43. package/lib/identity/persona.mjs +247 -0
  44. package/lib/identity/persona.test.mjs +117 -0
  45. package/lib/kpi.mjs +469 -0
  46. package/lib/kpi.test.mjs +244 -0
  47. package/lib/mandate/audit.mjs +168 -0
  48. package/lib/mandate/audit.test.mjs +195 -0
  49. package/lib/mandate/cache.mjs +162 -0
  50. package/lib/mandate/derive.mjs +317 -0
  51. package/lib/mandate/derive.test.mjs +224 -0
  52. package/lib/mandate/model.mjs +352 -0
  53. package/lib/mandate/model.test.mjs +145 -0
  54. package/lib/mandate/refresh.mjs +187 -0
  55. package/lib/mandate/refresh.test.mjs +293 -0
  56. package/lib/mcp/server.test.mjs +4 -4
  57. package/lib/org/approvals.mjs +14 -2
  58. package/lib/org/client.mjs +79 -25
  59. package/lib/org/client.test.mjs +54 -1
  60. package/lib/org/doctor.mjs +64 -0
  61. package/lib/org/doctor.test.mjs +31 -2
  62. package/lib/org/inbound/directedness.mjs +720 -0
  63. package/lib/org/inbound/directedness.test.mjs +543 -0
  64. package/lib/org/inbound/facts.mjs +501 -0
  65. package/lib/org/inbound/facts.test.mjs +375 -0
  66. package/lib/org/inbound/hydrate.mjs +535 -0
  67. package/lib/org/inbound/hydrate.test.mjs +326 -0
  68. package/lib/org/inbound/index.mjs +233 -0
  69. package/lib/org/inbound/index.test.mjs +324 -0
  70. package/lib/org/inbound/io.mjs +141 -0
  71. package/lib/org/inbound/project.mjs +201 -0
  72. package/lib/org/inbound/project.test.mjs +287 -0
  73. package/lib/org/inbound/surfaces.mjs +257 -0
  74. package/lib/org/knowledge.mjs +10 -1
  75. package/lib/org/knowledge.test.mjs +8 -1
  76. package/lib/org/leases.mjs +5 -0
  77. package/lib/org/mesh.mjs +45 -2
  78. package/lib/org/mesh.test.mjs +55 -0
  79. package/lib/org/messaging.mjs +180 -15
  80. package/lib/org/messaging.test.mjs +117 -0
  81. package/lib/org/param-contract.mjs +694 -0
  82. package/lib/org/param-contract.test.mjs +451 -0
  83. package/lib/org/protocol.checksum +1 -1
  84. package/lib/org/protocol.mjs +8 -0
  85. package/lib/org/protocol.test.mjs +5 -1
  86. package/lib/org/push.mjs +1025 -0
  87. package/lib/org/push.test.mjs +690 -0
  88. package/lib/org/tool-surface.mjs +138 -38
  89. package/lib/org/tool-surface.test.mjs +13 -8
  90. package/lib/org/typing.mjs +341 -0
  91. package/lib/org/typing.test.mjs +291 -0
  92. package/lib/plan/compile.mjs +510 -0
  93. package/lib/plan/compile.test.mjs +286 -0
  94. package/lib/plan/emit.mjs +256 -0
  95. package/lib/plan/emit.test.mjs +246 -0
  96. package/lib/plan/explain.mjs +226 -0
  97. package/lib/plan/explain.test.mjs +188 -0
  98. package/lib/plan/schema.mjs +140 -0
  99. package/lib/resource-governor.mjs +47 -1
  100. package/lib/resource-governor.test.mjs +21 -1
  101. package/lib/setup/enroll-from-cohort.mjs +84 -16
  102. package/lib/setup/enroll-from-cohort.test.mjs +43 -1
  103. package/lib/setup/sections/identity.mjs +15 -4
  104. package/lib/setup/sections/identity.test.mjs +94 -0
  105. package/lib/setup/sections/inventory.mjs +178 -0
  106. package/lib/setup/sections/inventory.test.mjs +198 -0
  107. package/lib/setup/sections/mandate.mjs +392 -0
  108. package/lib/setup/sections/mandate.test.mjs +373 -0
  109. package/lib/setup/sections/subagents.mjs +427 -0
  110. package/lib/setup/sections/subagents.test.mjs +429 -0
  111. package/lib/setup/sections/verify.mjs +121 -0
  112. package/lib/setup/sections/verify.test.mjs +175 -0
  113. package/lib/setup/sot.mjs +2 -0
  114. package/lib/subagents/cli.mjs +463 -0
  115. package/lib/subagents/cli.test.mjs +389 -0
  116. package/lib/subagents/client.mjs +373 -0
  117. package/lib/subagents/client.test.mjs +309 -0
  118. package/lib/subagents/gap.mjs +268 -0
  119. package/lib/subagents/gap.test.mjs +234 -0
  120. package/lib/subagents/lock.mjs +296 -0
  121. package/lib/subagents/lock.test.mjs +248 -0
  122. package/lib/subagents/manifest.mjs +224 -0
  123. package/lib/subagents/manifest.test.mjs +175 -0
  124. package/lib/subagents/refs.mjs +274 -0
  125. package/lib/subagents/refs.test.mjs +204 -0
  126. package/lib/subagents/resolve.mjs +455 -0
  127. package/lib/subagents/resolve.test.mjs +422 -0
  128. package/lib/subagents/schema.mjs +467 -0
  129. package/lib/subagents/schema.test.mjs +306 -0
  130. package/package.json +9 -4
  131. package/plugins/maestro-skills/.claude-plugin/marketplace.json +16 -0
  132. package/policies/ai-disclosure.yaml +42 -2
  133. package/scaffold/CLAUDE.md +16 -2
  134. package/schedules/triggers/goal-steward.md +79 -0
  135. package/scripts/ci/conformance-org-api.mjs +792 -0
  136. package/scripts/ci/conformance-org-api.test.mjs +417 -0
  137. package/scripts/daemon/agent-daemon.mjs +70 -11
  138. package/scripts/daemon/cadence-handlers.mjs +187 -5
  139. package/scripts/daemon/goal-steward-cadence.test.mjs +243 -0
  140. package/scripts/daemon/inbox-deferral.mjs +45 -2
  141. package/scripts/daemon/inbox-deferral.test.mjs +56 -0
  142. package/scripts/daemon/inbox-wake.mjs +282 -0
  143. package/scripts/daemon/inbox-wake.test.mjs +199 -0
  144. package/scripts/daemon/maestro-daemon.mjs +23 -0
  145. package/scripts/daemon/prompt-builder.mjs +41 -1
  146. package/scripts/daemon/responder.mjs +56 -0
  147. package/scripts/daemon/typing-registry.mjs +55 -2
  148. package/scripts/daemon/typing-registry.test.mjs +25 -0
  149. package/scripts/local-triggers/generate-plists.test.mjs +5 -5
  150. package/scripts/poller/inbox-scan-poller.mjs +26 -1
  151. package/scripts/poller/inbox-scan-poller.test.mjs +64 -0
  152. package/scripts/poller/slack-cloud-relay-client.mjs +5 -0
  153. package/scripts/poller/slack-poller.mjs +32 -0
  154. package/scripts/poller/slack-socket-mode.mjs +27 -1
  155. package/scripts/poller/slack-socket-mode.test.mjs +52 -0
  156. package/scripts/poller/utils.mjs +47 -0
  157. package/scripts/setup/gen-subagent-manifest.mjs +95 -0
  158. package/scripts/setup/gen-subagent-manifest.test.mjs +124 -0
  159. package/scripts/setup/generate-plan.mjs +108 -0
  160. package/scripts/setup/init-capability-manifest.mjs +70 -0
  161. package/scripts/setup/init-skill-marketplace.mjs +155 -0
  162. package/scripts/setup/init-skill-marketplace.test.mjs +193 -0
@@ -38,6 +38,7 @@ import {
38
38
  okFrame,
39
39
  errFrame,
40
40
  } from "./protocol.mjs";
41
+ import { normalizeParams, logReportOnce, toRegisterParams } from "./param-contract.mjs";
41
42
  import { applyBrandEnvCompat } from "../env-compat.mjs";
42
43
 
43
44
  // SDK self-bridge: agent instances import this module directly from their own
@@ -63,13 +64,31 @@ const DEFAULT_TIMEOUT_MS = 8000;
63
64
  * @returns {object}
64
65
  */
65
66
  export function loadOrgConfig(agentRoot) {
67
+ const p = join(agentRoot, "config", "org.yaml");
68
+ // Genuinely absent is not a fault: an un-enrolled agent has no org.yaml.
69
+ if (!existsSync(p)) return {};
66
70
  try {
67
- const p = join(agentRoot, "config", "org.yaml");
68
- if (!existsSync(p)) return {};
69
71
  const require = createRequire(import.meta.url);
70
72
  const yaml = require("js-yaml");
71
73
  return yaml.load(readFileSync(p, "utf8")) || {};
72
- } catch {
74
+ } catch (err) {
75
+ // The file EXISTS but could not be read/parsed. Returning {} silently here
76
+ // is how an enrolled agent ends up quietly un-enrolled: `isEnabled({})` is
77
+ // false, so the org mesh no-ops, the daemon logs nothing, and the agent
78
+ // never beats or receives the kill switch. Observed in the wild — the
79
+ // scaffold's package.json omitted `js-yaml`, so this require() threw on
80
+ // every freshly-created agent and the org integration was simply off.
81
+ //
82
+ // Still fail-open (callers depend on never throwing), but never silent.
83
+ const why = /Cannot find module 'js-yaml'/.test(String(err && err.message))
84
+ ? "js-yaml is not installed — run `npm install` (it is a required runtime dep)"
85
+ : String(err && err.message);
86
+ try {
87
+ console.warn(
88
+ `[org] config/org.yaml exists but could not be loaded (${why}). ` +
89
+ `Treating this agent as NOT enrolled — it will not join the org mesh.`,
90
+ );
91
+ } catch { /* never throw from logging */ }
73
92
  return {};
74
93
  }
75
94
  }
@@ -250,9 +269,18 @@ export async function pairRequest(o = {}) {
250
269
  * posts the params object, and returns the parsed res frame. Fail-open: a
251
270
  * transport error yields an INTERNAL error frame rather than throwing.
252
271
  *
272
+ * THE WIRE-CONTRACT CHOKEPOINT. Every plane reaches hq through this one function
273
+ * — the 2 090 `ui-parity.mjs` wrappers, the curated `tool-surface.mjs` table, the
274
+ * `org_rpc` escape hatch, and every hand-written helper in `lib/org/*.mjs` — so
275
+ * `normalizeParams` (lib/org/param-contract.mjs) runs HERE. That is what makes
276
+ * param-name drift a class of bug this SDK cannot ship again: a call site cannot
277
+ * opt out, and a newly added wrapper inherits the contract for free. The rewrite
278
+ * is reported once per distinct (method, rewrite) per process — fail-open, but
279
+ * NEVER silent (silent fail-open is what hid all 18 drift rows).
280
+ *
253
281
  * @param {string} method full dotted method name (e.g. "board.claim")
254
282
  * @param {object} params the params object (request body)
255
- * @param {object} o - { base, token, orgId?, idempotencyKey?, fetchImpl? }
283
+ * @param {object} o - { base, token, orgId?, idempotencyKey?, fetchImpl?, logImpl? }
256
284
  * @returns {Promise<object>} res frame
257
285
  */
258
286
  export async function call(method, params, o = {}) {
@@ -261,15 +289,45 @@ export async function call(method, params, o = {}) {
261
289
  const def = methodDef(method);
262
290
  if (!def) return errFrame("NOT_FOUND", `unknown method ${method}`);
263
291
 
292
+ // Project the caller's params onto the names hq's zod schemas actually read.
293
+ // Pure + total; a method with no contract entry passes through unchanged.
294
+ let wire = params || {};
295
+ try {
296
+ const report = normalizeParams(method, wire);
297
+ wire = report.params;
298
+ logReportOnce(method, report, o.logImpl);
299
+ } catch (err) {
300
+ // Fail-open, but NEVER SILENT. Every production bug in this system has been
301
+ // a quiet catch: the org mesh 401'd for hours without a line, the daemon
302
+ // read the wrong config file and logged "not configured", the inbound filter
303
+ // dropped six DMs saying nothing. If the param contract throws, the call
304
+ // still goes out with the RAW params — which is very likely the drift this
305
+ // module exists to correct — so the operator has to be told, or they debug a
306
+ // BAD_REQUEST with no idea the normaliser bailed.
307
+ try {
308
+ const log = typeof o.logImpl === "function" ? o.logImpl : console.warn;
309
+ log(
310
+ `[org] param contract failed for ${method} (${err && err.message}) — ` +
311
+ `sending RAW params; wire-name drift will NOT be corrected for this call`,
312
+ );
313
+ } catch { /* never throw from logging */ }
314
+ }
315
+
264
316
  const headers = baseHeaders(def.auth === false ? "" : token, def.auth === false ? "" : orgId);
265
317
  if (def.sideEffecting) {
266
- const key = o.idempotencyKey || (def.idempotent ? defaultIdempotencyKey(method, params) : "");
318
+ // Key off the ORIGINAL params too: the contract may have renamed the id the
319
+ // legacy key derivation reads.
320
+ const key =
321
+ o.idempotencyKey ||
322
+ (def.idempotent
323
+ ? defaultIdempotencyKey(method, wire) || defaultIdempotencyKey(method, params)
324
+ : "");
267
325
  if (key) headers["x-idempotency-key"] = String(key);
268
326
  }
269
327
  const r = await httpRequest(o.fetchImpl, v1Url(base, method), {
270
328
  method: "POST",
271
329
  headers,
272
- body: JSON.stringify(params || {}),
330
+ body: JSON.stringify(wire),
273
331
  });
274
332
  return asResFrame(r);
275
333
  }
@@ -553,6 +611,13 @@ export async function fetchHierarchy(o = {}) {
553
611
 
554
612
  /**
555
613
  * register self with the org (registry.register). Idempotent.
614
+ *
615
+ * Accepts EITHER a rich maestro self-entry (assembleSelfEntry shape) or an
616
+ * already-projected registerSchema params object: `call()` runs the wire contract
617
+ * (`toRegisterParams`) and hq's `.strict()` schema sees only
618
+ * {displayName, archetype, humanSponsor, card}. Passing the raw entry used to
619
+ * 400 on every boot while the caller logged success.
620
+ *
556
621
  * @param {object} entry directory entry (id, displayName, role, focus, pubkey…)
557
622
  * @param {object} o - { base, token, idempotencyKey?, fetchImpl? }
558
623
  */
@@ -1559,28 +1624,17 @@ export function brandingRestoreFoundation(params, o = {}) {
1559
1624
  * models { displayName, archetype:string, humanSponsor, card } — so we MUST trim
1560
1625
  * here or a rich entry 400s (BAD_REQUEST). Everything the server doesn't model is
1561
1626
  * preserved REDACTION-SAFE under `card` (the handler records only a `hasCard`
1562
- * boolean on the chain, never the blob). Pure; never throws.
1627
+ * boolean on the chain, never the blob).
1628
+ *
1629
+ * THE projection now lives in lib/org/param-contract (`toRegisterParams`) — the
1630
+ * same table `call()` applies — so `client.register(rawEntry)` and
1631
+ * `publishEntry({entry})` can no longer disagree about the shape. Re-exported
1632
+ * under the historical name for the callers that already import it.
1563
1633
  * @param {object} entry
1564
1634
  * @returns {object} registerSchema-shaped params
1565
1635
  */
1566
- function entryToRegisterParams(entry) {
1567
- const e = entry || {};
1568
- const displayName = e.fullName || e.name || e.displayName || String(e.id || "");
1569
- // archetype is an object in the maestro entry ({function,altitude,label}); the
1570
- // server wants a single string. Prefer the human label, then the function.
1571
- const arch = e.archetype;
1572
- const archetype =
1573
- typeof arch === "string"
1574
- ? arch
1575
- : (arch && (arch.label || arch.function || arch.altitude)) || "";
1576
- const humanSponsor =
1577
- (e.principal && (e.principal.fullName || e.principal.title)) || e.humanSponsor || "";
1578
- const params = { card: e };
1579
- if (displayName) params.displayName = String(displayName).slice(0, 200);
1580
- if (archetype) params.archetype = String(archetype).slice(0, 120);
1581
- if (humanSponsor) params.humanSponsor = String(humanSponsor).slice(0, 200);
1582
- return params;
1583
- }
1636
+ const entryToRegisterParams = toRegisterParams;
1637
+ export { entryToRegisterParams };
1584
1638
 
1585
1639
  /**
1586
1640
  * Publish (upsert) this agent's directory entry — now via the /v1 binding
@@ -8,9 +8,22 @@
8
8
 
9
9
  import { test } from "node:test";
10
10
  import assert from "node:assert/strict";
11
+ import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ /** Run `fn` with console.warn captured; returns the captured lines. */
16
+ function captureWarn(fn) {
17
+ const lines = [];
18
+ const real = console.warn;
19
+ console.warn = (...a) => { lines.push(a.join(" ")); };
20
+ try { fn(); } finally { console.warn = real; }
21
+ return lines;
22
+ }
11
23
 
12
24
  import {
13
25
  isEnabled,
26
+ loadOrgConfig,
14
27
  configFromAgent,
15
28
  pairRequest,
16
29
  call,
@@ -862,7 +875,9 @@ test("knowledgeSearch: side-effecting (POST), Bearer set, posts the query body",
862
875
  assert.equal(f.calls[0].init.method, "POST");
863
876
  assert.equal(f.calls[0].url, `${BASE}/api/v1/knowledge.search`);
864
877
  assert.equal(f.calls[0].headers.authorization, `Bearer ${TOKEN}`);
865
- assert.deepEqual(JSON.parse(f.calls[0].init.body), { query: "q", group: "org", limit: 5 });
878
+ // The wire contract canonicalises `query`→`q` (hq reads `p.q ?? p.query`) and
879
+ // keeps the legacy name alongside for an older server.
880
+ assert.deepEqual(JSON.parse(f.calls[0].init.body), { query: "q", q: "q", group: "org", limit: 5 });
866
881
  assert.deepEqual(frame.result.returned_ids, ["k-1"]);
867
882
  });
868
883
 
@@ -1105,3 +1120,41 @@ test("fetchHierarchy: GET /v1/hierarchy bare payload → {members,reporting}; nu
1105
1120
  const fail = fakeFetch(() => ({ ok: false, status: 503, body: { error: { code: "INTERNAL", message: "down" } } }));
1106
1121
  assert.equal(await fetchHierarchy({ base: BASE, token: TOKEN, orgId: "acme", fetchImpl: fail }), null);
1107
1122
  });
1123
+
1124
+ // ---------------------------------------------------------------------------
1125
+ // loadOrgConfig — an enrolled agent must never go quietly un-enrolled
1126
+ // ---------------------------------------------------------------------------
1127
+
1128
+ test("loadOrgConfig: absent org.yaml is silent (an un-enrolled agent is not a fault)", () => {
1129
+ const dir = mkdtempSync(join(tmpdir(), "orgcfg-"));
1130
+ const warnings = captureWarn(() => {
1131
+ assert.deepEqual(loadOrgConfig(dir), {});
1132
+ });
1133
+ assert.deepEqual(warnings, [], "no file, no noise");
1134
+ });
1135
+
1136
+ test("loadOrgConfig: parses config/org.yaml", () => {
1137
+ const dir = mkdtempSync(join(tmpdir(), "orgcfg-"));
1138
+ mkdirSync(join(dir, "config"), { recursive: true });
1139
+ writeFileSync(
1140
+ join(dir, "config", "org.yaml"),
1141
+ "org:\n cohort:\n enabled: true\n base: https://os.example.com\n",
1142
+ );
1143
+ const cfg = loadOrgConfig(dir);
1144
+ assert.equal(cfg.org.cohort.enabled, true);
1145
+ assert.equal(isEnabled(cfg), true);
1146
+ });
1147
+
1148
+ test("loadOrgConfig: a PRESENT but unparseable org.yaml WARNS (never silently un-enrols)", () => {
1149
+ // Returning {} quietly is how an enrolled agent ends up out of the mesh:
1150
+ // isEnabled({}) is false, so the daemon no-ops, never beats, and cannot
1151
+ // receive `halt`. Fail-open is right; silence is not.
1152
+ const dir = mkdtempSync(join(tmpdir(), "orgcfg-"));
1153
+ mkdirSync(join(dir, "config"), { recursive: true });
1154
+ writeFileSync(join(dir, "config", "org.yaml"), "org:\n cohort:\n - [unbalanced\n");
1155
+ let cfg;
1156
+ const warnings = captureWarn(() => { cfg = loadOrgConfig(dir); });
1157
+ assert.deepEqual(cfg, {}, "still fail-open");
1158
+ assert.match(warnings.join("\n"), /could not be loaded/, "the operator must be told");
1159
+ assert.match(warnings.join("\n"), /NOT enrolled/, "and told what it means");
1160
+ });
@@ -190,6 +190,70 @@ export async function checkOrgConnectivity(o = {}) {
190
190
  results.push({ level: "warn", msg: `pairing probe inconclusive (${chan.error?.code || "?"}: ${chan.error?.message || "no detail"})` });
191
191
  }
192
192
 
193
+ // ── 3b. INBOUND PATH probe — can this agent actually RECEIVE a message? ───
194
+ //
195
+ // Pairing proves the key is bound to a member. It does NOT prove the agent
196
+ // can hear anything, and those are different failures with the same healthy
197
+ // appearance. In production an enrolled, beating, correctly-paired agent was
198
+ // deaf for hours: the inbound cadence read org config from the wrong file, and
199
+ // the history call sent `channel` where hq validates `channelId`. Doctor was
200
+ // green throughout, because nothing exercised the receive path.
201
+ //
202
+ // So exercise it: read history from a real channel the agent belongs to. That
203
+ // single call covers the param contract, the read scope, and the ACL. A
204
+ // BAD_REQUEST here means protocol drift between this SDK and the server —
205
+ // the class of bug that leaves a fresh machine silently mute.
206
+ if (chan.ok) {
207
+ const rooms = chan.result && (Array.isArray(chan.result.channels) ? chan.result.channels : Array.isArray(chan.result) ? chan.result : []);
208
+ const probeRoom = (rooms || []).map((c) => c && (c.id || c.channelId)).find(Boolean);
209
+ if (!probeRoom) {
210
+ results.push({ level: "warn", msg: "inbound probe skipped — this member is in no channels yet" });
211
+ } else {
212
+ const hist = await call("messaging.history", { channelId: probeRoom, limit: 1 }, callOpts);
213
+ if (hist.ok) {
214
+ results.push({ level: "ok", msg: "inbound path works (messaging.history readable)" });
215
+ } else if (hist.error && hist.error.code === "BAD_REQUEST") {
216
+ results.push({
217
+ level: "fail",
218
+ msg:
219
+ `inbound path BROKEN — messaging.history rejected this SDK's params ` +
220
+ `(${hist.error.message || "BAD_REQUEST"}). The agent cannot read messages ` +
221
+ `sent to it. This is SDK/server protocol drift: upgrade with ` +
222
+ `\`npm update @cohortapp/agent-sdk && npx @cohortapp/agent-sdk upgrade\``,
223
+ });
224
+ } else {
225
+ results.push({
226
+ level: "warn",
227
+ msg: `inbound probe inconclusive (${hist.error?.code || "?"}: ${hist.error?.message || "no detail"})`,
228
+ });
229
+ }
230
+ }
231
+ }
232
+
233
+ // ── 3c. Is org enrolment visible to the DAEMON's config loader? ───────────
234
+ //
235
+ // The org client reads config/org.yaml; the cadence guards read the agent cfg.
236
+ // When those disagree the agent enrols successfully and still ignores every
237
+ // inbound event, because each org-facing guard bails out with "org messaging
238
+ // not configured". Assert the daemon's own view here rather than the client's.
239
+ try {
240
+ const { loadOrgConfig: loadOrg } = await import("./client.mjs");
241
+ const daemonView = loadOrg(agentRoot);
242
+ if (!daemonView || !daemonView.org || !daemonView.org.cohort) {
243
+ results.push({
244
+ level: "fail",
245
+ msg:
246
+ "org enrolment is NOT visible to the daemon's config loader — every " +
247
+ "org-facing cadence (messaging-inbound, brand-steward, …) will silently " +
248
+ "do nothing. Check config/org.yaml exists and js-yaml is installed.",
249
+ });
250
+ } else {
251
+ results.push({ level: "ok", msg: "org enrolment visible to the daemon cadences" });
252
+ }
253
+ } catch (err) {
254
+ results.push({ level: "warn", msg: `daemon org-config check failed (${err && err.message})` });
255
+ }
256
+
193
257
  // ── 4/5. workspace mailbox ───────────────────────────────────────────────
194
258
  const orgmailGate = existsSync(join(agentRoot, "config", "orgmail.yaml"));
195
259
  if (orgmailGate) {
@@ -62,6 +62,8 @@ function stubFetch(routes) {
62
62
  }
63
63
 
64
64
  const DIR_OK = { status: 200, body: { members: [] }, headers: { "x-org-protocol": String(PROTOCOL_VERSION) } };
65
+ const HISTORY_OK = { status: 200, body: { ok: true, result: { messages: [] } } };
66
+ const HISTORY_BAD_PARAMS = { status: 400, body: { ok: false, error: { code: "BAD_REQUEST", message: "channelId: Required" } } };
65
67
  const CHANNELS_OK = { status: 200, body: { ok: true, result: { channels: [{ id: "c1" }, { id: "c2" }] } } };
66
68
 
67
69
  const levelsOf = (rs) => rs.map((r) => r.level);
@@ -80,10 +82,17 @@ test("probe 1: not enrolled → single ok line, no network", async () => {
80
82
 
81
83
  test("probe 2: happy path — reachable + latency + aligned protocol header + paired", async () => {
82
84
  const r = root();
83
- const f = stubFetch({ "/api/v1/directory": DIR_OK, "/api/v1/messaging.channels": CHANNELS_OK });
85
+ const f = stubFetch({
86
+ "/api/v1/directory": DIR_OK,
87
+ "/api/v1/messaging.channels": CHANNELS_OK,
88
+ "/api/v1/messaging.history": HISTORY_OK,
89
+ });
84
90
  let t = 1000;
85
91
  const rs = await checkOrgConnectivity({ agentRoot: r, fetchImpl: f, env: {}, now: () => (t += 40) });
86
- assert.deepEqual(levelsOf(rs), ["ok", "ok", "ok", "ok"]);
92
+ // …reachable, protocol, paired, INBOUND PATH, daemon-visible org config.
93
+ assert.deepEqual(levelsOf(rs), ["ok", "ok", "ok", "ok", "ok", "ok"]);
94
+ assert.match(msgs(rs), /inbound path works/);
95
+ assert.match(msgs(rs), /visible to the daemon cadences/);
87
96
  assert.match(msgs(rs), /directory \d+ms/);
88
97
  assert.match(msgs(rs), /protocol version aligned/);
89
98
  assert.match(msgs(rs), /paired to a workforce member \(2 channel\(s\)\)/);
@@ -210,3 +219,23 @@ test("no orgmail gate file → no mailbox probe at all", async () => {
210
219
  assert.ok(!f.calls.some((c) => c.url.includes("email.inbox")));
211
220
  rmSync(r, { recursive: true, force: true });
212
221
  });
222
+
223
+ test("inbound probe FAILS loudly when the server rejects this SDK's params", () => {
224
+ // The real incident: fetchHistory sent `channel`, hq validates `channelId`,
225
+ // so the agent could not read a single message sent to it — while every other
226
+ // doctor line stayed green. Protocol drift must be a FAIL, not a shrug.
227
+ const r = root();
228
+ const f = stubFetch({
229
+ "/api/v1/directory": DIR_OK,
230
+ "/api/v1/messaging.channels": CHANNELS_OK,
231
+ "/api/v1/messaging.history": HISTORY_BAD_PARAMS,
232
+ });
233
+ return checkOrgConnectivity({ agentRoot: r, fetchImpl: f, env: {} }).then((rs) => {
234
+ const bad = rs.find((x) => /inbound path BROKEN/.test(x.msg));
235
+ assert.ok(bad, "a rejected inbound probe must surface");
236
+ assert.equal(bad.level, "fail");
237
+ assert.match(bad.msg, /protocol drift/);
238
+ assert.match(bad.msg, /upgrade/);
239
+ rmSync(r, { recursive: true, force: true });
240
+ });
241
+ });